실무 Multi-Agent 오케스트레이션 15장 · 검색 품질 개선 7 / 7 ← 이전목차다음 → TechLead Cro

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장의 함수는 한 번이었습니다.

← 이전 절정리와 체크리스트16장 →왜 멀티턴이 필요한가 — "그거"가 뭔지 모른다
오명운 · macro@prag-ai.com