logo

검색 전후 최적화

정답 청크를 정한 평가 질문으로 검색 성능을 재고, HyDE와 rerank를 붙여 Naive RAG와 비교한다.

  • Advanced RAG: 검색 전후에 단계를 추가해 Naive RAG의 검색 실패를 줄이는 방법.
    • 검색 전: 질문을 검색하기 좋은 형태로 바꿈. 질문 다시 쓰기(query rewriting), HyDE.
    • 검색 후: 넉넉히 검색한 결과의 순위를 다시 매김. rerank.
  • 개선하기 전에 먼저 할 일: 검색이 얼마나 잘 되는지 잴 기준 만들기. 기준 없이 바꾸면 나아졌는지 알 수 없음.

실습 준비

!pip install -q openai
import json
from pathlib import Path
import numpy as np
from openai import OpenAI
from mindmall_guide import guide

client = OpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key=Path("openrouter-key.txt").read_text().strip(),
)
model = "openai/gpt-4.1-mini"
embedding_model = "openai/text-embedding-3-small"

def chunk_with_heading(text):
    chunks = []
    for section in text.split("\n## ")[1:]:
        title, body = section.split("\n", 1)
        chunks += [f"[{title}] {p.strip()}" for p in body.split("\n\n") if p.strip()]
    return chunks

def embed(texts):
    response = client.embeddings.create(model=embedding_model, input=texts)
    vectors = np.array([d.embedding for d in response.data])
    return vectors / np.linalg.norm(vectors, axis=1, keepdims=True)

def search(query, k=3):  # 가까운 청크 k개의 번호
    scores = chunk_vectors @ embed([query])[0]
    return [int(i) for i in np.argsort(-scores)[:k]]

chunks = chunk_with_heading(guide)
chunk_vectors = embed(chunks)
len(chunks)
실행 결과
22

검색 성능 재기

  • 평가 질문: 실제 고객이 쓸 법한 말투로 질문을 만들고, 답이 들어 있는 청크 번호(정답 청크)를 사람이 정해 둠.
  • 일부러 안내서와 표현이 다른 질문을 넣음. 포인트(→ 적립금), 택배(→ 출고·도착), 공짜로 고쳐 주나요(→ 무상 수리) 등.
eval_set = [
    ("포인트 언제 들어와요?", 3),
    ("택배 언제 와요?", 6),
    ("키보드를 물에 빠뜨렸는데 공짜로 고쳐 주나요?", 18),
    ("불량품이면 반품 기한이 더 길어요?", 10),
    ("두 개 샀다가 하나만 돌려보내면 돈을 덜 돌려받나요?", 15),
    ("주말에 문의하면 언제 답을 받나요?", 21),
    ("키캡 뺐는데 환불 돼요?", 12),
    ("같은 고장이 계속 나면 새 걸로 바꿔 주나요?", 19),
    ("등급은 언제 바뀌어요?", 0),
    ("결제할 때 포인트를 얼마까지 쓸 수 있어요?", 4),
]
  • 예: '같은 고장이 계속 나면'의 정답 19번 청크는 "같은 고장으로 세 번 수리를 받은 뒤 다시 고장 나면 같은 상품으로 교환해 준다."를 담은 품질 보증 문단.

  • 지표 hit@k: 검색 결과 상위 k개 안에 정답 청크가 들어 있는 질문의 비율. 상위 k개 기준의 재현도.

  • 프롬프트에 3개를 넣을 계획이면 hit@3이 중요. 1위에 정답이 오는지는 hit@1.

def evaluate(search_fn):
    hits = [(gold, search_fn(question)) for question, gold in eval_set]
    return {
        "hit@1": float(np.mean([gold == found[0] for gold, found in hits])),
        "hit@3": float(np.mean([gold in found[:3] for gold, found in hits])),
        "실패한 질문": [q for (q, _), (gold, found) in zip(eval_set, hits) if gold not in found[:3]],
    }

baseline = evaluate(search)
baseline
실행 결과
{'hit@1': 0.6,
 'hit@3': 0.7,
 '실패한 질문': ['포인트 언제 들어와요?', '키보드를 물에 빠뜨렸는데 공짜로 고쳐 주나요?', '키캡 뺐는데 환불 돼요?']}
  • 질문을 그대로 임베딩해 검색하는 Naive RAG의 성적. 아래 방법들과 비교할 기준.

검색 전: HyDE

  • 질문 다시 쓰기: LLM으로 고객 질문을 안내서 용어에 가까운 검색어로 바꾸거나, "골드 회원도요?"처럼 앞 대화를 봐야 뜻이 통하는 질문을 독립된 질문으로 바꿈. 챗봇형 RAG에서 거의 필수. 아래 evaluate에 같은 방식으로 넣어 비교해 볼 수 있음.
  • HyDE(Hypothetical Document Embeddings): 질문에 대한 가상의 답변 문서를 LLM으로 먼저 쓰고, 그 답변의 임베딩으로 검색.
  • 아이디어: 짧은 질문보다 답변처럼 쓴 문장이 실제 정답 청크와 문체·어휘가 비슷함. 가상의 답변 내용이 틀려도 괜찮음. 검색에만 쓰고 버림.
def hyde(question):
    prompt = f"""온라인 쇼핑몰 고객 안내서에서 다음 질문에 답하는 문단을 한두 문장으로 지어내세요.
안내서 문체(~한다)로 쓰고 문단만 답하세요.

질문: {question}"""
    response = client.chat.completions.create(model=model, messages=[{"role": "user", "content": prompt}])
    return response.choices[0].message.content.strip()

