질문을 여러 갈래로 바꿔 검색하기 — Multi-Query Retriever 활용법

📌 3줄 요약
· Multi-Query Retriever는 사용자의 질문 하나를 여러 표현의 질문으로 자동 확장한 뒤, 각각으로 검색해 그 결과를 합쳐 주는 LangChain 검색기입니다.
· "같은 뜻이지만 단어가 달라 검색에 걸리지 않던 문서"를 놓치지 않도록, 질문을 여러 각도에서 다시 던지는 것이 핵심입니다.
· 질문 변형은 LLM이 생성하고, 각 변형으로 얻은 문서를 중복 제거해 하나로 돌려주므로 검색 재현율(recall)을 끌어올릴 수 있습니다.
❓ 원래 질문 → 🧠 LLM이 여러 변형 생성 → 🔍 변형마다 각각 검색 → 📄 중복 제거 후 합치기

RAG(검색 증강 생성)에서 검색 품질은 결국 "질문을 어떻게 던지느냐"에 크게 좌우됩니다. 그런데 사용자는 보통 한 가지 표현으로만 질문을 적습니다. 문제는 벡터 검색이 의미의 거리를 재다 보니, 같은 뜻이라도 단어 선택이 조금만 달라져도 정작 필요한 문서를 놓칠 수 있다는 점입니다. 오늘은 이 약점을 보완하려고 고안된 LangChain의 Multi-Query Retriever를 차근차근 살펴보겠습니다.


🧩 한 번의 질문이 놓치는 것들

벡터 검색은 질문과 문서를 각각 임베딩으로 바꾼 뒤, 그 의미적 거리가 가까운 문서를 찾아 줍니다. 꽤 똑똑해 보이지만 한계가 있습니다. 질문에 쓰인 표현과 관점이 문서의 그것과 어긋나면, 분명 관련 있는 내용인데도 거리가 멀게 측정되어 검색에서 빠지는 일이 생깁니다.

예를 들어 "앱이 자꾸 느려져요"라고 물었을 때, 정작 해답이 담긴 문서는 "성능 저하 원인"이나 "응답 지연 최적화"라는 다른 어휘로 쓰여 있을 수 있습니다. 사람에겐 같은 이야기지만, 한 가지 표현으로만 검색하면 그 거리를 넘어서지 못하는 것이죠.

단일 질문의 성패가 단어 선택이라는 우연에 묶여 있다는 것 — 이것이 Multi-Query Retriever가 풀려는 문제의 출발점입니다.

💡 Multi-Query Retriever의 아이디어

Multi-Query Retriever의 발상은 간단합니다. 질문 하나에만 검색을 의존하지 말고, 같은 의도를 가진 여러 질문을 만들어 여러 번 검색하자는 것입니다. 이때 질문 변형을 사람이 손으로 쓰는 게 아니라 LLM이 자동으로 생성합니다.

원래 질문을 모델에게 건네면, 모델은 관점과 표현을 달리한 여러 개의 대안 질문을 만들어 냅니다. 그다음 각 질문으로 벡터 저장소를 검색하고, 모든 결과를 하나로 합친 뒤 중복을 제거해 최종 문서 집합을 돌려줍니다. 하나의 각도로는 놓쳤을 문서를 여러 각도가 함께 건져 올리는 셈입니다.


🔁 동작 흐름 따라가기

이 검색기가 내부에서 하는 일을 단계로 나눠 보면 구조가 한눈에 들어옵니다.

🧠 질문 생성 단계
LLM이 원래 질문을 바탕으로 표현이 다른 여러 질문을 만들어 냅니다. 보통 기본값으로 세 개 안팎을 생성합니다.
🔍 병합 검색 단계
각 질문으로 따로 검색한 뒤, 모든 결과를 모아 중복 문서를 제거하고 합집합을 최종 결과로 반환합니다.

동작은 이렇게 이어집니다. 사용자가 질문을 던지면 먼저 LLM이 여러 변형 질문을 만들고, 그 각각으로 기존 벡터 검색을 수행합니다. 변형마다 돌아온 문서들을 한데 모은 다음, 같은 문서가 여러 번 걸렸다면 한 번만 남기고 나머지를 정리해 모델에게 넘깁니다. 검색 횟수는 늘지만, 그만큼 놓치는 문서가 줄어드는 구조입니다.


⚙️ 코드로 살펴보기

LangChain은 이 패턴을 MultiQueryRetriever라는 클래스로 제공합니다. 필요한 재료는 기본 검색기(벡터 저장소의 retriever)와 질문을 변형해 줄 LLM, 이 두 가지입니다.

from langchain.retrievers.multi_query import MultiQueryRetriever
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings, ChatOpenAI

# 기존 벡터 저장소와 그 검색기
vectorstore = Chroma(
    collection_name="docs",
    embedding_function=OpenAIEmbeddings(),
)
base_retriever = vectorstore.as_retriever()

