제목, 태그, 카테고리로 검색

모든 글
약 11분 분량 프로젝트/위키엔진

AI 검색 요약 — RAG 파이프라인 + SSE 스트리밍 + 비용 모니터링

목차

이전 글

콘텐츠 필터링 — 운영 안전장치에서 Aho-Corasick 금칙어 필터링, 블라인드 게시글 검색 제외, Negative Caching, 자동완성 안전장치를 구축했습니다.

지표결과
금칙어 필터링Aho-Corasick O(N+Z) 탐지, 16,090개
블라인드Occur.MUST_NOT blinded=true
Negative Caching빈 결과 30초 TTL (cache penetration 방지)

검색 기능(품질, 인프라, 안전장치)이 모두 갖춰졌습니다. 이 글에서 검색 결과를 AI가 요약하여 사용자에게 직접적인 답변을 제공합니다.


1. 정상 상태 — 현재 검색 경험

현재 검색 경험 — 문서 목록만 제공

프론트엔드에 “AI 요약” 섹션이 있지만, 현재 구현은 검색 결과와 무관하게 LLM에 쿼리만 전달하는 방식이다 — 검색된 문서를 컨텍스트로 주입하지 않으므로 할루시네이션 위험이 높고, 출처 인용이 불가능하다.

기존 구현RAG (이 글)
컨텍스트쿼리만 전달검색된 Top-5 문서 주입
할루시네이션높음 (LLM 자체 지식에 의존)낮음 (문서 기반 답변 제한)
출처 인용불가[문서 N] 인용 + 게시글 링크
답변 품질LLM 학습 데이터 의존실제 인덱스 문서 기반

검색 엔진의 AI 요약 트렌드

서비스AI 검색 기능방식
GoogleAI Overviews (SGE)검색 결과 상단에 AI 요약
Perplexity전체 응답이 AI 요약검색 -> 요약 -> 출처 표시
BingCopilot대화형 검색 + 출처 인용
네이버Cue:AI 기반 답변 + 출처

2. 문제 상황 — 기존 검색의 한계

  1. 정보 탐색 비용: 10개 링크 중 어떤 게시글이 답을 포함하는지 모름
  2. 지식 종합 불가: “자바 GC 종류와 각각의 장단점”처럼 여러 문서에 분산된 정보를 하나로 종합할 수 없음
  3. 질문형 쿼리 미지원: “왜 자바는 포인터가 없나?”처럼 자연어 질문에 대한 직접 답변 불가

RAG로 해결

RAG 파이프라인 — Retrieval → Augmentation → Generation


3. 대안 검토

LLM 선택

모델장점단점판단
OpenAI GPT-4o고품질, API 간편유료, 외부 의존API 비용 고려
Anthropic Claude긴 컨텍스트, 정확한 인용유료, 외부 의존대안
Ollama (로컬 LLM)무료, 데이터 프라이버시로컬 GPU 필요, 품질 제한Free Tier에서 GPU 없음
Google Gemini무료 티어 존재, 한국어 우수API 제한선택

선택: 외부 LLM API (Gemini 무료 티어) + Lucene 검색. Free Tier 서버에서 로컬 LLM은 불가(GPU 없음).

BM25 vs Dense Retrieval

방식Retrieval 품질인프라 비용판단
BM25 (현재 Lucene)키워드 매칭 기반, 충분히 좋음추가 비용 없음선택
Dense Retrieval (임베딩)의미 유사도 기반, 더 정확임베딩 모델 + 벡터 DB 필요후반 도입 검토
Hybrid (BM25 + Dense)최상의 RetrievalBM25 + 벡터 DB 모두 필요최종 목표

BM25가 이 프로젝트에서 충분한 근거: wikiEngine은 나무위키/한국어 위키백과/영어 위키백과/뉴스/웹텍스트 데이터 기반으로 기술 용어 키워드 검색이 주 사용 패턴이다. RAG Retrieval 비교 실험에서 BM25는 키워드 전용 쿼리 NDCG 0.88로 Dense Retrieval(혼합 쿼리 0.65) 대비 우위를 보였다 (출처: BM25 vs Dense Retrievers 비교). 엔지니어링 로그, 금융 데이터, 법률/과학 문서 등 키워드가 명확한 도메인에서는 BM25가 Dense보다 나을 수 있다는 것이 Anthropic RAG 가이드의 평가이기도 하다. “AI” to “인공지능” 수준의 동의어는 쿼리 확장 구현의 DB 기반 쿼리 확장으로 이미 해결되었다.

