본문 바로가기

프로젝트/Techfork

검색 품질을 5단계 실험으로 개선한 과정 — 필드 가중치부터 쿼리 구조까지

들어가며

TechFork는 기술 블로그 글을 수집해서 검색과 추천을 제공하는 서비스입니다. 검색은 BM25 기반 lexical search와 embedding 기반 semantic search를 결합한 하이브리드 구조로 동작하는데, 이 구조에서 "어떤 필드에 얼마만큼의 가중치를 줄 것인가", "kNN 탐색 범위는 어느 정도가 적당한가", "쿼리 구조는 어떻게 짜야 점수가 왜곡되지 않는가" 같은 질문에는 이론만으로 답하기 어렵습니다.

 

그래서 LLM 기반 Ground Truth를 구축하고, nDCG@K와 Recall@K를 측정하는 평가 프레임워크를 만든 뒤, 총 5단계에 걸쳐 설정을 실험적으로 좁혀갔습니다. 이 글은 각 단계에서 무엇을 바꿨고, 수치가 어떻게 나왔고, 왜 그 결론을 내렸는지를 정리한 실험 이력입니다.

평가 지표 정의와 Ground Truth 구축 과정은 검색 평가용 Ground Truth 구축 정리 글을 참고해주세요.

검색 파이프라인의 전체 구현 구조는 별도의 검색 구현 구조 정리 글에서 다룹니다.

 

검색 평가용 Ground Truth 구축 정리

개요검색 평가용 ground-truth는 실제 기술 블로그 문서를 기반으로 검색 쿼리를 생성하고, 다양한 검색 방식이 반환한 후보 문서들을 통합한 뒤, LLM-as-a-Judge로 관련도를 판정하는 방식으로 구축합

dmoritle.tistory.com


실험 개요

 


실험 환경

  • 총 쿼리 수: 531개 (LLM 기반 Ground Truth)
  • 평가 지표: nDCG@4, nDCG@8, nDCG@20, Recall@4, Recall@8, Recall@20, 평균 응답 시간(ms)
    • K=4는 UI에서 한 줄에 표시되는 결과 수, K=8은 한 화면에 보이는 결과 수, K=20은 한 번의 검색에서 반환하는 전체 결과 수입니다. 임의의 숫자가 아니라 실제 사용자가 보는 단위에 맞춰 설정했습니다.
  • 기반 스택: Elasticsearch 8.x, Spring Boot 3.x
  • 인프라: Oracle Cloud ARM 인스턴스 (4코어, 24GB RAM)

 


Phase 1. 필드 가중치 방향 탐색

목적

검색 품질에 가장 큰 영향을 주는 필드가 무엇인지 방향을 잡는 단계입니다. Title 중심, Summary 중심, Chunk(본문) 중심, 쌍 조합, 균등 가중치, Chunk 제외까지 총 7개 시나리오를 비교했습니다.

시나리오 구성

# 시나리오 Title Summary Chunk
1 Title 중심 0.60 0.20 0.20
2 Summary 중심 0.20 0.60 0.20
3 Chunk 중심 (본문) 0.20 0.20 0.60
4 Title+Summary 중심 0.40 0.40 0.20
5 Summary+Chunk 중심 0.20 0.40 0.40
6 균등 가중치 0.33 0.33 0.33
7 Title+Summary만 0.50 0.50 0.00

결과

시나리오 nDCG@4 nDCG@8 nDCG@20 Recall@20 Latency(ms)
1. Title 중심 0.6778 0.6735 0.7278 0.7823 1165
2. Summary 중심 0.7226 0.7251 0.7635 0.7979 1126
3. Chunk 중심 0.7005 0.6983 0.7332 0.7566 1276
4. Title+Summary 0.7126 0.7118 0.7666 0.8168 1122
5. Summary+Chunk 0.7191 0.7172 0.7556 0.7853 1196
6. 균등 가중치 0.7146 0.7144 0.7591 0.7954 1178
7. Title+Summary만 0.7103 0.7100 0.7674 0.8220 1055