# 질문 변형을 담당할 LLM
llm = ChatOpenAI(temperature=0)

retriever = MultiQueryRetriever.from_llm(
    retriever=base_retriever,
    llm=llm,
)

from_llm 하나로 조립이 끝납니다. 이제 평소처럼 invoke를 부르면, 내부에서 질문 변형 → 각각 검색 → 병합이 자동으로 일어납니다.

# 한 번 호출이지만 내부에서는 여러 질문으로 검색된다
results = retriever.invoke("앱이 자꾸 느려지는 이유가 뭔가요?")

for doc in results:
    print(doc.page_content[:60])

겉보기에는 보통의 검색기와 똑같이 invoke 한 번이지만, 돌아온 결과는 여러 표현으로 긁어모아 합친 문서 집합이라는 점이 다릅니다.


🪜 생성되는 질문을 직접 확인하기

LLM이 실제로 어떤 변형 질문을 만들어 검색했는지 궁금할 때가 많습니다. 이럴 때는 로깅을 켜 두면 내부에서 생성된 질문들을 눈으로 확인할 수 있습니다. 디버깅이나 품질 점검에 유용합니다.

import logging

# MultiQueryRetriever가 만든 질문을 로그로 출력
logging.basicConfig()
logging.getLogger(
    "langchain.retrievers.multi_query"
).setLevel(logging.INFO)

results = retriever.invoke("앱이 자꾸 느려지는 이유가 뭔가요?")
# 로그에 생성된 변형 질문들이 찍힌다

필요하다면 질문을 몇 개 만들지, 또는 어떤 지시로 변형할지를 프롬프트로 직접 조정할 수도 있습니다. 변형 질문의 개수와 방향을 다루는 설정은 아래 표로 정리했습니다.

조정 대상 효과
변형 질문 개수많을수록 재현율은 오르지만 호출·검색 비용이 늘어납니다.
변형 프롬프트도메인 용어나 동의어를 유도해 검색 각도를 조절합니다.

🔧 실무 팁

  • 재현율과 비용의 맞교환 — 변형 질문 수를 늘리면 놓치는 문서는 줄지만, 그만큼 LLM 호출과 검색 횟수가 늘어 지연과 비용이 커집니다. 적정 개수를 찾는 것이 중요합니다.
  • 온도는 낮게 — 질문 변형용 LLM은 temperature를 낮게 두는 편이 안정적입니다. 지나치게 창의적인 변형은 오히려 엉뚱한 검색을 부를 수 있습니다.
  • 재현율↑, 정밀도는 별개 — 더 많은 문서를 끌어오는 만큼 관련 없는 문서가 섞일 여지도 생깁니다. 뒤이어 재정렬이나 압축으로 다듬으면 좋습니다.
  • 로그로 변형을 점검 — 생성된 질문이 의도와 어긋나면 검색도 어긋납니다. 운영 전에 어떤 변형이 만들어지는지 꼭 확인해 보세요.

❓ 자주 묻는 질문 (FAQ)

Q. 일반 검색기와 무엇이 다른가요?
A. 보통의 검색기는 받은 질문 그대로 한 번만 검색합니다. Multi-Query Retriever는 그 질문을 여러 표현으로 늘려 각각 검색한 뒤 결과를 합친다는 점이 다릅니다.

Q. 검색이 더 느려지지 않나요?
A. 맞습니다. 질문 변형을 위한 LLM 호출과 여러 번의 검색이 더해져 지연이 늘어납니다. 재현율을 얻는 대신 치르는 비용이므로, 변형 개수로 균형을 잡는 것이 좋습니다.

Q. 어떤 상황에 특히 잘 맞나요?
A. 같은 개념이 여러 용어로 표현되는 문서이거나, 사용자의 질문 표현이 제각각일 때 잘 맞습니다. 고객 지원 문서, 사내 위키처럼 어휘가 다양한 자료에 효과적입니다.


마무리

정리하면, Multi-Query Retriever는 단일 질문이 단어 선택이라는 우연에 발목 잡히는 문제를, LLM으로 질문을 여러 각도로 확장해 푸는 검색기입니다. 원래 질문을 여러 변형 질문으로 바꿔 각각 검색하고, 그 결과를 중복 제거해 합치는 방식으로 검색 재현율을 끌어올립니다. 변형 개수와 프롬프트를 조절해 재현율과 비용의 균형을 맞추는 것, 그리고 로그로 생성된 질문을 점검하는 것이 실무의 핵심이었습니다. 다음 편에서는 검색 결과를 질문에 더 잘 맞게 걸러 주는 Contextual Compression 이야기로 이어가 보겠습니다.

댓글

이 블로그의 인기 게시물

LCEL — LangChain 표현식으로 체인 깔끔하게 잇기

Streaming — 답변을 실시간으로 흘려보내기

Callback와 로깅 — LangChain 내부 들여다보기