15장. 검색 품질 개선
따라하기 — 검색 네 단계 비교
목표
구어체 질문 "광고랑 다른 게 왔어요" 하나를 놓고 검색을 네 단계로 돌려, 정답 청크가 어디에 있는지 따라갑니다. 벡터 검색만 썼을 때는 상위 3개에 없던 정답 청크가, 재작성 → 하이브리드 → 리랭킹을 거치며 맨 위로 올라오는 것을 확인합니다.
0. 실습 준비
VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.
$ conda activate myenv
이번 장은 새로 설치할 것이 없습니다. 대신 두 가지가 폴더에 있어야 합니다.
| 있어야 하는 것 | 왜 | 없으면 |
|---|---|---|
chroma_db/ 폴더 |
벡터 검색이 이 폴더를 불러 씁니다 | 13장을 먼저 실행하라는 안내가 나오고 멈춥니다 |
lesson12_rag_chunking.py |
키워드 검색(BM25)을 만들 청크 본문을 가져옵니다 | ModuleNotFoundError로 멈춥니다 |
13장을 먼저 실행해야 합니다. chroma_db/가 없으면 아래 명령으로 먼저 만듭니다.
$ python lesson13_rag_embedding.py
1. 파일 만들기
haru-market 폴더 맨 위에 새 파일을 만듭니다.
lesson15_rag_quality.py
아래 코드 전체를 복사해 붙여 넣고 저장합니다.
# -*- coding: utf-8 -*-
"""[15장] RAG 4 — 검색 품질 개선 (하이브리드 검색 · 리랭킹 · 쿼리 재작성)
문제 상황: 고객 표현과 문서 용어가 다르다.
고객: "광고랑 다른 게 왔어요" 문서: "표시·광고와 상이" (제2조 표 1의 한 줄)
벡터 검색이 많이 커버하지만, 놓치는 경우가 있다.
오늘의 3가지 개선:
1) 하이브리드 검색: 키워드(BM25) + 벡터 검색 결과를 번갈아 담는다
2) 쿼리 재작성: 고객 표현 → 문서 용어로 LLM이 바꿔서 검색
3) LLM 리랭킹: 검색 결과를 LLM이 질문 관련도 순으로 재정렬
실행: python lesson15_rag_quality.py (13장 선행 필요)
"""
import os
from itertools import zip_longest
from google.genai import types
from langchain_chroma import Chroma
from langchain_google_genai import GoogleGenerativeAIEmbeddings
from rank_bm25 import BM25Okapi
from config import EMBEDDING_MODEL, MODEL, ROOT, get_client
from lesson12_rag_chunking import load_policy_chunks
client = get_client()
# ── 준비: 벡터 검색기 + BM25 키워드 검색기 ────────────────────────────
# 13장에서 만든 벡터 저장소가 있는지 먼저 확인한다. 폴더가 없는데 Chroma(...) 를
# 부르면 빈 폴더를 새로 만들어 버려서, 오류 없이 검색 결과만 0건이 된다.
CHROMA_DIR = ROOT / "chroma_db"
NEED_13 = "13장 lesson13_rag_embedding.py 를 먼저 실행하세요."
if not CHROMA_DIR.exists():
raise SystemExit(f"[준비 필요] chroma_db/ 폴더가 없습니다. {NEED_13}")
embeddings = GoogleGenerativeAIEmbeddings(
model=EMBEDDING_MODEL, google_api_key=os.getenv("GOOGLE_API_KEY"))
vectorstore = Chroma(collection_name="haru_policy", embedding_function=embeddings,
persist_directory=str(CHROMA_DIR))
if not vectorstore.get(limit=1)["ids"]: # 폴더는 있는데 저장된 청크가 0개
raise SystemExit(f"[준비 필요] chroma_db/ 에 정책 문서가 없습니다. {NEED_13}")
chunks = load_policy_chunks()
texts = [c["text"] for c in chunks]
# BM25: 단순 공백 토큰화(수업용). 형태소 분석기를 쓰면 더 좋아진다.
bm25 = BM25Okapi([t.split() for t in texts])
def with_source(metadata: dict, text: str) -> str:
"""본문 앞에 출처 꼬리표 한 줄을 붙인다 — 14장의 [정책 발췌]와 같은 형태.
검색 함수가 본문만 돌려주면 답변에 출처를 적을 재료가 사라진다."""
return (f"({metadata.get('doc_title', '정책')} · "
f"{metadata.get('article') or '조항 미상'})\n{text}")
def keyword_search(query: str, k: int = 4) -> list[str]:
scores = bm25.get_scores(query.split())
top = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k]
return [with_source(chunks[i]["metadata"], texts[i]) for i in top if scores[i] > 0]
def vector_search(query: str, k: int = 4) -> list[str]:
return [with_source(d.metadata, d.page_content)
for d in vectorstore.similarity_search(query, k=k)]
# ── 1. 하이브리드: 두 검색 결과를 번갈아 담는다 (중복 제거) ───────────
def hybrid_search(query: str, k: int = 6) -> list[str]:
"""벡터 1위, 키워드 1위, 벡터 2위, 키워드 2위 … 순서로 담는다.
벡터 결과를 먼저 다 담으면 k 개가 차서 키워드 결과가 전부 잘린다."""
merged = []
for pair in zip_longest(vector_search(query, k), keyword_search(query, k)):
for t in pair: # 짧은 쪽이 끝나면 None 이 온다
if t is not None and t not in merged:
merged.append(t)
return merged[:k]
# ── 2. 쿼리 재작성: 고객 표현 → 정책 문서 용어 ────────────────────────
def rewrite_query(query: str) -> str:
r = client.models.generate_content(
model=MODEL,
contents=f"고객 문의를 쇼핑몰 반품·교환·환불 정책 문서에서 검색하기 좋은 "
f"공식 용어 문장 1개로 바꿔라. 문장만 출력.\n고객 문의: {query}",
config=types.GenerateContentConfig(temperature=1.0),
)
return r.text.strip()
# ── 3. LLM 리랭킹: 후보를 관련도 순으로 재정렬 ────────────────────────
def llm_rerank(query: str, candidates: list[str], top_n: int = 3) -> list[str]:
numbered = "\n\n".join(f"[{i}] {c}" for i, c in enumerate(candidates))
r = client.models.generate_content(
model=MODEL,
contents=f"질문: {query}\n\n아래 발췌 중 질문에 답하는 데 유용한 순서대로 "
f"번호만 콤마로 나열하라 (예: 2,0,1).\n\n{numbered}",
config=types.GenerateContentConfig(temperature=1.0),
)
try:
order = [int(x) for x in r.text.strip().replace(" ", "").split(",")]
except ValueError: # 번호로 읽을 수 없는 답 → 원래 순서 폴백
order = []
picked = []
# 모델이 고른 번호를 먼저, 모자라면 원래 순서로 채워 top_n 개를 맞춘다
for i in order + list(range(len(candidates))):
if 0 <= i < len(candidates) and i not in picked:
picked.append(i)
return [candidates[i] for i in picked[:top_n]]
if __name__ == "__main__":
tricky = "광고랑 다른 게 왔어요" # 문서 용어와 거리가 먼 구어체
ANSWER = "표시·광고와 상이" # 정답이 적힌 줄 (사람이 문서를 읽고 정했다)
def show(results: list[str]) -> None:
"""결과마다 출처 꼬리표를 찍고, 정답 줄이 든 청크에 [정답]을 표시한다."""
for t in results:
mark = "[정답]" if ANSWER in t else "[ ]"
print(f" {mark} {t.split(chr(10))[0]}")
print(f"고객 문의: {tricky!r} 정답: {ANSWER!r} 줄이 든 청크\n")
print("■ [1] 벡터 검색만 (상위 3)")
show(vector_search(tricky, 3))
rewritten = rewrite_query(tricky)
print(f"\n■ [2] 쿼리 재작성: {rewritten!r} (벡터 검색 상위 3)")
show(vector_search(rewritten, 3))
print("\n■ [3] 하이브리드 검색 (후보 6)")
candidates = hybrid_search(rewritten, k=6)
show(candidates)
print("\n■ [4] LLM 리랭킹 (최종 상위 3)")
show(llm_rerank(tricky, candidates, top_n=3))
print("""
──────────────────────────────────────────────────────────
최종 검색 파이프라인 (21장 정책안내 에이전트에 탑재)
질문 → 쿼리 재작성 → 하이브리드 검색(벡터+BM25) → LLM 리랭킹 → 상위 3개
비용 메모: 재작성·리랭킹은 LLM 호출 2번 추가 = 토큰 비용.
'항상' 이 아니라 '1차 검색 결과의 거리 점수가 나쁠 때만' 켜는 것도 방법.
──────────────────────────────────────────────────────────""")
2. 코드에서 볼 곳
파일의 위쪽은 검색 함수들이고, 아래쪽 실행부가 그 함수들로 네 단계를 비교합니다.
| 함수 | 하는 일 | 부르는 것 |
|---|---|---|
with_source |
청크 본문 앞에 출처 꼬리표 한 줄을 붙이기 | 없음 |
vector_search |
뜻으로 찾기 | 임베딩 API 1번 |
keyword_search |
단어로 찾기 (BM25) | 없음 |
hybrid_search |
위 둘을 번갈아 담기 | 임베딩 API 1번 |
rewrite_query |
질문을 문서의 말로 바꾸기 | LLM 1번 |
llm_rerank |
후보의 순서를 다시 매기기 | LLM 1번 |
검색 결과에 출처를 붙여 돌려준다
def with_source(metadata: dict, text: str) -> str:
"""본문 앞에 출처 꼬리표 한 줄을 붙인다 — 14장의 [정책 발췌]와 같은 형태.
검색 함수가 본문만 돌려주면 답변에 출처를 적을 재료가 사라진다."""
return (f"({metadata.get('doc_title', '정책')} · "
f"{metadata.get('article') or '조항 미상'})\n{text}")
검색 함수는 모두 이 함수를 거쳐 "(문서 이름 · 조항)" 한 줄 + 본문을 돌려줍니다. 14장의 [정책 발췌]에 넣던 것과 같은 모양입니다. 그래서 이 장의 검색 결과를 14장의 답변에 그대로 이어 붙일 수 있고, 답변에 "반품교환환불정책 · 제2조" 같은 출처가 적힙니다(실습문제 3).
실행부 — 네 단계
| 단계 | 검색에 넣는 문장 | 방법 |
|---|---|---|
| [1] | 원래 질문 | 벡터 검색, 상위 3개 |
| [2] | 재작성한 문장 | 벡터 검색, 상위 3개 |
| [3] | 재작성한 문장 | 하이브리드 검색, 후보 6개 |
| [4] | 원래 질문 + [3]의 후보 | 리랭킹, 상위 3개 |
결과를 찍는 show 함수는 청크마다 꼬리표 한 줄을 찍고, 정답 줄이 든 청크에 [정답]을 표시합니다.
ANSWER = "표시·광고와 상이" # 정답이 적힌 줄 (사람이 문서를 읽고 정했다)
(중략)
mark = "[정답]" if ANSWER in t else "[ ]"
정답이 어느 줄인지는 사람이 정책 문서를 읽고 정했습니다. 검색이 나아졌는지 재려면 질문마다 정답이 어디인지 먼저 적어 두어야 합니다.
저장소가 있는지 확인하는 부분은 14장과 같습니다. chroma_db/가 없으면 13장을 먼저 실행하라는 안내를 찍고 멈춥니다.
3. 실행하기
실행하기 전에 짐작해 보세요.
- [1] 벡터 검색만으로 상위 3개 안에 정답 청크가 들어올까요?
- 정답 청크는 어느 단계에서 처음 나타날까요?
$ python lesson15_rag_quality.py
LLM을 두 번 호출하고 5초 안팎 걸립니다.
실행 결과 (재작성 문장과 [2]~[4]의 순서는 실행할 때마다 달라질 수 있습니다. [1]은 누가 실행해도 같습니다)
고객 문의: '광고랑 다른 게 왔어요' 정답: '표시·광고와 상이' 줄이 든 청크
■ [1] 벡터 검색만 (상위 3)
[ ] (반품교환환불정책 · 제7조 (반품·교환 불가 사유), 제8조 (배송 지연 보상), 제9조 (분쟁 처리))
[ ] (반품교환환불정책 · 자주 묻는 질문 (FAQ), 부칙 — 개정 이력)
[ ] (반품교환환불정책 · 제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ))
■ [2] 쿼리 재작성: '상품 상세 페이지의 이미지 또는 설명과 상이한 상품 수령으로 인한 반품 및 교환 신청 방법' (벡터 검색 상위 3)
[ ] (반품교환환불정책 · 제7조 (반품·교환 불가 사유), 제8조 (배송 지연 보상), 제9조 (분쟁 처리))
[정답] (반품교환환불정책 · 제1조 (용어의 정의), 제2조 (반품 사유별 기준), 제3조 (반품 신청 절차), 제4조 (교환))
[ ] (반품교환환불정책 · 제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간))
■ [3] 하이브리드 검색 (후보 6)
[ ] (반품교환환불정책 · 제7조 (반품·교환 불가 사유), 제8조 (배송 지연 보상), 제9조 (분쟁 처리))
[정답] (반품교환환불정책 · 제1조 (용어의 정의), 제2조 (반품 사유별 기준), 제3조 (반품 신청 절차), 제4조 (교환))
[ ] (반품교환환불정책 · 제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간))
[ ] (반품교환환불정책 · 제5조 (환불 처리 기간), 제6조 (환불 금액 산정), 제7조 (반품·교환 불가 사유))
[ ] (반품교환환불정책 · 제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ))
[ ] (반품교환환불정책 · 제1조 (용어의 정의), 제2조 (반품 사유별 기준))
■ [4] LLM 리랭킹 (최종 상위 3)
[정답] (반품교환환불정책 · 제1조 (용어의 정의), 제2조 (반품 사유별 기준), 제3조 (반품 신청 절차), 제4조 (교환))
[ ] (반품교환환불정책 · 제1조 (용어의 정의), 제2조 (반품 사유별 기준))
[ ] (반품교환환불정책 · 제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간))
(이하 생략)
| 이렇게 나오면 | 원인과 조치 |
|---|---|
[준비 필요] chroma_db/ 폴더가 없습니다. |
13장을 실행하지 않았습니다. python lesson13_rag_embedding.py를 실행한 뒤 다시 돌립니다 |
ModuleNotFoundError: No module named 'lesson12_rag_chunking' |
12장의 파일이 폴더에 없습니다 |
429 RESOURCE_EXHAUSTED |
짧은 시간에 요청이 몰렸습니다. 잠시 기다렸다가 다시 실행합니다 |
4. 무엇을 관찰했나
정답 청크의 자리를 따라간다
| 단계 | 정답 청크(policy-006)의 자리 |
무슨 일이 있었나 |
|---|---|---|
| [1] 벡터 검색만 | 상위 3에 없음 | 12개 가운데 6위였습니다. "광고랑 다른 게"와 "표시·광고와 상이"의 거리가 멀었습니다 |
| [2] 재작성 | 2위 | 모델이 질문을 "…설명과 상이한 상품 수령으로 인한 반품 및 교환 신청 방법"으로 바꿨습니다. 문서의 말 쪽으로 옮겨 갔습니다 |
| [3] 하이브리드 | 후보 6개 중 2번째 | 벡터 결과와 키워드 결과를 번갈아 담아 후보를 넓혔습니다 |
| [4] 리랭킹 | 1위 | 모델이 후보 여섯 개를 읽고 이 청크를 가장 유용하다고 골랐습니다 |
벡터 검색만으로는 상위 3개에 없던 청크가 맨 위로 올라왔습니다. 이 교재를 준비하며 다섯 번 실행했을 때, 재작성 문장은 매번 달랐지만 [4]의 1위는 다섯 번 모두 정답 청크였습니다.
꼬리표로 읽는다
화면에는 청크의 본문이 아니라 꼬리표가 찍혀 있습니다. 정답은 제2조의 표 1에 있으므로 꼬리표에 "제2조 (반품 사유별 기준)"가 있는 줄을 보면 됩니다. 꼬리표에 제2조가 있는 청크는 둘입니다.
[정답]이 붙은 청크(policy-006) — 제2조의 표 1 전체가 들어 있습니다.- 그 아래 "제1조, 제2조"만 적힌 청크(
policy-005) — 제2조의 제목과 표 제목까지만 들어 있고 표의 내용은 다음 청크에 있습니다.
리랭킹은 [4]에서 이 둘을 1위와 2위로 올렸습니다. 발췌를 읽고 고른 결과입니다.
세 방법이 서로 다른 자리에서 일했다
| 방법 | 이번 실행에서 한 일 | 추가 비용 |
|---|---|---|
| 쿼리 재작성 | 질문을 문서의 말로 옮겨 정답 청크를 상위 3 안으로 | LLM 1번 |
| 하이브리드 검색 | 벡터 결과와 키워드 결과를 합쳐 후보 6개를 마련 | 없음 |
| 리랭킹 | 후보를 읽고 정답 청크를 1위로. 결과는 정확히 3개 | LLM 1번 |
재작성 문장은 실행마다 달라지므로, 어느 실행에서는 [2]에서 정답 청크가 곧바로 1위에 오고, 어느 실행에서는 [3]에서 키워드 검색이 건져 옵니다. 세 방법을 겹쳐 두었기 때문에 어느 길로든 [4]에서는 맨 위에 옵니다.
누가 무엇을 했는가
| 한 일 | 누가 |
|---|---|
| 질문을 문서의 말로 바꿨다. 후보를 읽고 번호를 골랐다 | LLM |
| 뜻과 단어로 후보를 찾았다 | 임베딩 모델 + Chroma, BM25 |
| 정답 줄을 정했다. 후보 6개, 최종 3개, 번갈아 담는 순서, 어느 단계에 어느 질문을 넣을지를 정했다 | 우리(개발자) |
5. 지금 폴더의 모습
haru-market/
├── config.py
├── data/
├── chroma_db/ ← 13장에서 만든 것. 이번 장이 불러 씁니다
├── (중략)
├── lesson12_rag_chunking.py ← 이번 장이 청크를 가져다 씁니다
├── lesson13_rag_embedding.py
├── lesson14_rag_retriever.py
└── lesson15_rag_quality.py ← 이번 장
핵심 정리
- 벡터 검색만으로는 "광고랑 다른 게 왔어요"의 정답 청크가 상위 3개에 없었습니다(6위).
- 쿼리 재작성이 질문을 문서의 말로 옮겼고, 하이브리드 검색이 후보를 넓혔고, 리랭킹이 정답 청크를 1위로 올렸습니다.
- 검색 함수는 본문에 출처 꼬리표를 붙여 돌려줍니다. 그래서 14장의 답변에 그대로 이을 수 있습니다.
- 검색이 나아졌는지는 정답 청크가 몇 위에서 몇 위로 갔는지로 확인합니다. 정답 줄은 사람이 먼저 정해 둡니다.