판단

Summary 중심(시나리오 2)이 nDCG@4, nDCG@8에서 가장 높은 값을 보였습니다. 즉 상위 랭킹 품질이 가장 좋았습니다. 한편 Chunk 제외(시나리오 7)는 nDCG@20, Recall@20, latency에서 강점을 보였지만, 상위 4~8건의 정확도에서는 Summary 중심에 밀렸습니다.

 

이 단계의 결론은 단일 최적해를 정하는 것이 아니라 탐색 방향을 Summary 중심으로 좁히는 것이었습니다. Title 중심이나 Chunk 중심은 nDCG@4 기준으로 0.68~0.70 수준에 머물렀기 때문에, 다음 단계에서는 Summary를 주축으로 세부 가중치를 조정하는 방향으로 진행했습니다.

 

수치만 놓고 보면 Chunk 제외(시나리오 7)도 유력한 후보였지만, Summary 중심을 선택한 데는 도메인 특성상의 이유도 있었습니다. 기술 블로그에는 코드 스니펫, 설정 값, 에러 메시지 등 본문에만 존재하는 검색 대상이 많습니다. chunk를 완전히 제외하면 이런 콘텐츠에 대한 매칭 경로가 사라지기 때문에, 현재 Ground Truth에서는 차이가 작더라도 실제 사용에서 edge case를 놓칠 위험이 있다고 판단했습니다. Summary를 주축으로 하되 chunk를 안전장치로 유지하는 구성이 더 안정적이라는 결론입니다.

 


Phase 2. Summary 중심 세부 조정

목적

Phase 1에서 선택한 Summary 중심 구성(0.20/0.60/0.20)을 baseline으로 두고, Summary 비중을 더 높이거나 Title/Chunk 비율을 조정하면 품질이 추가로 개선되는지 확인합니다.

시나리오 구성

# 시나리오 Title Summary Chunk
1 Baseline (Phase 1 선택) 0.20 0.60 0.20
2 Summary 강화 0.15 0.70 0.15
3 Summary 최대 0.10 0.80 0.10
4 Chunk 비중 확대 0.10 0.60 0.30
5 Title 비중 확대 0.30 0.60 0.10
6 Summary+Title 균형 0.30 0.55 0.15
7 Summary+Title 균형 강화 0.35 0.55 0.10

결과

시나리오 nDCG@4 nDCG@8 nDCG@20 Recall@20
1. Baseline (0.20/0.60/0.20) 0.7224 0.7251 0.7624 0.7959
2. Summary 강화 (0.15/0.70/0.15) 0.7262 0.7278 0.7638 0.7945
3. Summary 최대 (0.10/0.80/0.10) 0.7247 0.7237 0.7624 0.7932
4. Chunk 비중 확대 0.7189 0.7208 0.7551 0.7866
5. Title 비중 확대 0.7174 0.7209 0.7640 0.8030
6. Summary+Title 균형 0.7156 0.7210 0.7627 0.8000
7. Summary+Title 균형 강화 0.7149 0.7197 0.7626 0.8007

판단

Summary 강화(0.15/0.70/0.15)가 nDCG 전 구간에서 가장 높은 값을 기록했습니다. 흥미로운 점은 Summary를 0.80까지 올린 시나리오 3이 오히려 소폭 하락했다는 것입니다. Summary가 지배적일수록 좋은 것이 아니라, Title과 Chunk가 보조 신호로서 일정 비중을 유지해야 한다는 의미입니다.

 

이 단계의 핵심 결정은 titleBoost=0.15, summaryBoost=0.70, chunkBoost=0.15를 이후 단계의 기본 필드 가중치로 고정한 것입니다.

 


Phase 3. Chunk 구조 확인

목적

Phase 2에서 정한 가중치를 유지하면서, chunk를 BM25와 Vector 양쪽에서 어떻게 취급할지 확인합니다. chunk를 한쪽에서만 제외하면 품질이 달라지는지, 아니면 chunk의 기여가 미미한지를 판단하는 단계입니다.