Retrieval 개선 로드맵

Eugene Yan의 “Search: Query Matching”에서 정리한 것처럼 검색 시스템은 Lexical(BM25) to Graph(동의어) to Embedding(벡터) 순서로 진화합니다.

Retrieval 개선 로드맵 — BM25 → Hybrid

벡터 검색 도입 시 인프라:

벡터 검색 도입 시 인프라 비교


4. 구현

4-1. RAG 파이프라인

RAG 파이프라인 상세 — 5단계

4-2. Context 구성

@Service
public class RAGContextBuilder {
private static final int MAX_CONTEXT_CHARS = 8000; // ~2000 토큰
private static final int MAX_DOCS = 5;
/**
* 검색 결과 Top-N 문서에서 LLM 컨텍스트를 구성한다.
* 문서당 최대 글자 수를 제한하여 컨텍스트 윈도우 내에 맞춘다.
*/
public String buildContext(List<PostSearchResult> results) {
int perDocLimit = MAX_CONTEXT_CHARS / Math.min(results.size(), MAX_DOCS);
StringBuilder context = new StringBuilder();
for (int i = 0; i < Math.min(results.size(), MAX_DOCS); i++) {
PostSearchResult result = results.get(i);
String truncated = truncate(result.getContent(), perDocLimit);
context.append(String.format(
"[문서 %d] 제목: %s\nID: %d\n내용: %s\n\n",
i + 1, result.getTitle(), result.getId(), truncated
));
}
return context.toString();
}
}

4-3. LLM API 호출 — Spring AI 2.0

@Service
public class RagService {
// Spring AI 2.0.0-M4 + spring-ai-starter-model-google-genai (Gemini 2.0 Flash)
public AISummaryResponse summarize(String query, List<PostSearchResult> results) {
String context = buildContext(results);
String systemPrompt = """
당신은 검색 결과를 요약하는 AI 어시스턴트입니다.
아래 제공된 문서만을 참고하여 사용자의 질문에 답변하세요.
규칙:
1. 문서에 없는 내용은 답변하지 마세요.
"제공된 문서에서 해당 정보를 찾을 수 없습니다"라고 답하세요.
2. 답변에 사용한 문서의 번호를 [문서 N] 형태로 인용하세요.
3. 한국어로 답변하세요.
4. 300자 이내로 요약하세요.
""";
String userPrompt = String.format("검색 결과:\n%s\n\n질문: %s", context, query);
String aiResponse = chatClient.prompt(systemPrompt, userPrompt).call().content();
return new AISummaryResponse(
aiResponse,
extractCitations(aiResponse, results),
results
);
}
}

4-4. SSE 스트리밍

LLM 응답은 1~5초 걸릴 수 있으므로, Server-Sent Events로 토큰 단위 스트리밍합니다.

