벡터 검색을 붙이면 키워드 검색보다 항상 똑똑하게 답을 찾아줄 거라 기대하는 분이 많은데요, 실제로는 벡터 검색이 엉뚱한 문서를 물어오는 상황이 꽤 자주 벌어집니다. 결론부터 정리하면, 질의에 제품 코드·에러 번호·고유명사가 섞이거나 재현율이 눈에 띄게 흔들린다면 BM25를 함께 쓰는 하이브리드 검색을 도입할 시점입니다. 반대로 질의가 대부분 자연어 질문이고 문서 수가 많지 않다면 굳이 인덱스를 두 개로 쪼갤 필요는 없습니다.
벡터 검색이 엉뚱한 문서를 물어오는 이유는 무엇일까요?
벡터 검색은 질의와 문서를 같은 임베딩 공간의 좌표로 바꾸고, 그 좌표가 가까운 문서를 코사인 유사도나 내적으로 찾아옵니다. 이 방식은 “환불 절차가 어떻게 되나요” 같은 자연어 질문과 “결제 취소 후 처리 흐름을 설명한 문서”처럼 표현이 달라도 의미가 비슷하면 잘 찾아낸다는 장점이 있습니다.
문제는 임베딩 모델이 짧고 고유한 토큰을 뭉뚱그려 버린다는 점입니다. “모델 K-450 에러코드 E-12” 같은 질의를 넣으면, 임베딩 벡터는 이 문자열을 “제품·에러·문제 해결”이라는 대략적인 의미로 압축해버립니다. 그 결과 E-12가 아니라 E-21이나 아예 다른 모델의 에러 문서를 물어오는 경우가 생깁니다. 코드·일련번호·인명처럼 정확히 일치해야 의미가 있는 텍스트에서 벡터 검색의 약점이 드러나는 셈입니다.
BM25는 벡터가 놓치는 키워드를 정확히 잡아냅니다
BM25는 Okapi BM25 랭킹 함수라는 이름으로 위키백과에도 정리되어 있는 고전적인 검색 알고리즘입니다. TF-IDF 계열답게 특정 단어가 문서에 얼마나 자주 등장하는지, 그 단어가 전체 문서 집합에서 얼마나 희귀한지를 계산해 점수를 매깁니다. 임베딩처럼 의미를 뭉개지 않고 토큰 자체의 일치 여부를 그대로 반영한다는 점이 핵심입니다.
그래서 “E-12” 같은 문자열은 BM25 인덱스(대개 역색인, inverted index)에서 정확히 매칭되는 문서를 상위로 끌어올립니다. 반대로 BM25는 동의어나 문맥을 이해하지 못하기 때문에 “결제를 취소하고 싶어요”라는 질의로 “환불 절차 안내” 문서를 찾지 못할 수 있습니다. 두 방식이 서로 다른 실패 지점을 갖고 있다는 사실이, 이 둘을 섞는 이유의 전부입니다.