결과

시나리오 nDCG@4 nDCG@8 nDCG@20 Recall@20 Latency(ms)
1. Chunk 모두 포함 0.7263 0.7279 0.7640 0.7947 593
2. BM25 Chunk 제외 0.7265 0.7256 0.7647 0.7961 572
3. Vector Chunk 제외 0.7257 0.7253 0.7665 0.8001 587

판단

세 시나리오 간 차이가 매우 작습니다. nDCG@4 기준으로 0.7257~0.7265 범위이고,

Recall@20은 0.7947~0.8001 범위입니다. 실질적으로 어떤 구성을 택해도 품질 차이가 유의미하지 않다는 의미입니다.

 

이 결과 자체가 의미 있는 발견입니다. chunk가 검색 품질에 결정적인 변수가 아니라는 것을 확인했기 때문입니다.

 

이 시점에서는 구조적 안정성을 고려해 Chunk 모두 포함 구성을 유지했지만, 이후 k6 부하 테스트에서 vector chunk kNN 연산이 TPS 병목으로 작용하는 것을 확인한 뒤 vectorChunkBoost를 0으로 변경했습니다. Phase 3에서 "vector chunk를 제외해도 품질 차이가 없다"는 근거가 이미 확보되어 있었기 때문에, 품질 손실 없이 TPS를 개선하는 저비용 최적화로 활용할 수 있었습니다.

 


Phase 4. kNN 파라미터 최적화

목적

필드 가중치를 고정한 채, semantic search의 k(반환 문서 수)와 num_candidates(탐색 후보 수) 조합을 조절합니다. 탐색 범위를 넓히면 이론적으로 더 좋은 후보를 찾을 수 있지만, latency 비용이 따릅니다. 이 trade-off의 실제 양상을 확인하는 단계입니다.

결과

시나리오 nDCG@4 nDCG@8 nDCG@20 Recall@20 Latency(ms)
1. k=20, c=60 0.7371 0.7338 0.7762 0.8110 561
2. k=30, c=90 0.7324 0.7309 0.7679 0.7979 604
3. k=40, c=120 0.7316 0.7324 0.7702 0.8016 604
4. k=50, c=150 0.7304 0.7296 0.7681 0.8013 600
5. k=60, c=200 0.7262 0.7276 0.7640 0.7949 596
6. k=80, c=250 0.7263 0.7239 0.7628 0.7924 600
7. k=100, c=300 0.7223 0.7208 0.7612 0.7920 605

판단

직관과 반대의 결과가 나왔습니다. k와 candidates를 늘릴수록 품질이 올라가리라 예상할 수 있지만, 실제로는 k=20/c=60이 nDCG 전 구간과 Recall@20 모두에서 가장 높은 값을 기록했습니다. latency도 561ms로 가장 낮았습니다.

 

이 현상은 탐색 범위를 넓힐 때 노이즈 문서가 함께 유입되면서 RRF 결합 단계에서의 순위 품질을 오히려 떨어뜨리는 것으로 해석할 수 있습니다. 현재 인덱스 규모(약 5,800건)에서는 작은 탐색 범위가 오히려 노이즈 필터 역할을 한 셈입니다.

결론적으로 k=20, candidates=60을 기준값으로 채택했습니다. 인덱스 규모가 크게 늘어나면 재실험이 필요하겠지만, 현재 규모에서는 이 설정이 품질과 성능 모두에서 가장 유리합니다.

 


Phase 5. 쿼리 구조 개선 (bool → dis_max)

목적

Phase 1~4까지는 필드 가중치와 kNN 파라미터를 조정했지만, BM25 계열 쿼리의 구조 자체는 bool should 기반이었습니다. 이 구조에서는 title과 summary가 exact 매칭과 fuzzy 매칭 양쪽에 동시에 걸릴 때 점수가 과도하게 누적되는 문제가 있었습니다. Phase 5에서는 쿼리 구조를 dis_max로 바꿔 이 구조적 불균형을 해소할 수 있는지 확인합니다.

 

