RAG 성능 고도화 — 질의 변형과 다중 질의 생성
이 글은 RAG 검색 단의 정확도를 끌어올리는 '질의 변형'을 직접 적용해보며 정리한 기록이다. 청킹으로 문서를 어떻게 자를지 고민한 데 이어, 이번엔 검색에 들어가는 '질문' 그 자체를 손보는 이야기다. 책과 Gemini의 도움을 빌려 궁금증을 해소해가며 쌓았다.
사용자의 질문을 그대로 검색하면, 왜 자꾸 헛소리만 늘어놓을까?
RAG를 어느 정도 굴려보고 나서 가장 먼저 부딪힌 벽은, 사람들이 생각보다 질문을 엉성하게 한다는 사실이었다. 검색 단에서 청킹이고 임베딩이고 다 잘 깔아놨는데도 엉뚱한 문서가 올라오길래 한참을 들여다봤더니, 문제의 절반은 시스템이 아니라 입력으로 들어온 질문 그 자체에 있었다.
벡터 검색은 단어가 똑같으냐가 아니라 뜻이 비슷하냐를 따진다고는 하지만, 그 "비슷함"에도 한계가 있다. 사용자가 "컴퓨터 고치는 법"이라고 물었는데 정작 문서에는 "PC 수리 가이드"라고 적혀 있다면, 임베딩 공간에서 두 표현의 거리가 멀어 검색이 그냥 실패해버린다. 같은 걸 묻고 있는데도 단어 선택 하나가 어긋나서 정답 문서를 놓치는 것이다.
그래서 나온 발상이 질의 변형(Query Transformation)이다. 사용자가 던진 원래 질문을 그대로 검색에 쓰지 않고, 더 잘 걸리는 검색 쿼리로 한 번 다시 써서 넣는 과정이다. 쉽게 말하면 면접관 앞에서 횡설수설하는 지원자의 말을, 옆에 있던 베테랑이 "아, 이분이 지금 묻고 싶은 건 이거예요"라고 정리해주는 셈이다. 사용자가 대충 물어도 시스템이 알아듣게 만드는 장치다.
이때 질문을 다시 쓰는 주체는 내가 아니라 LLM이다. 원래 질문을 분석하고 재구성하는 일을 LLM에게 맡긴다. 이 기법이 매력적인 이유는 적용 난이도에 비해 효과가 크다는 데 있다. 파이프라인을 갈아엎지 않고도 검색 앞단에 변형 단계 하나만 끼워 넣으면 되는데, 그 대가로 훨씬 풍부하고 실용적인 정보를 끌어올 수 있다. 손이 적게 가면서 성능이 올라가는, 흔치 않은 개선책이라 먼저 손대볼 만하다고 봤다.
질문 하나로 부족하면, 여러 개로 늘려서 검색한다
질의 변형 중에서도 가장 먼저 시도해본 게 다중 질의 생성(Multi-Query Generation)이다. 이름 그대로, 하나의 질문을 LLM에게 줘서 표현이 조금씩 다른 여러 개의 질문으로 불려놓는 방법이다. 질문 하나로 검색하면 그 한 번의 표현에 운명을 거는 셈인데, 여러 표현으로 동시에 던지면 어느 하나는 정답 문서에 걸릴 확률이 훨씬 높아진다. 그물을 한 번만 던지는 대신, 각도를 달리해 여러 번 던지는 것이다.
흐름은 단순하다. 원래 질문을 LLM이 서로 다른 표현의 질문 여러 개로 확장하고, 그 질문들을 병렬로 각각 문서 검색에 넣는다. 그렇게 나온 결과들을 한데 모으면 같은 문서가 여기저기서 중복으로 딸려 오기 마련이라, 중복을 제거한 뒤 하나로 통합해 최종 결과로 넘긴다. 단일 쿼리였다면 표현이 어긋나 놓쳤을 문서까지 건져 올리니, 그만큼 더 풍부한 맥락을 답변 생성에 쥐여줄 수 있다.
말로만 보면 감이 잘 안 오니 실제 예시로 보자. 사용자가 "주식투자 처음 시작하려면 어떻게 해야 하죠?"라고 물었다고 하자. 딱 봐도 막연하고 검색하기 까다로운 질문이다. 이걸 그대로 벡터 DB에 넣으면 어디에 초점을 맞춰야 할지 애매하다. 다중 질의 생성은 이 한 문장을 LLM에게 넘겨, 초점이 다른 세 갈래 질문으로 다시 써낸다. "초보 투자자를 위한 주식 투자 기초지식은 무엇인가", "주식 시장 분석을 위한 기본적인 재무제표 읽는 방법은", "주식투자 시작 전 필요한 자금 관리 전략은"처럼 말이다. 막연했던 한 질문이 기초 지식·재무제표·자금 관리라는 구체적인 세 방향으로 쪼개지고, 각각이 따로 검색에 들어간다. 단일 쿼리 하나로는 분명 놓쳤을 영역까지 골고루 훑게 되는 것이다.
graph TD
A["사용자의 모호한 질문 1개"] --> B["LLM: 질문 재생성<br/>(3~5개 다른 표현으로 확장)"]
B --> C["질문 1"]
B --> D["질문 2"]
B --> E["질문 3"]
C --> F["벡터 DB 검색 1"]
D --> G["벡터 DB 검색 2"]
E --> H["벡터 DB 검색 3"]
F --> I["검색 결과 통합<br/>(합집합)"]
G --> I
H --> I
I --> J["중복 제거"]
J --> K["풍부해진 문맥(Context)<br/>LLM에 전달"]
K --> L["최종 답변 생성"]
이 흐름을 머릿속으로만 그리지 말고 작은 코드로 직접 돌려보는 편이 이해가 빠르다. LangChain에는 이 다중 질의 생성을 통째로 구현해둔 MultiQueryRetriever가 있어서, 검색기와 LLM만 물려주면 질문 확장부터 결과 통합까지 알아서 처리해준다. 다만 그 안에서 무슨 일이 벌어지는지 감을 잡으려면, 라이브러리를 걷어내고 핵심 흐름만 직접 짜보는 게 빠르다. 외부 API 없이 '여행지 추천' 검색을 예로, 막연한 질문 하나가 여러 질문으로 늘어나 검색되고 다시 하나로 합쳐지는 과정을 그대로 옮겨봤다.
# 다중 질의(Multi-Query) 검색 예제 — 여행지 추천 도메인
# 외부 라이브러리 없이 파이썬 표준 라이브러리만 사용
# ── 1. 여행 문서 모음 ──────────────────────────────────────────────────────────
# 실제 RAG에서는 벡터 DB에 저장된 청크들이 이 역할을 한다.
TRAVEL_DOCS = [
{
"id": "doc_001",
"title": "제주도 자연 여행",
"content": "제주도는 한라산과 성산일출봉 등 유네스코 세계자연유산을 품고 있다. "
"오름 트레킹과 해안 올레길 걷기가 대표 액티비티이며, 봄에는 유채꽃 명소로 유명하다.",
},
{
"id": "doc_002",
"title": "제주도 맛집과 카페",
"content": "제주도는 흑돼지 바베큐, 갈치조림, 해산물 회가 유명하다. "
"애월 해안도로를 따라 감성 카페들이 즐비하며 오션뷰 커피를 즐길 수 있다.",
},
{
"id": "doc_003",
"title": "교토 전통 문화 여행",
"content": "교토는 금각사, 아라시야마 대나무 숲, 후시미이나리 신사 등 고즈넉한 일본 전통 명소가 많다. "
"기온 마쓰리 등 계절 축제와 다도 체험, 기모노 투어도 인기다.",
},
{
"id": "doc_004",
"title": "방콕 도심 관광",
"content": "방콕은 왓 프라깨우 왕궁사원, 왓 아룬, 수상시장이 유명하다. "
"야시장과 쇼핑몰, 길거리 음식 투어는 자유여행자들에게 특히 인기 있다.",
},
{
"id": "doc_005",
"title": "유럽 배낭여행 코스",
"content": "파리 에펠탑과 루브르 박물관, 로마 콜로세움과 바티칸, 바르셀로나 사그라다 파밀리아는 "
"유럽 배낭여행의 3대 필수 코스다. 유레일 패스를 활용하면 도시 간 이동이 편리하다.",
},
{
"id": "doc_006",
"title": "강원도 힐링 여행",
"content": "강원도 속초와 강릉은 설악산 등반과 경포대 해수욕장으로 유명하다. "
"겨울에는 평창 스키 리조트가, 가을에는 단풍 드라이브 코스가 인기다.",
},
{
"id": "doc_007",
"title": "발리 해변 휴양",
"content": "발리는 꾸따 비치와 우붓 라이스 테라스가 대표 명소다. "
"서핑, 스노클링, 스쿠버 다이빙 등 해양 액티비티가 풍부하고 "
"힌두 사원과 일몰 명소인 타나롯 사원도 빼놓을 수 없다.",
},
{
"id": "doc_008",
"title": "서울 도심 여행",
"content": "서울은 경복궁, 북촌 한옥마을, 인사동 전통 거리, 남산타워가 주요 관광지다. "
"홍대, 이태원, 성수동은 젊은 여행자들에게 인기 있는 핫플레이스 지역이다.",
},
]
# ── 2. 단순 키워드 기반 유사도 계산 ──────────────────────────────────────────
# 실제 RAG에서는 임베딩 벡터의 코사인 유사도로 계산한다.
# 여기서는 쿼리 단어가 문서에 '부분 문자열'로 들어있는지 세어 흉내 낸다.
# (한국어는 '맛집'과 '맛집과'처럼 조사가 붙으므로 단순 일치보다 부분 포함이 잘 맞는다.)
def tokenize(text: str) -> list[str]:
"""텍스트를 소문자 공백 분리 토큰 리스트로 변환한다."""
return text.lower().split()
def keyword_match_score(query: str, doc_text: str) -> float:
"""
쿼리 단어 중 문서 본문에 포함된 단어의 비율을 유사도 점수로 반환한다.
실제로는 임베딩 벡터의 코사인 유사도를 사용한다.
"""
query_tokens = set(tokenize(query))
if not query_tokens:
return 0.0
doc_text = doc_text.lower()
hits = sum(1 for t in query_tokens if t in doc_text) # 부분 문자열 포함 검사
return hits / len(query_tokens)
def search_docs(query: str, top_k: int = 3, threshold: float = 0.1) -> list[dict]:
"""확장 질문 하나로 문서를 검색해 상위 결과를 반환한다."""
scores = []
for doc in TRAVEL_DOCS:
score = keyword_match_score(query, doc["title"] + " " + doc["content"])
if score >= threshold:
scores.append((score, doc))
scores.sort(key=lambda x: x[0], reverse=True)
return [doc for _, doc in scores[:top_k]]
# ── 3. LLM 역할: 질문 확장 함수 ──────────────────────────────────────────────
# 실제로는 LLM이 원래 질문을 받아 다른 관점의 질문들을 생성한다.
# 여기서는 간단한 규칙 사전으로 흉내를 낸다.
EXPANSION_RULES = {
"자연": ["오름 트레킹 등산 명소", "유네스코 세계자연유산 국내여행", "산 바다 자연 경관 힐링 여행"],
"문화": ["전통 사찰 역사 유적 투어", "박물관 유네스코 문화유산 관광", "기모노 한복 전통 체험 여행"],
"음식": ["맛집 길거리 음식 야시장 투어", "해산물 현지 특산물 미식 여행", "카페 디저트 감성 식도락 여행"],
"해변": ["비치 해수욕장 서핑 해양 액티비티", "스노클링 스쿠버 열대 바다 여행", "오션뷰 일몰 리조트 휴양"],
"유럽": ["파리 로마 바르셀로나 배낭여행", "에펠탑 콜로세움 유럽 명소 관광", "유레일 패스 유럽 도시 여행"],
"일본": ["교토 오사카 도쿄 일본 여행", "벚꽃 단풍 일본 계절 여행", "신사 사원 일본 전통 문화 투어"],
}
DEFAULT_EXPANSIONS = [
"국내 여행 추천 명소 관광",
"해외 여행 핫플레이스 코스",
"자유 여행 배낭여행 추천 루트",
]
def expand_query_with_rules(original_query: str, n: int = 3) -> list[str]:
"""
규칙 사전으로 원래 질문을 n개의 확장 질문으로 변환한다.
매칭된 여러 카테고리에서 번갈아 하나씩 뽑아 '서로 다른 각도'를 확보한다.
실제로는 LLM이 이 작업을 수행하며, 문맥과 의도를 더 정교하게 반영한다.
"""
matched = [queries for kw, queries in EXPANSION_RULES.items() if kw in original_query]
expanded: list[str] = []
if matched:
# 라운드로빈: 카테고리1[0], 카테고리2[0], 카테고리1[1] ... 순으로 다양하게 섞는다
depth = max(len(q) for q in matched)
for i in range(depth):
for queries in matched:
if i < len(queries):
expanded.append(queries[i])
# 매칭이 없거나 부족하면 기본 확장으로 보충
if len(expanded) < n:
expanded.extend(DEFAULT_EXPANSIONS)
return expanded[:n]
# ── 4. 다중 질의 검색 실행 ────────────────────────────────────────────────────
def multi_query_search(original_query: str, top_k_per_query: int = 3) -> dict:
"""
다중 질의(Multi-Query) 검색의 핵심 흐름:
1) 원래 질문을 여러 확장 질문으로 변환
2) 각 확장 질문으로 개별 검색 수행
3) 결과 합집합(union)에서 중복 제거
"""
# 질문 확장 — 실제로는 LLM이 생성한다
expanded_queries = expand_query_with_rules(original_query)
# 각 확장 질문별 검색 결과 수집
per_query_results: dict[str, list[dict]] = {}
for query in expanded_queries:
per_query_results[query] = search_docs(query, top_k=top_k_per_query)
# 결과 합집합: doc id 기준으로 중복 제거
seen_ids: set[str] = set()
final_docs: list[dict] = []
for query, docs in per_query_results.items():
for doc in docs:
if doc["id"] not in seen_ids:
seen_ids.add(doc["id"])
final_docs.append(doc)
return {
"original_query": original_query,
"expanded_queries": expanded_queries,
"per_query_results": per_query_results,
"final_docs": final_docs,
}
# ── 5. 결과 출력 ──────────────────────────────────────────────────────────────
def print_results(result: dict) -> None:
print("=" * 65)
print(f"[원래 질문] {result['original_query']}")
print("=" * 65)
print("\n▶ 확장된 질문 목록 (실제로는 LLM이 생성한다)")
for i, q in enumerate(result["expanded_queries"], 1):
print(f" Q{i}. {q}")
print("\n" + "-" * 65)
print("▶ 확장 질문별 검색 결과")
for q, docs in result["per_query_results"].items():
print(f"\n [질문] {q}")
if docs:
for doc in docs:
print(f" → {doc['title']} (id: {doc['id']})")
else:
print(" → 검색 결과 없음")
print("\n" + "-" * 65)
print(f"▶ 중복 제거 후 최종 결과 ({len(result['final_docs'])}건)")
for i, doc in enumerate(result["final_docs"], 1):
print(f"\n [{i}] {doc['title']}")
preview = doc["content"][:60] + ("..." if len(doc["content"]) > 60 else "")
print(f" {preview}")
print("=" * 65)
# ── 6. 데모 실행 ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
# 사용자의 모호한 원래 질문 1개
user_question = "자연도 보고 음식도 즐길 수 있는 여행지 추천해줘"
result = multi_query_search(user_question, top_k_per_query=3)
print_results(result)
이렇게 직접 짜보고 나면 MultiQueryRetriever 같은 라이브러리가 무엇을 대신해주는지 분명해진다. 결국 사용자가 던진 하나의 질문을 LLM으로 여러 개의 유사 질문으로 확장해 검색해주는 도구인데, 그 동작은 앞서 설명한 다중 질의 생성을 단계별로 자동화한 것에 지나지 않는다. 안에서 벌어지는 일을 풀어보면, 먼저 LLM이 원래 질문을 바탕으로 표현이 다른 질문 서너 개를 만들어내고(질문 확장), 그 질문들로 각각 벡터 DB를 따로 검색한 다음(개별 검색), 찾아온 문서들을 모두 합쳐 중복을 걷어내고(결과 통합), 그렇게 넓어진 맥락을 LLM에 넘겨 최종 답변을 만든다. 앞에서 손으로 그렸던 그림을 라이브러리가 그대로 대신 밟아주는 셈이다.
그래서 다중 질의는 언제 쓰고, 언제는 과한가
여기까지 보면 다중 질의를 무조건 깔아야 할 것 같지만, 공짜로 얻는 개선은 아니다. 득과 실을 같이 놓고 봐야 판단이 선다.
| 장점 | 단점 |
|---|---|
| 검색 누락 방지 — 여러 표현으로 검색망을 넓히니 관련 문서를 찾을 확률이 크게 올라간다 | 비용 발생 — 질문을 확장하려고 LLM을 한 번 더 호출하므로 API 비용이 추가된다 |
| 사용자 편의성 — 대충 물어도 AI가 알아서 의도를 파악해 검색해준다 | 속도 저하 — 검색을 여러 번 돌리는 만큼 전체 응답 시간이 길어진다 |
정리하면 다중 질의는 사용자의 질문이 막연하고 표현이 제각각인 상황, 그래서 단어 하나 어긋났다고 정답 문서를 통째로 놓치는 게 가장 뼈아픈 서비스에서 값을 한다. 검색 한 번 더 도는 지연과 LLM 추가 호출 비용을 내주고, 검색 누락을 막는 안전망을 사는 거래라고 봤다. 반대로 질문이 이미 정형화돼 있거나(사내 코드명·정해진 양식의 질의처럼) 응답 속도와 호출 비용이 빠듯한 환경이라면, 모든 질문을 굳이 서너 갈래로 불려가며 검색할 이유는 없다. 결국 다중 질의는 기본값으로 켜두는 기능이 아니라, 내 사용자가 얼마나 엉성하게 묻는지를 먼저 보고 켜고 끄는 선택지에 가깝다고 볼수있다.
[참고자료]
RAG 마스터 레시피 - 브라이스 유 , 조경아 , 박수진 , 김재웅
https://product.kyobobook.co.kr/detail/S000216240484
테디노트의 랭체인을 활용한 RAG 비법노트
'AI Engineering > RAG' 카테고리의 다른 글
| RAG 성능 고도화1 — 청킹 전략 깊게 파기 (0) | 2026.06.21 |
|---|