@GetMapping(value = "/search/ai-summary", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter aiSummaryStream(@RequestParam String q) {
SseEmitter emitter = new SseEmitter(30_000L);
// Virtual Thread에서 비동기 실행
Thread.startVirtualThread(() -> {
try {
List<PostSearchResult> results = searchService.search(q);
// SSE 이벤트: delta (토큰), citations (출처), done, skip, error
ragService.streamSummary(q, results, emitter);
} catch (Exception e) {
emitter.complete();
}
});
return emitter;
}

4-5. AI 요약 트리거 조건

모든 검색에 AI 요약을 제공하면 비용 낭비입니다. AiSummaryDecisionService에서 쿼리 의도를 분류한다:

  • 네비게이션 의도 (“네이버”, “구글”, “위키백과”) -> AI 요약 스킵
  • 거래 의도 (“구매”, “가격”) -> AI 요약 스킵
  • 물음표 쿼리 (“자바 GC?”) -> 결과 1건이라도 AI 답변 (Google AI Overviews 동일: 질문형 출현율 28~38%)
  • 일반 쿼리 -> 결과 3건 이상일 때만 AI 답변

4-6. 할루시네이션 방지

전략설명
문서 기반 답변 제한시스템 프롬프트에 “제공된 문서만 참고” 명시
인용 강제답변에 [문서 N] 인용 필수, 인용 없는 문장 경고
Retrieval 품질 확인검색 결과 BM25 스코어가 임계값 미만이면 AI 요약 스킵
사용자 피드백”이 답변이 도움이 되었나요?” 버튼으로 품질 모니터링

5. 비용 분석 + 모니터링

Rate Limiting — Redis Token Bucket

비용 최적화 — Rate Limiting + 캐싱

비용 추정

항목추정 비용
Gemini 2.0 Flash (무료 티어)15 RPM, 1,000 RPD, 250K TPM
Gemini 2.0 Flash (Tier 1 유료)$0.10/1M input + $0.40/1M output
예상 사용량 (일 1,000건 AI 검색)~1.2M input + ~0.3M output 토큰
월 비용 (Tier 1 유료)~$4.8/월
월 비용 (무료)$0 (무료 티어 내, 일 1,000건 한계)

토큰 수 산출 근거: Top-5 문서 x 500자 context = 2,500자. 약 1,000 토큰/건. 시스템 프롬프트(~200 토큰) + 쿼리(~10 토큰) = ~1,210 토큰/건. Output 평균 300자. 약 300 토큰/건.

피드백 시스템

CREATE TABLE ai_summary_feedback (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
query VARCHAR(500) NOT NULL,
rating TINYINT NOT NULL, -- 1=up, 0=down
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
  • Micrometer 카운터: ai_summary_feedback_total{rating=up/down} -> Grafana 자동 노출
  • 활용: thumbs_up_rate 추이 모니터링 -> 부정적 피드백 집중 쿼리 패턴 분석 -> 시스템 프롬프트 수동 개선
  • RLHF는 불가 (자체 모델 아닌 외부 API). 외부 API 호출 서비스에서 피드백은 프롬프트 개선 + 품질 모니터링 용도

Grafana 대시보드

Spring Boot 대시보드에 “AI 요약” 섹션 추가 — 7개 패널:

  • LLM 호출 RPM, 응답시간 avg/max, 토큰 사용량, 피드백 결과, 일별 토큰, 일별 비용 추정
  • 비용 임계값: $1(노랑), $5(빨강) — Gemini 2.0 Flash 단가 기준

6. 검증 — Before/After

AI 요약 + 출처 + 피드백

AI 요약 + 출처 + 피드백

AI 요약 전체 화면 (검색 결과와 함께)

AI 요약 전체

AI 요약 전체 흐름 데모

AI 요약 전체 흐름 데모

“자바” 검색 -> BM25 Top-5 문서 Retrieval -> Gemini 2.0 Flash SSE 스트리밍 -> 토큰 단위 타이핑 -> 인라인 출처 배지 + 하단 출처 링크 표시. 검색 결과는 즉시 렌더링되고, AI 요약은 별도 SSE 채널로 비동기 수신.

트리거 스킵 — 네비게이션 의도

트리거 스킵

“네이버” 검색 시 AI 요약 미표시 (네비게이션 의도 — 사용자가 네이버에 가고 싶은 것이지, 네이버에 대한 설명을 원하는 게 아님).

자동완성 자모 매칭 + prefix 하이라이트

자동완성 하이라이트

Grafana AI 요약 대시보드

Grafana AI 요약 대시보드

  • LLM 호출 수 (RPM): gemini-2.5-flash-lite 모델별 분당 요청 수
  • LLM 응답시간 (ms): avg ~1.5초, max ~2초
  • 토큰 사용량 (per min): input ~100 tokens/min, output ~20 tokens/min
  • AI 요약 피드백: thumbs up 1건 기록

7. 배포 이슈 및 해결

운영 환경 배포 시 발견된 문제들. 모두 로컬(ddl-auto: update)에서는 자동 처리되지만 운영(ddl-auto: validate + Flyway)에서는 수동 조치가 필요한 차이에서 기인합니다.

#이슈원인해결
1ai_summary_feedback.rating 타입 불일치DB: TINYINT, Hibernate 기대: INTEGER@Column(columnDefinition = “TINYINT”) 명시
2GEMINI_API_KEY 환경변수 미전달Ansible env 템플릿 + docker-compose에 누락docker-compose environment + .env.prod에 추가
3posts.blinded 컬럼 미존재Flyway V4 마이그레이션 누락V4 마이그레이션 추가 (idempotent SQL)
4Flyway V4 실패 기록 잔존이전 시도에서 실패 -> flyway_schema_history에 success=0 기록수동 DELETE + idempotent SQL로 재실행
5Replica DB 스키마 불일치MySQL Replication 끊김 -> DDL 전파 안 됨banned_words, ai_summary_feedback 테이블 + blinded 컬럼 수동 추가
6Ansible 헬스체크 타임아웃42GB Lucene 인덱스 로딩으로 부팅 3분+Docker restart policy로 자동 재시작

교훈

  1. 운영 배포 전 Flyway 마이그레이션 체크리스트 필요: 엔티티 변경 -> V 마이그레이션 작성 -> Replica 전파 확인
  2. docker-compose environment 블록에 새 환경변수 추가 누락 방지: Ansible 템플릿 변경 시 서버1(environment)/서버2(env_file) 차이 인지
  3. MySQL Replication 상태 모니터링 필요: 끊기면 Flyway DDL이 Replica에 전파 안 됨

8. snippetSource 개선 — Wikipedia CirrusSearch 패턴

문제

검색 결과에서 일부 문서의 snippet이 빈 문자열로 표시되는 현상.

"삼성전자" -> snippet 없음 (빈 문자열)
"삼성전기 삼성SDI 삼성SDS 삼성코닝." -> snippet 정상 표시

원인 분석

snippetSource stored field에 raw 위키 마크업이 그대로 저장되어 있었다:

snippetSource = "[include(틀:회원수정)]\n[include(틀:삼성)]\n[include(틀:삼성전자)]..."

UnifiedHighlighter가 마크업 토큰에서 offset 불일치 -> 빈 snippet 반환.

현업 사례

시스템접근법
Wikipedia CirrusSearchsource_text(raw) + text(clean) 별도 필드. 검색/하이라이팅은 clean text 사용
Elasticsearch 공식Ingest Pipeline html_strip processor로 target_field에 clean text 저장
SolrHTMLStripFieldUpdateProcessorFactory로 인덱스 타임에 정제

결론: raw 마크업을 stored field에 저장하고 쿼리 타임에 정리하는 것은 안티패턴. 인덱스 타임에 clean text를 저장하는 것이 업계 표준.

해결

LuceneIndexService.toDocument() 변경:

// Before: raw 위키 마크업 저장
String snippetSource = content.substring(0, 500);
// After: clean plain text 저장 (Wikipedia CirrusSearch 패턴)
String cleaned = PostSearchResponse.stripMarkup(content.substring(0, 1500));
String snippetSource = cleaned.substring(0, Math.min(cleaned.length(), 500));

마크업이 콘텐츠의 30~60%를 차지하므로, 원본 1500자에서 정리하여 500자 clean text 확보.

항목BeforeAfter
snippetSource 내용raw 위키 마크업clean plain text
유의미한 텍스트 밀도40~70% (마크업 포함)100%
Highlighter 매칭 성공률낮음 (마크업 토큰 간섭)높음

이전 글 전체 목록

이 글은 검색 고도화 시리즈의 마지막 글입니다:

순서핵심
1카테고리 검색 필터링 + Facet 집계Lucene FILTER 절, DB GROUP BY Facet
2쿼리 확장 + Query Understanding동의어, 오타 교정, UnifiedHighlighter, 재색인 인프라
3LTR 재랭킹 + 카테고리 자동 분류XGBoost LambdaMART, LLM-as-a-Judge, Facet 네이티브 전환
4콘텐츠 필터링 — 운영 안전장치Aho-Corasick 금칙어, 블라인드, Negative Caching
5AI 검색 요약 — RAG (이 글)RAG 파이프라인, SSE, 비용 모니터링

다음 글

WikiEngine 총정리에서 26편 전체 시리즈의 아키텍처 진화, 핵심 수치, 자동완성 시스템 설계 이론과 실제 구현의 매핑을 정리합니다.


출처

Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.