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

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장의 답변에 그대로 이을 수 있습니다.
  • 검색이 나아졌는지는 정답 청크가 몇 위에서 몇 위로 갔는지로 확인합니다. 정답 줄은 사람이 먼저 정해 둡니다.
← 이전 절쿼리 재작성과 리랭킹 — 질문을 고치고, 순서를 다시 매긴다다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com