1편에서는 답변 생성을 각자의 코딩 에이전트에 맡기고, 서버는 E-GENE 관련 근거만 돌려주는 구조로 바꾼 이야기를 했다. 서버의 역할이 "정확한 근거 반환"으로 분명해지자 다음 고민이 생겼다.
어떻게 검색 품질을 높이고, 근거 없는 답변을 막을 것인가?
이번 글은 초기 인덱스인 Blue를 만들며 발견한 기술적 한계와, 이를 하나씩 고쳐 Green 인덱스를 만들기까지의 과정이다.
1. 초기 인덱스 Blue 설계와 구현
먼저 지식 출처를 네 종류로 정하고 수집 파이프라인을 만들었다.
- 사내 기술 커뮤니티 글
- 공식 문서
- 표준 DB 스키마
- 영속화 스킬 지식
영속화 스킬 지식은 기존 문서만으로 설명되지 않는 사내 지식의 빈틈을 메우는 데이터다. 수집·정제와 MCP 반영 파이프라인은 따로 다룬다.
커뮤니티 글 수집 구조
크롤러는 로컬 PC에서 돌게 했다. GCP Cloud Run 같은 데이터센터 IP에서 보낸 수집 요청이 네이버 카페에서 차단됐기 때문이다. 그래서 크롤러만 로컬 PC의 스케줄러에서 실행하고, 바뀐 데이터를 주기적으로 수집하게 했다.
화면 표시용 본문과 임베딩 입력 분리
사용자에게 보여 줄 원문에는 URL과 작성일 같은 정보가 필요하다. 하지만 이런 정보는 임베딩 입력에서는 검색과 직접 관련 없는 잡음이 된다. 그래서 화면 표시용 본문과 임베딩 입력 텍스트를 나누고, 임베딩 입력에는 문서 제목, 섹션 경로, 핵심 본문만 넣었다.
코드 블록 보존
SQL과 스크립트 코드 블록은 청킹할 때 중간에 잘리지 않게 통째로 보존했다. 코드가 중간에서 갈라지면 실행 흐름이나 조건문의 의미를 잃기 때문에, 코드 블록의 완전성을 먼저 지켰다.
E-GENE 버전 메타데이터
E-GENE 버전에 따른 문서 차이를 다루려고 버전 정보를 메타데이터로 관리했다. 다만 본문에 있는 JBoss·Tomcat 버전이나 다른 숫자를 E-GENE 제품 버전으로 잘못 읽을 수 있었다. 이를 막으려고 문서 제목에 적힌 버전만 파싱하도록 가드를 뒀다.
하이브리드 검색
검색은 벡터 검색에 BM25를 결합한 하이브리드 방식이다. 임베딩 모델은 Upstage를 썼다. Firestore 벡터 인덱스의 차원 상한인 2,048차원에 맞춰 임베딩 API 호출 단계에서 출력 차원을 직접 지정했다. 그래서 임베딩을 만든 뒤 따로 차원을 줄이는 과정이 없다.
이렇게 약 7천 건의 문서와 약 1만 2천 개의 청크로 첫 인덱스인 Blue를 만들었다.
2. Blue에서 발견한 세 가지 문제
Blue를 만든 뒤 실제 질의를 넣으며 운영해 보니 세 가지 한계가 분명히 드러났다.
1. 맥락 파괴. Blue는 문서를 고정 1,000자 단위로 잘랐다. 구현은 단순했지만, 메뉴 경로와 속성 설명, 예외 조건이 서로 다른 청크로 갈라졌다. 코드 블록 자체는 지켰어도 코드와 그 앞뒤 설명이 떨어지면서 핵심 설정값이나 적용 조건을 놓치는 경우가 있었다. 검색 결과가 질문과 겉보기에 비슷해도, 답에 필요한 조건이 다른 청크에 있으면 정확한 근거로 쓰기 어려웠다.
2. 식별자 검색의 한계. ECF_MENU, MEN_ID 같은 테이블명·컬럼명, SQL ID, 오류 코드는 의미가 비슷한 것보다 정확히 일치하는 것이 중요하다. 그런데 벡터 검색만으로는 이런 식별자가 든 문서를 늘 상위로 끌어오지 못했다. 오타 없이 정확한 식별자로 검색했는데도 의미가 비슷한 일반 매뉴얼이 먼저 나오기도 했다.
3. 긴 매뉴얼의 상위 독점. 여러 질의어를 폭넓게 담은 긴 매뉴얼 청크가 반복해서 상위에 올라왔다. 그 바람에 특정 질문에 대한 답이 명확히 담긴 짧은 청크가 뒤로 밀렸다. 결과의 양은 충분했지만, 정작 필요한 구체적 근거가 상위에 없었다.
3. Green에 적용한 개선
Blue의 문제를 풀려고 Green 인덱스에 세 가지를 적용했다.
문서 구조를 보존하는 청킹
기계적인 고정 길이 분할을 버리고, 소제목과 코드 블록, 표 같은 문서 본래의 구조를 기준으로 청크를 나눴다. 청킹 방식에 따른 성능을 보려고 같은 문서군에 일곱 가지 전략을 적용하고 평가 질의 38개로 비교했다.
| 청킹 전략 | 청크 수 | RAGAS 점수 |
|---|---|---|
| 고정 1,000자 / 겹침 200자 | 1,972 | 0.767 |
| 재귀 800자 / 겹침 120자 | 2,164 | 0.762 |
| 구조 보존 900자 | 2,016 | 0.758 |
| 스키마 인지 800자 | 2,135 | 0.750 |
| 재귀 512자 / 겹침 80자 | 3,307 | 0.717 |
| 고정 512자 / 겹침 64자 | 3,252 | 0.706 |
| 재귀 384자 / 겹침 64자 | 4,391 | 0.688 |
예상과 달리 고정 1,000자가 가장 좋았고, 청크를 잘게 나눌수록 문맥이 끊기면서 점수가 일관되게 떨어졌다. 다만 DB 스키마 정의서는 일반 문서와 성격이 달랐다.
스키마 문서는 테이블 설명과 컬럼 목록이 한 구조를 이룬다. 컬럼 목록이 여러 청크로 갈라지면 어떤 컬럼이 어느 테이블에 속하는지 알기 어렵다. 스키마 정의서의 컬럼 목록을 일정 길이까지 한 청크로 유지했더니 관련 질의의 회수 성능이 약 10% 좋아졌다. 그래서 스키마 정의서에만 예외를 둬, 최대 6,000자까지 한 청크로 유지하는 전용 로직을 만들었다.
TARGET_CHARS = 1200
MAX_CHARS = 2000
SCHEMA_MAX_CHARS = 6000
if source_type == "schema":
pieces = (
[text]
if len(text) <= SCHEMA_MAX_CHARS
else _pack_blocks(
_split_blocks(text),
TARGET_CHARS,
SCHEMA_MAX_CHARS,
)
)벡터 + BM25 + 식별자 역색인의 가중 융합
자연어 질의와 정확한 식별자 검색을 함께 처리하려고 검색 구조를 넓혔다. 벡터 검색과 BM25에 더해 문서에서 다음 식별자를 뽑았다.
- 테이블명
- 컬럼명
- SQL ID
- 오류 코드
약 14,000개의 식별자로 별도 역색인을 만들고, 벡터·BM25·식별자 검색이 돌려준 순위를 RRF(Reciprocal Rank Fusion)로 합쳤다. 가중치는 벡터 1.0, BM25 0.2, 식별자 0.5다.
벡터 검색을 기본 신호로 쓰면서, 정확한 식별자가 있으면 식별자 검색 결과에 점수를 더 주는 방식이다. 덕분에 긴 매뉴얼 청크의 상위 독점이 줄고, 정확한 테이블명이나 컬럼명이 든 짧은 정의형 청크가 위로 올라왔다.
# 가중치 설정: 벡터 1.0 / BM25 0.2 / 식별자 0.5
w_vec = 1.0
w_bm25 = 0.2
w_exact = 0.5
RRF_K = 60
# 식별자 역색인 매칭: 짧은 정의형 청크 우선
idents = self._query_idents(query)
exact_hits: list[int] = []
for ident in idents:
exact_hits.extend(self.exact[ident])
exact_hits = sorted(
set(exact_hits),
key=lambda o: self.chunks[o]["doc_len"],
)[:20]
# Vector + BM25 + 식별자 가중 RRF
fused: Counter = Counter()
for rank, (ord_, _, _, _) in enumerate(vec, 1):
fused[ord_] += w_vec / (RRF_K + rank)
for rank, (ord_, _) in enumerate(bm, 1):
fused[ord_] += w_bm25 / (RRF_K + rank)
for rank, ord_ in enumerate(exact_hits, 1):
fused[ord_] += w_exact / (RRF_K + rank)변형 질의를 함께 처리하는 배치 검색
사용자나 코딩 에이전트는 같은 의도의 질문을 서로 다른 표현으로 보낸다. 예를 들어 아래 질문들은 표현은 달라도 같은 정보를 찾는다.
MEN_ID는 어디에서 관리하나요?- 메뉴 ID가 저장된 테이블은 무엇인가요?
ECF_MENU의 키 컬럼을 알려 주세요.
이런 변형 질의를 함께 처리하도록 search_knowledge 도구에 배치 검색을 넣었다. 최대 3개의 변형 질의를 병렬로 검색하고, 각 결과를 다시 RRF로 합친다. 여러 질의를 한꺼번에 검색하면 문서가 너무 많아질 수 있어서 최종 컨텍스트에는 토큰 상한을 걸었다.
4. 시도했지만 기각한 기술
개선하면서 여러 보조 기술도 검토했다. 하지만 모든 기술이 검색 품질 향상으로 이어지지는 않았다. 노이즈를 만들거나 오히려 품질을 떨어뜨린 방식은 최종 구성에서 뺐다.
이미지 캡션 임베딩
문서 속 이미지도 검색에 쓰려고 Gemini Vision으로 캡션을 만들어 임베딩 입력에 넣었다. 그런데 이미지의 실제 의미를 제대로 설명하지 못하는 모호한 캡션이 나왔고, 이 텍스트가 무관한 문서를 상위로 끌어올리는 오염도 생겼다. 그래서 EGENE_CAPTION_TRUSTED 게이트 플래그를 두고, 검증된 캡션만 검색에 쓰고 검증되지 않은 캡션은 기본으로 껐다.
LLM 리랭커
기본 검색의 상위 20개 후보를 LLM이 다시 읽고 순서를 바꾸는 리랭커도 실험했다. 보통 리랭커는 검색 결과를 개선하려고 쓰지만, 이 데이터에서는 반대였다. LLM이 E-GENE 고유 식별자나 내부 용어보다 자연어로 그럴듯한 문서를 높게 쳐서, 정답 문서를 오히려 뒤로 밀어냈다.
| 지표 | 기본 검색 | 리랭크 적용 |
|---|---|---|
| recall@5 | 0.770 | 0.516 |
| recall@10 | 0.865 | 0.730 |
| hit@5 | 0.905 | 0.810 |
| MRR | 0.854 | 0.600 |
| nDCG@10 | 0.808 | 0.571 |
모든 주요 지표가 떨어져서 LLM 리랭커는 뺐다. 이후 answer_question 같은 답변 생성 도구도 서버에서 없앴다. 서버가 답을 만들지 않고 정확한 근거만 돌려주도록 역할을 좁혀, 검색 전용 MCP 서버로 특화했다.
5. Green 인덱스 완성과 평가 준비
앞의 개선안을 합쳐 Green 인덱스를 완성했다. 그리고 다음 환경 변수로 재배포 없이 활성 인덱스를 바꿀 수 있게 했다.
KB_ACTIVE_INDEX=blue|green
이제 초기 구현인 Blue와 개선판인 Green을 정량 지표와 블라인드 테스트로 비교해, 운영 인덱스를 바꿀지 정하는 일이 남았다. 3편에서는 다음을 다룬다.
- Blue와 Green의 정량 평가 결과
- 검색 결과만 비교한 블라인드 테스트
- 예상과 달랐던 지표의 반전
- 최종 인덱스 채택 결과
- 지금 운영 중인 검색 서빙 구조