15장. 검색 품질 개선
쿼리 재작성과 리랭킹 — 질문을 고치고, 순서를 다시 매긴다
한 줄 요약
쿼리 재작성(query rewriting) 은 고객의 말을 문서가 쓰는 말로 바꿔서 검색하는 방법이고, 리랭킹(re-ranking) 은 모아 온 후보를 LLM이 직접 읽고 순서를 다시 매기는 방법입니다. 둘 다 효과가 있지만 LLM 호출이 한 번씩 늘어납니다.
1. 쿼리 재작성 — 검색을 고치지 않고 질문을 고친다
고객은 "광고랑 다른 게 왔어요"라고 하고 문서는 "표시·광고와 상이"라고 씁니다. 13장의 지도 비유로 말하면, 질문이 찍힌 자리와 정답 청크가 찍힌 자리가 떨어져 있습니다.
질문을 문서의 말투로 바꾸면 질문이 찍히는 자리가 정답 청크 쪽으로 옮겨 갑니다. 그 번역을 LLM에게 시킵니다.
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()
| 프롬프트의 부분 | 왜 넣었나 |
|---|---|
| "쇼핑몰 반품·교환·환불 정책 문서에서" | 어떤 문서의 말투로 바꿀지 알려 줍니다 |
| "공식 용어 문장 1개로" | 구어체를 문서 용어로, 그리고 한 문장으로 |
| "문장만 출력" | 설명을 덧붙이지 않게 합니다. 결과를 그대로 검색에 넣을 것이기 때문입니다 |
temperature=1.0 |
같은 질문에 되도록 같은 문장이 나오게 합니다 |
「따라하기」에서 "광고랑 다른 게 왔어요"는 "상품 상세 페이지의 이미지 또는 설명과 상이한 상품 수령으로 인한 반품 및 교환 신청 방법" 같은 문장으로 바뀝니다. 문서의 말 "상이"가 들어갔습니다.
재작성한 문장은 검색에만 쓴다
- 고객에게 답할 때는 원래 질문을 씁니다. 재작성은 검색이 잘 되도록 말을 바꾼 것이고, 고객이 실제로 한 말은 원래 질문입니다. 그래서 리랭킹과 답변 생성에는 원래 질문을 넘깁니다.
- 문장은 실행마다 조금씩 달라집니다. 온도가 0이어도 그렇습니다(3장). 그래서 재작성 하나에 기대지 않고 뒤에 하이브리드 검색과 리랭킹을 잇습니다.
2. 리랭킹 — 넓게 모은 뒤 좁게 고른다
벡터 검색의 거리는 빠르지만 거친 자입니다. 13장에서 1위와 2위의 거리 차이가 0.003밖에 안 되는 것을 봤습니다. 청크 12개에서 후보 6개를 추리는 데는 충분하지만, 그 6개 가운데 무엇이 정말 질문에 답하는지 가리기에는 부족합니다.
그래서 순서 매기기를 한 번 더 합니다. 이번에는 LLM이 후보를 직접 읽습니다.
| 1차 검색 (벡터·키워드) | 리랭킹 (LLM) | |
|---|---|---|
| 보는 것 | 벡터 사이의 거리, 겹치는 단어 | 질문과 발췌의 내용 |
| 속도 | 빠르다 | 느리다 (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]]
| 코드 | 뜻 |
|---|---|
f"[{i}] {c}" |
후보마다 번호를 붙여 보여 줍니다. 후보에는 출처 꼬리표와 본문이 함께 들어 있습니다 |
| "번호만 콤마로 나열하라 (예: 2,0,1)" | 답의 형식을 정합니다. 코드가 숫자로 바꿔 써야 하기 때문입니다 |
try … except ValueError |
답을 숫자로 바꿀 수 없으면 order를 빈 목록으로 둡니다 |
order + list(range(len(candidates))) |
모델이 고른 번호 뒤에 원래 순서를 이어 붙입니다. 모델이 번호를 두 개만 돌려줘도 나머지 자리가 원래 순서로 채워집니다 |
picked[:top_n] |
앞에서 top_n개를 남깁니다. 항상 top_n개가 나옵니다 |
모자라거나 읽을 수 없을 때 안전한 쪽으로 물러나는 것을 폴백(fallback) 이라고 합니다. 4장에서 배운 것과 같은 태도입니다. 코드가 받아 쓸 답은 형식을 정해 주고, 형식이 깨질 때를 대비합니다. 모델이 "유용한 순서대로 나열하라"는 말에 유용하다고 본 번호 한두 개만 돌려주어도, 답이 숫자가 아니어도, 이 함수는 세 개를 돌려줍니다.
리랭킹에는 원래 질문을 넘깁니다. 실행부를 보면 검색은 재작성한 문장으로 하지만, 리랭킹에는 원래 질문(tricky)을 넘깁니다. 고객이 실제로 알고 싶은 것에 맞춰 고르기 위해서입니다.
3. 비용 — 질문 하나에 LLM을 몇 번 부르는가
| 구성 | 임베딩 | LLM | 내용 |
|---|---|---|---|
| 14장 | 1번 | 1번 | 검색 → 생성 |
| 15장 전체 | 1번 | 3번 | 재작성 → 검색 → 리랭킹 → 생성 |
LLM 호출이 세 배가 됩니다. 비용만이 아니라 고객이 기다리는 시간도 늘어납니다.
그래서 세 방법을 모든 질문에 다 쓰지 않는 길도 있습니다. 1차 벡터 검색의 1위 거리가 충분히 가까우면 그대로 답하고, 멀 때만 재작성과 리랭킹을 켜는 식입니다. 우리 청크에서 잰 1위 거리입니다.
| 질문 | 1위 거리 | 문서에 답이 |
|---|---|---|
| 단순 변심인데 반품 배송비 얼마예요? | 0.440 | 있다 |
| 해외 배송도 반품 되나요? | 0.533 | 없다 |
| 돈 언제 돌려줘요? | 0.581 | 있다 |
| 광고랑 다른 게 왔어요 | 0.621 | 있다 |
| 오늘 날씨 어때요? | 0.768 | 없다 |
문서의 말과 가까운 첫 질문은 0.440이고, 구어체인 두 질문은 0.58, 0.62입니다. 기준을 0.55쯤에 두면 쉬운 질문은 싸게, 어려운 질문만 비싸게 처리할 수 있습니다.
다만 이 표에서 하나 더 읽어야 합니다. 문서에 답이 없는 "해외 배송" 질문(0.533)이, 답이 있는 "돈 언제 돌려줘요?"(0.581)보다 가깝습니다. 거리는 질문의 말이 문서의 말과 얼마나 닮았는지를 말해 줄 뿐, 문서에 답이 있는지는 말해 주지 않습니다. 거리로 정할 수 있는 것은 "검색을 더 공들일지"까지이고, "답할지 기권할지"는 여전히 발췌를 읽고 판단해야 합니다.
4. 세 방법을 한 줄로 이으면
rewritten = rewrite_query(tricky) # ① 질문을 문서의 말로 (LLM)
candidates = hybrid_search(rewritten, k=6) # ② 후보 6개 모으기
final = llm_rerank(tricky, candidates, top_n=3) # ③ 읽고 순서 다시 매기기 (LLM)
| 단계 | 누가 하나 | 무엇을 넣나 |
|---|---|---|
| ① 재작성 | LLM | 원래 질문 |
| ② 후보 모으기 | 임베딩 모델 + Chroma + BM25 | 재작성한 문장 |
| ③ 리랭킹 | LLM | 원래 질문 + 후보 6개 |
어느 단계에 어느 질문을 넣을지, 후보를 몇 개 모으고 몇 개를 남길지는 모두 우리가 정한 것입니다.
핵심 정리
- 쿼리 재작성은 고객의 말을 문서의 말로 바꿔 검색합니다. 재작성한 문장은 검색에만 씁니다.
- 리랭킹은 후보를 LLM이 읽고 순서를 다시 매깁니다. 답의 형식을 정하고, 깨지면 폴백합니다.
- 모델이 번호를 일부만 돌려줘도 나머지를 원래 순서로 채워 항상
top_n개를 돌려줍니다. - 세 방법을 다 쓰면 질문 하나에 LLM을 3번 부릅니다. 언제 켤지를 따져야 합니다.