하이브리드 검색으로 넘어가야 할 신호
아래 표는 두 검색 방식의 특성을 정리한 것입니다. 도입 전에 자신의 서비스 로그가 어느 쪽 실패 패턴에 가까운지 먼저 확인해 보시는 걸 권해드립니다.
| 구분 | 벡터(임베딩) 검색 | BM25(키워드) 검색 |
|---|---|---|
| 강점 | 동의어·패러프레이즈 질의, 자연어 질문 | 코드·번호·고유명사·정확 일치 |
| 약점 | 짧은 고유 토큰, 숫자·코드 구분 | 표현이 다른 동의어, 문맥 이해 |
| 인덱스 구조 | 벡터 DB(HNSW 등 ANN 인덱스) | 역색인(inverted index) |
| 대표 실패 사례 | 에러코드가 섞인 질문 오검색 | “환불”과 “결제 취소” 미매칭 |
이 표에서 벡터 검색 약점 칸에 해당하는 로그가 반복된다면, 즉 사용자가 제품명·모델명·오류 코드로 질문했는데 문서를 엉뚱하게 물어오는 사례가 쌓인다면 하이브리드로 넘어갈 신호로 봐도 됩니다. 반대로 질의가 전부 서술형 질문이고 코퍼스가 수천 건 이하로 작다면, 인덱스를 두 개 운영하는 비용 대비 얻는 이득이 크지 않을 수 있습니다.
OpenSearch와 LangChain으로 붙여보면 이렇게 동작합니다
실제 구현은 두 갈래로 나뉩니다. 검색엔진 레벨에서 합칠지, 애플리케이션 레벨에서 합칠지입니다. OpenSearch는 벡터 kNN 쿼리와 BM25 기반의 일반 쿼리를 하나의 hybrid 쿼리로 묶고, normalization-processor로 두 점수의 스케일을 맞춘 뒤 합산하는 방식을 공식적으로 지원합니다. 자세한 파이프라인 설정은 OpenSearch 공식 하이브리드 검색 문서에 정리되어 있습니다.
애플리케이션 레벨에서 빠르게 실험해보고 싶다면 LangChain의 EnsembleRetriever가 편합니다. BM25 리트리버와 벡터 리트리버 결과를 Reciprocal Rank Fusion(RRF) 방식으로 재정렬해 하나의 결과 리스트로 합쳐줍니다.
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
# docs: Document 객체 리스트 (사내 매뉴얼, 에러코드 안내 등)
bm25_retriever = BM25Retriever.from_documents(docs)
bm25_retriever.k = 5
vectorstore = FAISS.from_documents(docs, OpenAIEmbeddings())
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.5, 0.5],
)
results = ensemble_retriever.invoke("모델 K-450 에러코드 E-12 해결법")
for r in results:
print(r.metadata.get("source"), r.page_content[:40])
# 예시 출력
# manual_k450.pdf E-12는 냉각 팬 센서 이상입니다. 우선...
# manual_k450.pdf E-12 발생 시 전원을 껐다가 30초 후...
# faq_general.pdf 제품 에러코드 전체 목록은 부록 참고...

weights 값은 데이터 특성에 따라 조정이 필요합니다. 질의에 코드·번호가 많다면 BM25 쪽 가중치를 0.6~0.7까지 올려보는 식으로 시작하는 편이 안전합니다. 다만 이 방식은 리트리버 두 개를 각각 호출하기 때문에 지연 시간이 늘어나고, BM25 인덱스와 벡터 인덱스를 문서가 추가·수정될 때마다 함께 갱신해야 하는 운영 부담이 따릅니다.
RRF 가중치를 조정했는데 순위가 흔들리는 이유는 무엇인가요?
RRF는 두 리트리버가 매긴 순위(rank)만 사용하고 원래 점수(score)는 버립니다. 문서 순위가 k번째일 때 1/(k+rank) 형태로 점수를 계산해 합산하는 구조라서, weights를 조금만 바꿔도 상위 문서 구성이 크게 흔들릴 수 있습니다. 특히 BM25 쪽 후보군이 적은 코퍼스(문서 수가 수백 건 이하)에서는 순위 변동 폭이 더 커집니다.
이럴 때는 weights를 극단적으로 조정하기보다, 각 리트리버의 k(반환 개수)를 먼저 10~20 정도로 넉넉히 늘려 후보 풀을 키운 다음 가중치를 미세 조정하는 순서를 권해드립니다. 후보군이 좁으면 어떤 가중치를 넣어도 결과가 요동치는 것처럼 보이기 때문입니다.
표로 정리한 도입 판단 기준, 지금 점검해 보세요
지금까지 살펴본 내용을 바로 적용할 수 있도록 체크리스트로 정리했습니다.
| 점검 항목 | 하이브리드 도입 권장 | 벡터 검색 단독 유지 |
|---|---|---|
| 질의 유형 | 코드·모델명·번호 포함 질문이 잦음 | 대부분 서술형 자연어 질문 |
| 문서 규모 | 수만 건 이상, 도메인 용어가 많음 | 수백~수천 건, 어휘가 균질 |
| 재현율 이슈 | 정답 문서가 상위에서 자주 빠짐 | 상위 재현율이 안정적 |
| 운영 여력 | 인덱스 두 개 관리·튜닝 가능 | 인프라·튜닝 리소스 제한적 |
핵심은 벡터 검색이 엉뚱한 문서를 물어오는 패턴이 코드·번호·고유명사 질의에 집중되는지를 로그로 먼저 확인하는 일입니다. 그 패턴이 확인되면 OpenSearch의 hybrid 쿼리든 LangChain의 EnsembleRetriever든, 위에서 다룬 방식 중 하나로 BM25를 섞어보시고 RRF 가중치는 후보 풀을 넉넉히 키운 뒤 조정하시길 권해드립니다.
CUDA device-side assert triggered — 스택트레이스가 엉뚱한 이유와 진짜 위치 찾는 법