dis_max는 여러 매칭 중 가장 높은 점수를 기본값으로 사용하고, 나머지 매칭의 기여를 tieBreaker로 제어하는 구조입니다. 이렇게 하면 같은 필드가 exact/fuzzy 양쪽에 히트하더라도 점수가 단순 합산되지 않습니다.

결과

시나리오 nDCG@4 nDCG@8 nDCG@20 Recall@20 Latency(ms)
1. 챔피언 재평가 (exact=2.0, tie=0.3) 0.7367 0.7331 0.7755 0.8094 567
2. chunk 너프 (summary=0.75, chunk=0.10) 0.7342 0.7320 0.7741 0.8087 551
3. exact 맹신형 (exact=3.0) 0.7283 0.7257 0.7702 0.8076 552

판단

기존 가중치 설정을 dis_max 구조에 그대로 적용한 시나리오 1이 nDCG 전 구간에서 가장 좋은 값을 보였습니다. chunk 너프(시나리오 2)는 latency가 약간 더 낮았지만 품질 차이는 미미했고, exact 맹신형(시나리오 3)은 오히려 지표가 하락했습니다.

 

exact 매칭 boost를 3.0으로 올렸을 때 품질이 떨어진 것은, 정확히 일치하는 키워드에 지나치게 의존하면 유의어나 관련 개념으로 매칭되는 문서가 밀려나기 때문으로 보입니다. 기술 블로그 검색에서는 사용자가 입력한 키워드와 정확히 같은 단어가 아니더라도 관련 문서가 상위에 올라와야 하므로, exact 매칭은 적당한 수준으로 유지하는 것이 낫습니다.

최종적으로 dis_max 구조 + exactBoost=2.0 + tieBreaker=0.3을 현재 기준 설정으로 채택했습니다.

 


최종 설정 요약

5단계 실험을 거쳐 정해진 현재 검색 설정은 다음과 같습니다.

항목 결정 단계
titleBoost 0.15 Phase 2
summaryBoost 0.70 Phase 2
bm25ChunkBoost 0.15 Phase 2~3
vectorChunkBoost 0.00 Phase 3 실험 + 이후 부하 테스트
knnK 20 Phase 4
knnNumCandidates 60 Phase 4
exactBoost 2.0 Phase 5
fuzzyBoost 1.0 Phase 5
tieBreaker 0.3 Phase 5
쿼리 구조 dis_max Phase 5

돌아보며

실험을 진행하면서 몇 가지 교훈이 남았습니다.

 

첫째, 직관과 실측은 다릅니다. Phase 4에서 kNN 탐색 범위를 넓히면 당연히 더 좋은 후보를 찾을 것이라 예상했지만, 실제로는 가장 작은 범위가 가장 좋은 결과를 냈습니다. 코드 레벨의 변경뿐 아니라 설정 튜닝도 반드시 실측을 기반으로 판단해야 한다는 점을 다시 확인했습니다.

 

둘째, 한 번에 여러 변수를 바꾸지 않는 것이 중요합니다. Phase 1에서 필드 방향을 잡고, Phase 2에서 가중치를 세부 조정하고, Phase 3에서 chunk 구조를 점검하는 식으로 한 단계에 하나의 질문에만 답하도록 실험을 설계했습니다. 덕분에 각 설정 변경의 효과를 개별적으로 추적할 수 있었습니다.

 

셋째, "차이가 없다"는 결과도 가치 있습니다. Phase 3에서 chunk 구성 간 차이가 거의 없다는 결과는 실망스러울 수 있지만, 이후 성능 최적화가 필요할 때 chunk를 제거해도 품질 손실이 없다는 근거가 됩니다.

현재 설정은 약 5,800건 규모의 인덱스에서 검증된 값입니다. 인덱스 규모가 크게 달라지거나 문서 특성이 변하면 재실험이 필요하겠지만, 실험 프레임워크가 구축되어 있으므로 동일한 절차를 반복할 수 있습니다.