hyde("택배 언제 와요?")
실행 결과
'주문하신 상품은 결제 완료 후 평균 2~3일 이내에 배송되며, 정확한 배송일정은 배송 조회를 통해 확인할 수 있다.'
  • 실제 안내서의 내용(평일 오후 2시까지 결제하면 당일 출고, 1-2일 안에 도착)과 다름. 하지만 '결제', '배송 조회'처럼 안내서와 비슷한 어휘와 문체를 씀.
hyde_result = evaluate(lambda q: search(hyde(q)))
hyde_result
실행 결과
{'hit@1': 0.7, 'hit@3': 1.0, '실패한 질문': []}
  • 별도의 질문-답변 쌍으로 임베딩 모형을 학습시키지 않아도 됨. 대신 질문마다 LLM 호출이 하나 늘어 느리고 비용이 듦.

출처: Gao 외(2022), Precise Zero-Shot Dense Retrieval without Relevance Labels.

검색 후: rerank

  • 1차 검색은 빠르지만 거침. 임베딩은 질문과 청크를 따로 벡터로 만든 뒤 비교하므로 둘 사이의 세밀한 관계를 놓침.
  • rerank: 1차 검색으로 후보를 넉넉히(예: 10개) 뽑고, 더 정밀한 모형으로 질문과 후보를 함께 읽고 점수를 매겨 순위를 다시 정함.
  • 전용 reranker 모형: 질문과 청크를 한 입력으로 넣어 관련성 점수를 출력하는 BERT류 모형(cross-encoder). 여기서는 LLM에 점수를 매기게 함.
  • 8주 구조화된 출력의 JSON Schema를 사용해 점수 목록을 정해진 형식으로 받음.
score_schema = {"type": "json_schema", "json_schema": {"name": "relevance", "strict": True, "schema": {
    "type": "object",
    "properties": {"scores": {"type": "array", "items": {"type": "integer"}}},  # 후보 순서대로 0~10점
    "required": ["scores"], "additionalProperties": False}}}

def rerank(question, candidates, k=3):
    listing = "\n".join(f"{n}. {chunks[i]}" for n, i in enumerate(candidates, start=1))
    prompt = f"""질문에 답하는 데 각 자료가 얼마나 필요한지 0~10점으로 매기세요.
자료 순서대로 점수 목록을 답하세요.

[질문]
{question}

[자료]
{listing}"""
    response = client.chat.completions.create(
        model=model, messages=[{"role": "user", "content": prompt}], response_format=score_schema)
    scores = json.loads(response.choices[0].message.content)["scores"]
    order = sorted(range(len(candidates)), key=lambda n: -scores[n])  # 점수 내림차순
    return [candidates[n] for n in order[:k]]
reranked = evaluate(lambda q: rerank(q, search(q, k=10)))
reranked
실행 결과
{'hit@1': 0.9, 'hit@3': 1.0, '실패한 질문': []}
  • rerank(q, search(q, k=10)): 1차 검색으로 후보 10개를 뽑고 rerank로 3개를 고름. 예: 시험 실행에서 "키보드를 물에 빠뜨렸는데 공짜로 고쳐 주나요?"는 1차 검색 상위 3개([12, 10, 13])에 정답 18번이 없었지만, rerank 후 18번이 1위로 올라옴.
  • rerank는 1차 검색의 후보 안에서만 순서를 바꿈. 후보 10개 안에 정답이 없으면 고칠 수 없음.
  • LLM 호출이 질문마다 하나 늘고, 후보 청크를 모두 읽으므로 토큰 비용이 큼. 전용 reranker 모형은 이보다 빠르고 쌈.

결과 비교

방법 hit@1 hit@3
Naive 0.6 0.7
HyDE 0.7 1.0
rerank 0.9 1.0
  • 위 실행에서는 HyDE와 rerank 모두 Naive보다 나았음. rerank는 1위 정답 비율(hit@1)을 크게 올림.
  • 평가 질문이 10개뿐이라 질문 하나가 10%p. 실행마다 LLM의 출력이 달라 결과도 조금씩 바뀜. 실제로는 수십-수백 개의 평가 질문으로 비교.
  • 어느 방법이 나은지는 문서와 질문에 따라 다름. 평가 질문으로 직접 재 보고 고르는 것이 원칙.
  • 방법을 겹쳐 쓸 수 있음. 예: 질문 다시 쓰기 → 하이브리드 검색으로 후보 20개 → rerank로 3개. 단, 붙이는 방법마다 평가 질문으로 효과를 확인. 질문 다시 쓰기는 검색어의 초점이 흐려져 오히려 성적이 떨어지기도 함.
  • 비용과 속도: 단계를 하나 붙일 때마다 응답이 느려짐. 성능 향상이 그 비용만큼 가치 있는지 판단.

퀴즈

문제 1 / 5맞음: 0힌트: 0틀림: 0채점중: 0남음: 5

검색 방법을 개선하기 전에 정답 청크를 정한 평가 질문을 먼저 만드는 이유는?

  • ○바꾼 방법이 실제로 나아졌는지 재기 위해
  • ○임베딩 모형을 학습시키기 위해
  • ○청크의 수를 줄이기 위해
  • ○모델이 정답 청크를 외우게 하기 위해

퀴즈를 풀려면 대화형 기능을 불러와야 합니다.