다중 홉 질문을 위한 질의 분해
다중 홉 질문을 위한 질의 분해 (Query Decomposition for Multi-Hop Questions)
⏱️ 소요 시간 15분 | 난이도: 중급 출처: Qdrant 공식 문서 — query-decomposition
여러분, 다중 홉(multi-hop) 질문이라는 걸 들어보셨나요? 이건 두 개의 사실이 사슬처럼 연결된 질문이에요. "영화 《인셉션》의 감독은 어디에서 태어났는가?" 같은 질문이 대표적이죠. 이 질문을 풀려면 먼저 감독이 누구인지 알아내고, 그다음에 그 사람의 출생지를 찾아야 해요. 먼저 사실 하나를 찾아야 다음 사실을 찾을 수 있는 구조인 거죠.
그런데 여기서 문제가 생겨요. 단일 검색 한 번으로는 영화에 관한 청크(chunk)만 가져오게 됩니다. 그런데 출생지 정보는 크리스토퍼 놀란(Christopher Nolan)에 관한 청크에 들어 있고, 그 청크는 《인셉션》을 전혀 언급하지 않아요. reranking이나 fusion은 이미 검색된 결과를 재정렬하는 역할만 하기 때문에, 후보 집합(candidate set)에 애초에 없던 증거를 되살려낼 수가 없거든요.
여기서 **분해(Decomposition)**가 등장합니다. 질문을 검색하고, LLM이 그 결과를 읽고 다음 하위 질문을 만들고, 다시 검색하고, 놓친 게 없을 때까지 이 과정을 반복하는 방식이에요. 이 접근법은 Self-Ask(모델이 스스로 후속 질문을 던지는 방식)와 IRCoT(검색과 모델의 추론을 각 단계에서 번갈아 수행하는 방식) 위에 구축되어 있어요.
사전 준비: 포인트가 채워진 Qdrant 컬렉션, 질의를 인코딩할 임베딩 모델, 그리고 qdrant-client와 openai가 설치된 Python 환경이 필요해요.
동작 원리
먼저 클라이언트를 만들고 호프(hops, 검색 반복 횟수)의 상한을 정해볼게요.
from openai import OpenAI
from qdrant_client import QdrantClient, models
from your_embedding_model import embed # 컬렉션이 사용하는 모델과 일치해야 해요
llm = OpenAI(api_key="<your-api-key>")
# Qdrant Cloud를 쓴다면: QdrantClient(url="https://<id>.cloud.qdrant.io", api_key="...")
client = QdrantClient("http://localhost:6333")
MODEL = "gpt-5-mini" # 작고 빠르고 저렴해요. 원하는 채팅 모델로 바꿔도 돼요
MAX_HOPS = 3 # 후속 호프의 상한을 걸어서 루프가 항상 끝나게 해요
각 호프는 평범한 유사도 검색 하나예요.
def retrieve(text, limit=10):
response = client.query_points(
collection_name="{collection_name}",
query=embed(text),
using="dense", # 벡터 이름이 있으면 지정. 이름이 없다면 생략해요
limit=limit,
)
return response.points
각 호프가 끝난 뒤, LLM은 지금까지의 결과를 읽고 아직 빠진 사실 하나를 말하거나 DONE이라고 답해요. 다음 하위 질문을 고르는 건 가벼운 작업이라서, 작고 빠른 모델이 잘 처리해요.
def next_subquestion(question, hops):
"""다음에 무엇을 검색할지 LLM에게 묻고, 빠진 게 없으면 None을 반환해요."""
# 지금까지 각 호프에서 상위 3개 청크를 모아요
context = "\n".join(
hit.payload.get("text", "")
for hits in hops
for hit in hits[:3]
)
prompt = (
f"Question: {question}\n\n"
f"Results so far:\n{context}\n\n"
"What single follow-up question still needs answering? "
"Reply with only the question, or DONE if the results already answer it."
)
response = llm.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
)
answer = response.choices[0].message.content.strip()
return None if answer.upper().startswith("DONE") else answer
루프에는 이미 모든 호프의 결과가 담겨 있어요. 여기서 중요한 포인트 — 마지막 결과만이 아니라 전부를 합쳐야 해요. 각 호프가 사슬의 한 고리를 가져오니까, 앞선 호프를 버리면 답을 질문과 이어주는 증거를 잃게 되거든요.
def rrf_fuse(hops, k=2, limit=10):
"""각 호프별 결과를 Reciprocal Rank Fusion(RRF)으로 합쳐요."""
scores, points = {}, {}
for hits in hops:
for rank, hit in enumerate(hits):
scores[hit.id] = scores.get(hit.id, 0) + 1 / (k + rank)
points.setdefault(hit.id, hit)
ranked = sorted(scores, key=scores.get, reverse=True)
return [points[i] for i in ranked[:limit]]
Reciprocal Rank Fusion(RRF) 은 각 청크를 매 호프에서의 순위로 점수를 매겨요. 공식은 1 / (k + rank)이고, 이걸 각 호프에 걸쳐 합산하죠. 그래서 어떤 호프에서든 높은 순위를 기록한 청크는 올라가고, 여러 호프에서 높은 순위를 기록한 청크는 더 올라가요. RRF에 대해 더 알고 싶다면 하이브리드 질의 문서를 참고해요. 루프가 각 호프의 결과를 이미 들고 있으니 여기서는 Python에서 fusion을 수행해요. 단일 요청 안에서 fusion을 하고 싶다면, Qdrant가 RrfQuery로 서버 측 RRF를 실행해 주기도 해요.
이제 조각들을 하나로 엮어볼게요. LLM이 만족할 때까지 루프를 돌리고, 그다음 fusion을 수행해요.
question = "Where was the director of the film Inception born?"
hops = [retrieve(question)] # 각 호프의 결과. LLM을 안내하는 데도 사용돼요
for _ in range(MAX_HOPS):
follow_up = next_subquestion(question, hops)
if follow_up is None:
break
print("follow-up:", follow_up)
hops.append(retrieve(follow_up))
pool = rrf_fuse(hops) # 모든 호프의 결과를 합쳐요. 추가 질의는 없어요
for point in pool[:3]:
print(point.payload["text"])
루프는 LLM이 만들어낸 후속 질문을 출력하고, rrf_fuse는 호프들을 재사용해 pool을 만들어요. 작고 인위적인 영화-감독 컬렉션으로 돌리면 이런 결과가 나와요:
follow-up: Where was Christopher Nolan born?
Christopher Nolan was born on 30 July 1970 in London, England. He developed an interest in filmmaking as a child.
Inception is a 2010 science fiction film written and directed by Christopher Nolan. It follows a thief who steals corporate secrets through dream-sharing technology.
Christopher Nolan studied English literature at University College London before starting his film career.
출생지 청크는 《인셉션》을 전혀 언급하지 않으니, 원래 질문으로는 이 청크를 찾을 수 없어요. 오직 후속 질문만이 찾아내죠. 여기서 RRF는 영화 청크와 출생지 청크 둘 다를 pool의 맨 위에 올려요. 그 pool을 여러분의 답변 단계 — 청크를 읽고 답을 작성하는 LLM 호출 — 에 넘겨주면 됩니다.
언제 써야 할까요
분해는 호프마다 LLM 호출과 질의를 하나씩 추가해요. 그러니 질문이 여러 사실에 걸쳐 있을 때만 꺼내 쓰는 게 좋아요. 단일 사실 질문이라면 질의 한 번이 더 빠르고 정확성도 같거든요. 이 방법이 여러분 데이터에 정말 도움이 되는지 확인하려면, 소규모 다중 홉 질문 집합에서 단일 패스(Single-Pass)와 분해의 recall@k를 비교해보세요. 검색 관련성 측정 튜토리얼이 그 설정 방법을 다루고 있어요.