15장. 검색 품질 개선
실습문제와 해답
코드를 직접 돌려 보며 15장 내용을 손에 익힙니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다. 세 문제 모두 chroma_db/가 있어야 합니다.
lesson15_rag_quality.py의 실험 부분은 if __name__ == "__main__": 아래에 있어서, 다른 파일에서 함수를 가져와도 실험이 다시 돌지 않습니다. 그래서 이번 문제들은 그 파일의 함수를 가져다 씁니다.
문제 1 — 정답 청크의 순위를 표로 만들기
「따라하기」는 질문 하나로 확인했습니다. 이번에는 질문 다섯 개에 대해 정답 청크가 어디에 있는지 표로 만듭니다. 새 파일 ex15_ranks.py를 만드세요. LLM은 부르지 않습니다.
- 질문마다 정답이 적힌 줄의 일부를 함께 적어 둡니다(아래 해답의
cases). -
질문마다 네 가지를 출력합니다: 벡터 검색에서 정답 청크의 순위(12개 중), 키워드 검색에서의 순위, 벡터 검색 상위 3에 있는지, 하이브리드 검색 후보 6에 있는지.
-
힌트: 검색 함수가 돌려주는 문자열에는 본문이 들어 있으므로
정답 줄 in 문자열로 검사할 수 있습니다.
해답 보기
from lesson15_rag_quality import hybrid_search, keyword_search, vector_search
# (질문, 정답이 적힌 줄의 일부) — 정답 줄은 사람이 정책 문서를 읽고 정했다
cases = [
("광고랑 다른 게 왔어요", "표시·광고와 상이"),
("다른 물건이 잘못 왔어요", "오배송(상품 상이)"),
("쿠폰 안 쓰면 없어져요?", "해당 월 말일에 소멸"),
("포인트 없어지기 전에 알려주나요?", "소멸 30일 전"),
("되팔려고 많이 사도 등급 올라가요?", "리셀러"),
]
def rank(results: list[str], answer: str) -> str:
"""정답 줄이 든 청크가 몇 번째인지. 없으면 '-'"""
for i, t in enumerate(results, start=1):
if answer in t:
return f"{i}위"
return "-"
print("벡터 순위 | 키워드 순위 | 벡터 상위 3 | 하이브리드 후보 6 | 질문")
for question, answer in cases:
v = rank(vector_search(question, 12), answer)
b = rank(keyword_search(question, 12), answer)
top3 = "있음" if rank(vector_search(question, 3), answer) != "-" else "없음"
hybrid = "있음" if rank(hybrid_search(question, k=6), answer) != "-" else "없음"
print(f" {v:>3} | {b:>3} | {top3} | {hybrid} | {question}")
$ python ex15_ranks.py
실행 결과 (LLM을 부르지 않으므로 누가 실행해도 같습니다)
벡터 순위 | 키워드 순위 | 벡터 상위 3 | 하이브리드 후보 6 | 질문
6위 | 3위 | 없음 | 있음 | 광고랑 다른 게 왔어요
5위 | 3위 | 없음 | 있음 | 다른 물건이 잘못 왔어요
4위 | 1위 | 없음 | 있음 | 쿠폰 안 쓰면 없어져요?
2위 | 1위 | 있음 | 있음 | 포인트 없어지기 전에 알려주나요?
2위 | 3위 | 있음 | 있음 | 되팔려고 많이 사도 등급 올라가요?
해설 — 세 가지를 봅니다.
첫째, 위의 세 질문은 벡터 검색 상위 3에 정답 청크가 없습니다(6위, 5위, 4위). 세 질문 모두 하이브리드 검색의 후보 6에는 들어 있습니다. 키워드 검색이 그 청크를 3위 안으로 찾았고, 번갈아 담으면서 후보에 들어왔습니다.
둘째, 아래 두 질문은 벡터 검색만으로도 2위입니다. 모든 질문에 검색을 더 공들일 필요는 없습니다. 「쿼리 재작성과 리랭킹」 절에서 본 "1위 거리가 멀 때만 켠다"는 생각이 여기서 나옵니다.
셋째, "쿠폰 안 쓰면 없어져요?"는 키워드 검색이 1위로 찾았습니다. 질문의 단어가 문서의 단어와 글자 그대로 겹쳤기 때문입니다. 두 검색은 잘 찾는 질문이 서로 다릅니다.
정답 줄(cases의 둘째 값)은 사람이 정책 문서를 읽고 정했습니다. 이런 표가 있어야 검색을 바꿨을 때 나아졌는지를 숫자로 말할 수 있습니다.
문제 2 — RRF로 합쳐 번갈아 담기와 비교하기
「하이브리드 검색」 절에서 순위를 합치는 표준 방법이 RRF라고 했습니다. 새 파일 ex15_rrf.py에서 RRF를 직접 만들어, 이번 장의 hybrid_search(번갈아 담기)와 결과를 비교하세요. LLM은 부르지 않습니다.
rrf_search(query, k=6, c=60): 벡터 검색 12개와 키워드 검색 12개에서, 청크마다1 / (c + 순위)를 더해 점수가 큰 순서로 k개를 고릅니다.- 검색 문장은 「따라하기」에서 나왔던 재작성 문장 하나를 그대로 씁니다: "상품 불일치 및 오배송으로 인한 반품 및 교환 기준에 대해 안내해 주세요."
-
벡터 검색, 키워드 검색, 번갈아 담기, RRF의 결과 여섯 개씩을 청크 id의 끝 세 자리로 한 줄에 출력하고, 정답 청크에
*를 붙입니다. -
힌트: 검색 함수가 돌려주는 문자열은
with_source(메타데이터, 본문)으로 만든 것입니다. 같은 함수로{문자열: id}사전을 만들어 두면 id를 찾을 수 있습니다.
해답 보기
from lesson15_rag_quality import (chunks, hybrid_search, keyword_search,
vector_search, with_source)
# 검색 함수가 돌려주는 문자열(꼬리표 + 본문) → 청크 id
ids = {with_source(c["metadata"], c["text"]): c["id"] for c in chunks}
ANSWER = "표시·광고와 상이"
def rrf_search(query: str, k: int = 6, c: int = 60) -> list[str]:
"""RRF: 검색마다 1 / (c + 순위) 를 점수로 주고, 청크별로 더해 큰 순서로 고른다."""
scores = {}
for results in (vector_search(query, 12), keyword_search(query, 12)):
for rank, t in enumerate(results, start=1):
scores[t] = scores.get(t, 0) + 1 / (c + rank)
return sorted(scores, key=scores.get, reverse=True)[:k]
def show(title: str, results: list[str]) -> None:
line = " ".join(ids[t][-3:] + ("*" if ANSWER in t else "") for t in results)
print(f"{title:<14} {line}")
query = "상품 불일치 및 오배송으로 인한 반품 및 교환 기준에 대해 안내해 주세요."
print(f"검색 문장: {query!r} (* = 정답 줄이 든 청크)\n")
show("벡터 검색", vector_search(query, 6))
show("키워드 검색", keyword_search(query, 6))
show("번갈아 담기", hybrid_search(query, k=6))
show("RRF", rrf_search(query, k=6))
$ python ex15_rrf.py
실행 결과 (LLM을 부르지 않으므로 누가 실행해도 같습니다)
검색 문장: '상품 불일치 및 오배송으로 인한 반품 및 교환 기준에 대해 안내해 주세요.' (* = 정답 줄이 든 청크)
벡터 검색 009 010 007 006* 005 008
키워드 검색 006* 003 008 005 009 001
번갈아 담기 009 006* 010 003 007 008
RRF 006* 009 010 008 005 003
해설 — 정답 청크 006의 자리를 봅니다.
| 방법 | 정답 청크의 자리 | 왜 |
|---|---|---|
| 벡터 검색 | 4위 | |
| 키워드 검색 | 1위 | |
| 번갈아 담기 | 2위 | 벡터 1위(009) 다음에 키워드 1위(006)가 들어왔습니다 |
| RRF | 1위 | 두 검색에 모두 나온 청크라 점수가 두 번 더해졌습니다 |
RRF에서 006의 점수는 1/(60+4) + 1/(60+1)입니다. 벡터 1위인 009는 키워드에서 5위라 1/(60+1) + 1/(60+5)입니다. 앞의 것이 조금 더 큽니다. 양쪽에서 고르게 높은 청크가 한쪽에서만 1위인 청크를 앞섰습니다.
번갈아 담기는 "양쪽에 모두 나왔다"는 사실을 쓰지 않습니다. 같은 청크가 두 번 나오면 한 번만 담을 뿐입니다. 그래서 RRF의 단순형입니다. 두 방법 모두 정답 청크를 후보 6개 안에, 그것도 앞자리에 넣었습니다. 우리 파이프라인은 뒤에서 리랭킹이 순서를 다시 매기므로 단순형으로 충분하고, 리랭킹 없이 합친 순서를 그대로 쓴다면 RRF가 더 알맞습니다.
c=60은 RRF에서 널리 쓰는 값입니다. 이 값이 클수록 1위와 2위의 점수 차이가 줄어듭니다.
문제 3 — 고친 검색을 14장의 답변에 연결하기
이번 장 첫 절에서 14장의 함수는 "광고랑 다른 게 왔어요"에 신청 기간(3개월)을 안내하지 못했습니다. 이번 장의 검색을 써서 이 질문에 다시 답하게 하는 파일 ex15_answer.py를 만드세요.
- 검색:
rewrite_query→hybrid_search(6개) →llm_rerank(3개). - 생성: 14장의
RAG_SYSTEM을 그대로 씁니다. 검색 결과에는 출처 꼬리표가 이미 붙어 있으므로[발췌 1],[발췌 2]번호만 앞에 붙여 넣습니다. - 실행하기 전에, 이 질문 하나에 LLM을 몇 번 부르게 되는지 세어 보세요.
해답 보기
from google.genai import types
from config import MODEL
from lesson14_rag_retriever import RAG_SYSTEM
from lesson15_rag_quality import client, hybrid_search, llm_rerank, rewrite_query
def answer_with_better_search(question: str) -> str:
# ① 검색: 재작성 → 하이브리드 → 리랭킹 (LLM 2번)
rewritten = rewrite_query(question)
final = llm_rerank(question, hybrid_search(rewritten, k=6), top_n=3)
# ② 컨텍스트 주입 — 검색 결과에 출처 꼬리표가 이미 붙어 있다
context = "\n\n".join(f"[발췌 {i+1}] {t}" for i, t in enumerate(final))
prompt = f"[정책 발췌]\n{context}\n\n[고객 문의]\n{question}"
# ③ 생성 (LLM 1번) — 규칙은 14장의 것을 그대로 쓴다
response = client.models.generate_content(
model=MODEL, contents=prompt,
config=types.GenerateContentConfig(
system_instruction=RAG_SYSTEM, temperature=1.0),
)
return response.text.strip()
question = "광고랑 다른 게 왔어요"
print(f"고객: {question}")
print(f"하루: {answer_with_better_search(question)}")
$ python ex15_answer.py
실행 결과 (문장은 실행마다 다릅니다)
고객: 광고랑 다른 게 왔어요
하루: 안녕하세요, 하루마켓 상담원 하루입니다. 표시·광고와 상이한 상품은 수령 후 3개월 이내에 신청하실 수 있으며, 배송비는 회사가 전액 부담합니다. 반품 신청은 하루마켓 앱·웹 마이페이지의 주문내역에서 진행하실 수 있습니다. (근거: 반품교환환불정책 · 제2조)
해설 — 답에 "수령 후 3개월 이내, 회사 전액 부담" 이 들어갔습니다. 정책 문서의 표 1과 같습니다. 이 교재를 준비하며 다섯 번 실행했을 때 다섯 번 모두 3개월과 회사 전액 부담을 안내했고, 출처는 다섯 번 모두 반품교환환불정책 제2조였습니다.
14장의 함수와 비교하면 프롬프트는 한 글자도 바꾸지 않았습니다. RAG_SYSTEM은 그대로이고, 달라진 것은 모델 앞에 놓인 발췌뿐입니다. 첫 절에서 빠졌던 내용의 원인이 생성이 아니라 검색에 있었다는 것을 이렇게 확인합니다.
출처를 보세요. (근거: 반품교환환불정책 · 제2조) 입니다. 문서 이름과 조항이 모두 적혔습니다. 검색 함수가 본문과 함께 출처 꼬리표를 돌려주기 때문에, 14장에서처럼 꼬리표를 따로 만들지 않아도 답변에 출처가 살아 있습니다. 파이프라인의 한 단계를 바꿀 때는 그 뒤 단계가 무엇을 받아 쓰는지를 함께 맞춥니다.
그만큼 비용도 들었습니다. LLM을 세 번 불렀습니다(재작성, 리랭킹, 생성). 14장의 함수는 한 번이었습니다.