검색은 됐는데 답하기 애매한 순간
2편에서는 sample-office-guide.md에서 만든 chunk를 vector store에 넣고, retriever가 질문과 가까운 chunk를 꺼내는 흐름을 봤다. 그때 일부러 강조한 문장이 있다. retriever 결과는 답변이 아니라 답변 후보라는 점이다.
3편은 그 다음 장면에서 출발한다. 질문은 여전히 단순하다.
회의실 예약은 언제까지 가능한가?
이 질문에는 회의실 예약은 사용 시작 1시간 전까지 가능합니다.라는 chunk가 잘 걸려야 한다. 그런데 실제 문서가 조금만 커지면 검색 결과는 이렇게 흔들린다. 회의실 예약 문장은 첫 번째로 오지만, 두 번째에는 장비 반납 문장이 따라오고, 세 번째에는 보안 출입 카드 문장이 섞인다. 질문과 완전히 무관하지는 않지만, 답변 근거로 쓰기에는 넓은 결과다.
여기서 흔히 하는 실수가 k 값을 바꾸거나 prompt 문장을 더 엄격하게 쓰는 것이다. 물론 둘 다 도움이 될 수 있다. 하지만 검색 결과 자체가 넓게 들어오면 prompt는 이미 섞인 재료를 받는다. 모델은 그 안에서 답을 만들어내지만, 어떤 chunk가 실제 근거였는지 점점 흐려진다.
검색 결과가 애매할 때는 retriever 전략을 기능 목록처럼 붙이면 안 된다. metadata filter로 검색 범위를 먼저 좁히고, multi-query retrieval로 질문 표현을 넓혀보고, context compression으로 가져온 chunk 안의 불필요한 문장을 덜어내고, reranking으로 최종 후보의 순서를 다시 잡는다.
판단 기준은 단순하다. prompt에 넣기 전에 어떤 후보를 남기고, 어떤 후보를 빼야 하는지 설명할 수 있어야 한다.
실행 예시: 검색 결과를 실패 로그처럼 읽는다
전략을 붙이기 전에 현재 결과를 읽어야 한다. 검색 결과를 성공 로그처럼 보면 "결과가 나왔다"에서 멈춘다. 실패 로그처럼 보면 "왜 이 chunk가 들어왔지?"라는 질문이 생긴다.
예시 chunk를 조금 늘려 보자. 같은 sample-office-guide.md 안에 회의실, 장비, 보안, 방문자 안내가 함께 들어 있다.
from langchain_core.documents import Document # 검색 품질 비교에 사용할 최소 예시 chunk를 만든다.chunks = [ Document( page_content="회의실 예약은 사용 시작 1시간 전까지 가능합니다.", metadata={"source": "sample-office-guide.md", "section": "reservation"}, ), Document( page_content="회의실 사용 후에는 공용 장비 상태를 확인해 주세요.", metadata={"source": "sample-office-guide.md", "section": "reservation"}, ), Document( page_content="장비 대여는 안내 데스크에서 기록 후 반납합니다.", metadata={"source": "sample-office-guide.md", "section": "equipment"}, ), Document( page_content="방문자 출입은 보안 데스크 승인 후 임시 카드를 발급합니다.", metadata={"source": "sample-office-guide.md", "section": "security"}, ),]
retriever가 아래처럼 결과를 돌려줬다고 가정한다.
reservation- 회의실 예약은 사용 시작 1시간 전까지 가능합니다.equipment- 장비 대여는 안내 데스크에서 기록 후 반납합니다.security- 방문자 출입은 보안 데스크 승인 후 임시 카드를 발급합니다.
첫 번째 결과는 좋다. 문제는 두 번째와 세 번째다. 장비 대여와 방문자 출입은 "사내 안내"라는 큰 주제에서는 비슷하지만, 회의실 예약 마감 시점을 묻는 질문에는 필요하지 않다. 이 상태로 prompt에 세 chunk를 모두 넣으면 모델은 답을 맞힐 수도 있다. 하지만 검색 품질이 좋았기 때문이 아니라 첫 번째 chunk가 운 좋게 섞여 있었기 때문이다.
답변을 만들기 전에는 다음 세 가지를 먼저 기록한다.
- 정답에 직접 필요한 chunk가 몇 번째에 왔는가
- 같은 section의 보조 문맥이 같이 왔는가
- 다른 section의 chunk가 왜 끼어들었는가
이 기록이 있어야 뒤에서 전략을 바꿨을 때 좋아졌는지 나빠졌는지 비교할 수 있다.
metadata filter로 먼저 검색 공간을 줄인다
가장 먼저 볼 것은 metadata다. 1편에서 metadata를 계속 남기자고 한 이유가 여기서 드러난다. chunk마다 section이 남아 있다면, 질문이 명백하게 회의실 예약에 관한 것일 때 검색 범위를 reservation 영역으로 좁힐 수 있다.
# 질문 범주가 분명할 때는 의미 검색 전에 metadata로 후보 공간을 줄인다.retriever = vector_store.as_retriever( search_kwargs={ "k": 3, "filter": {"section": "reservation"}, })
이 코드는 사용하는 vector store에 따라 문법이 달라질 수 있다. 특정 문법보다 먼저 볼 것은 순서다. 의미 검색을 더 똑똑하게 만들기 전에, 애초에 검색하면 안 되는 영역을 제외할 수 있는지 본다.
metadata filter는 특히 문서 범주가 분명할 때 효과가 좋다. 사내 문서라면 section, department, doc_type, version 같은 값이 후보가 된다. 제품 문서라면 product, feature, release, locale 같은 값이 도움이 된다.
다만 filter를 너무 빨리 믿으면 안 된다. 사용자가 "회의실에서 쓰는 모니터는 어디서 빌리나요?"라고 물으면 reservation과 equipment가 동시에 필요할 수 있다. 질문이 여러 영역을 걸치는데 section을 하나로 고정하면 필요한 근거를 스스로 버리는 셈이 된다.
metadata filter를 쓴 뒤에는 결과를 이렇게 비교한다.
before
reservation- 회의실 예약은 사용 시작 1시간 전까지 가능합니다.equipment- 장비 대여는 안내 데스크에서 기록 후 반납합니다.security- 방문자 출입은 보안 데스크 승인 후 임시 카드를 발급합니다.after:
section = reservation
reservation- 회의실 예약은 사용 시작 1시간 전까지 가능합니다.reservation- 회의실 사용 후에는 공용 장비 상태를 확인해 주세요.
좋아진 점은 다른 section이 빠졌다는 것이다. 아직 남은 질문은 있다. 두 번째 chunk는 답변에 꼭 필요한가? "언제까지 가능한가"라는 질문에는 첫 번째 chunk만 있으면 충분하다. 이때 다음 전략이 필요해진다.
multi-query retrieval은 질문 표현을 넓힌다
metadata filter가 검색 공간을 줄이는 전략이라면, multi-query retrieval은 질문 표현을 넓히는 전략이다. 사용자의 질문과 문서의 표현이 다를 때 유용하다.
예를 들어 사용자가 이렇게 물을 수 있다.
회의실 신청 마감 시간이 어떻게 돼?
문서에는 신청 마감 시간이라는 표현이 없고 예약, 사용 시작 1시간 전이라는 표현이 있을 수 있다. 단일 query로는 표현 차이 때문에 원하는 chunk가 뒤로 밀릴 수 있다. 이때 원 질문에서 몇 개의 검색 query를 만들어 각각 검색한 뒤 결과를 합칠 수 있다.
# 같은 의도를 여러 검색 표현으로 풀어 표현 차이를 줄인다.queries = [ "회의실 신청 마감 시간", "회의실 예약 가능 시점", "회의실 사용 시작 전 예약 기준",] candidate_docs = [] for query in queries: candidate_docs.extend(retriever.invoke(query)) unique_docs = deduplicate_by_source_and_text(candidate_docs)
여기서 조심할 점은 multi-query가 검색 의도를 넓힌다는 것이다. 넓히는 방향이 맞으면 표현 차이를 줄인다. 반대로 너무 넓히면 질문이 흐려진다. 회의실 신청 마감 시간이 사내 시설 이용 정책으로 바뀌면 장비 대여, 방문자 출입, 좌석 예약까지 같이 들어올 수 있다.
그래서 multi-query를 쓸 때는 생성된 query를 결과와 함께 남겨야 한다.
query: 회의실 신청 마감 시간
hit:
reservation- 회의실 예약은 사용 시작 1시간 전까지 가능합니다.query: 회의실 예약 가능 시점
hit:
reservation- 회의실 예약은 사용 시작 1시간 전까지 가능합니다.query: 회의실 사용 시작 전 예약 기준
hit:
reservation- 회의실 예약은 사용 시작 1시간 전까지 가능합니다.
이 출력은 단순한 디버그 로그가 아니다. 검색 품질을 판단하는 근거다. 같은 정답 chunk가 여러 표현에서 반복해서 올라오면 좋은 신호다. 반대로 query마다 전혀 다른 section이 올라오면 질문 확장이 너무 넓거나 embedding 기준이 문서 표현과 맞지 않을 수 있다.
context compression은 가져온 뒤에 줄인다
retriever가 가져온 chunk가 항상 prompt에 그대로 들어가야 하는 것은 아니다. 특히 chunk가 길거나 한 chunk 안에 여러 문장이 섞여 있으면, 답변에 필요한 문장만 남기는 단계가 필요하다. 이 흐름이 context compression이다.
예시를 조금 바꿔 보자.
회의실 예약은 사용 시작 1시간 전까지 가능합니다.
회의실 사용 후에는 공용 장비 상태를 확인해 주세요.
예약 변경은 참석자에게 별도로 안내해야 합니다.
질문이 "회의실 예약은 언제까지 가능한가?"라면 첫 문장이 핵심이다. 두 번째와 세 번째 문장은 같은 section에 있지만 답변에는 직접 필요하지 않다. compression은 이런 chunk를 더 짧은 context로 바꾸는 역할을 한다.
# 질문에 직접 필요한 문장만 남기되 metadata는 유지한다.def compress_for_question(question: str, docs: list[Document]) -> list[Document]: compressed = [] for doc in docs: sentences = split_sentences(doc.page_content) kept = [ sentence for sentence in sentences if "예약" in sentence and ("전까지" in sentence or "시작" in sentence) ] if kept: compressed.append( Document( page_content=" ".join(kept), metadata=doc.metadata, ) ) return compressed
실제 구현은 더 정교한 compressor를 쓸 수 있다. 여기서는 원리를 보기 위해 단순한 함수로 표현했다. 핵심은 metadata를 유지하면서 prompt에 들어갈 본문을 줄이는 것이다. context를 줄였는데 source나 section이 사라지면 나중에 답변과 근거를 연결하기 어렵다.
compression은 비용과 노이즈를 줄이는 데 도움이 된다. 하지만 과하게 줄이면 반대로 근거가 빈약해진다. "회의실 예약은 1시간 전까지 가능하다"는 문장만 남고, "예약 변경은 참석자에게 안내해야 한다"는 후속 조건이 필요한 질문에서 빠질 수 있다. 그래서 compression 결과도 원래 chunk와 나란히 봐야 한다.
reranking은 최종 후보의 순서를 다시 본다
metadata filter와 multi-query, compression을 지나도 마지막으로 남는 문제가 있다. 후보는 맞는데 순서가 애매한 경우다. vector similarity는 질문과의 가까움을 보지만, 답변에 실제로 도움이 되는 순서를 항상 보장하지는 않는다.
reranking은 검색된 후보를 다시 점수화해 순서를 바꾸는 단계다. 첫 검색은 넓게 가져오고, reranking은 답변 가능성 기준으로 좁히는 흐름이라고 보면 된다.
# 답변 가능성 신호를 기준으로 후보 chunk의 순서를 다시 매긴다.def rank_for_answer(question: str, docs: list[Document]) -> list[Document]: # score는 예시용 휴리스틱이며 실제 서비스에서는 별도 reranker로 대체할 수 있다. def score(doc: Document) -> int: text = doc.page_content points = 0 if "회의실" in text: points += 1 if "예약" in text: points += 1 if "전까지" in text or "1시간" in text: points += 2 if doc.metadata.get("section") == "reservation": points += 1 return points return sorted(docs, key=score, reverse=True)
이 예시는 의도적으로 단순하다. 실제 reranker는 별도 모델이나 점수화 로직을 사용할 수 있다. 블로그 예제에서 챙길 지점은 retriever가 가져온 순서를 그대로 믿지 않고, 답변에 필요한 신호를 기준으로 다시 정렬한다는 점이다.
reranking 후에는 최소한 상위 몇 개 결과를 출력해서 읽어야 한다.
reservation- 회의실 예약은 사용 시작 1시간 전까지 가능합니다.reservation- 회의실 사용 후에는 공용 장비 상태를 확인해 주세요.equipment- 장비 대여는 안내 데스크에서 기록 후 반납합니다.
이 결과라면 prompt에는 1번만 넣거나, 1번과 2번을 함께 넣을지 판단할 수 있다. 3번은 같은 사내 안내 문서에서 왔지만 현재 질문에는 빼는 편이 낫다.
경계 사례: 전략은 한 번에 다 켜지 않는다
retriever 전략은 기능 목록이 아니다. 검색 결과를 읽고 필요한 것만 붙이는 조정 순서에 가깝다.
metadata filter는 검색 공간을 줄인다. multi-query retrieval은 질문 표현을 넓힌다. context compression은 가져온 chunk 안에서 답변에 필요한 부분만 남긴다. reranking은 후보의 순서를 다시 잡는다.
이 네 가지를 한 번에 켜면 결과가 좋아져도 이유를 알기 어렵다. 반대로 나빠졌을 때도 어디서 틀어졌는지 찾기 어렵다. 그래서 작은 실험 단위로 바꿔야 한다.
baseline retrieval
-> metadata filter 적용
-> multi-query 추가
-> compression 적용
-> reranking 적용
-> prompt context 확정
각 단계에서 같은 질문을 넣고 결과를 비교한다. "정답 chunk가 몇 번째인가", "불필요한 section이 줄었는가", "metadata가 유지되는가", "prompt에 들어갈 context가 짧아졌는가"를 본다.
이 순서를 지키면 retriever 전략이 감으로 바뀌지 않는다. 검색 결과가 왜 좋아졌는지 기록으로 남고, 나중에 문서가 늘어나거나 질문 패턴이 바뀌었을 때 어느 지점부터 다시 봐야 할지 알 수 있다.
다음 글로 넘길 기준
이 글을 지나면 더 똑똑한 답변보다 먼저 검색 후보를 좁히는 절차가 남아야 한다.
sample-office-guide.md는 이제 chunk 목록으로만 남아 있지 않다. chunk에는 section metadata가 있고, 질문은 여러 검색 표현으로 확장될 수 있고, 검색된 context는 압축될 수 있고, 후보는 다시 순서가 매겨질 수 있다. 이 흐름을 거친 뒤에야 prompt에 넣을 context를 고를 수 있다.
다음 글에서는 검색 품질을 더 넓은 관점에서 본다. 지금까지는 "어떤 chunk를 가져올 것인가"에 집중했다면, 다음에는 문서 구조를 어떻게 살릴 것인지로 넘어간다. keyword search와 semantic search를 함께 쓰는 방식, 작은 chunk로 찾고 큰 부모 문서를 가져오는 방식, 하나의 문서를 여러 표현으로 인덱싱하는 방식이 여기서 이어진다.
앞 글들과 기준은 이어진다. RAG에서 답변 품질은 마지막 LLM 호출에서 갑자기 좋아지지 않는다. 문서가 어떤 단위로 쪼개졌고, retriever가 어떤 후보를 가져왔고, 그 후보를 어떻게 줄이고 정렬했는지에 따라 답변의 재료가 결정된다.
자주 묻는 질문
Q. metadata filter를 먼저 쓰는 것이 항상 맞나?
A. 질문의 범주가 분명하고 metadata가 믿을 만할 때는 먼저 보는 편이 좋다. 다만 질문이 여러 범주를 걸치면 filter가 필요한 근거를 버릴 수 있으므로 결과를 반드시 비교해야 한다.
Q. multi-query retrieval은 query transformation과 같은가?
A. 겹치는 부분은 있지만 목적이 조금 다르다. query transformation은 하나의 검색 표현을 바꾸는 흐름으로 쓸 수 있고, multi-query retrieval은 여러 표현으로 검색해 후보를 합치는 방식에 가깝다. 둘 다 원 질문의 의도를 유지하는지 확인해야 한다.
Q. reranking을 쓰면 compression은 필요 없나?
A. 아니다. reranking은 후보 순서를 바꾸고, compression은 후보 안의 context를 줄인다. 긴 chunk가 상위에 잘 올라오더라도 prompt에 그대로 넣기 부담스럽다면 compression이 여전히 필요할 수 있다.

댓글
GitHub 계정으로 로그인하면 댓글을 남길 수 있습니다. 댓글은 GitHub Discussions를 통해 운영됩니다.