Skip to content

RAG 하이브리드 검색 BM25와 벡터

RAG 하이브리드 검색 BM25와 벡터
목차

자비스에게 “3월 15일 미팅 뭐였지?”라고 물으면 4월 미팅을 가져왔고, 날짜와 장소명에서 집중적으로 틀렸습니다. 벡터 검색은 의미가 비슷한 것을 찾는데 날짜 숫자는 의미로서 무게가 작아서, 맥락이 비슷한 다른 날 기록에 밀렸기 때문입니다. 분석기가 만든 토큰의 빈도와 희소성 등을 점수화하는 BM25를 붙여 벡터와 함께 돌리고 결과를 합쳤더니(하이브리드) 날짜와 장소명 검색이 거의 다 맞게 됐습니다. 응답은 50ms쯤 느려졌지만 체감은 없었습니다. 한국어 붙임말 처리가 안 되고, 전문 검색(FTS) 인덱스가 하루 한 번 갱신돼서 오늘 나눈 대화는 오늘 못 잡는 한계가 남아 있습니다.

자비스한테 3월을 물었더니 4월을 가져왔습니다

3편에서 RAG 시스템을 만들었습니다. 대화 기록이나 메모를 저장해뒀다가 나중에 “그때 뭐 얘기했더라?” 하고 물으면 찾아주는 구조입니다. 만들고 나서 한동안 잘 쓰고 있다고 생각했습니다.

그런데 날짜가 들어간 질문을 할 때마다 묘하게 빗나갔습니다.

나: 3월 15일 팀 미팅에서 뭐 얘기했더라?

자비스: 팀 미팅 기록을 찾았습니다. 4월 7일 미팅에서 다음 내용을 논의하셨습니다…

4월요? 내가 날짜를 잘못 기억했나 싶어서 직접 뒤져봤는데, 3월 15일 기록은 분명히 거기 있었습니다. 자비스가 그걸 두고 4월을 골라온 거였습니다.

한 번이면 그러려니 했을 텐데, 비슷한 게 계속 나왔습니다. 특정 장소명을 넣어서 물으면 다른 곳 기록을 가져오고, 에러 코드를 넣으면 비슷한 주제의 다른 글을 줬습니다. 공통점이 보였습니다. “의미는 비슷한데 핵심 단어가 다른” 질문에서만 틀린다는 것이었습니다. 날짜, 장소, 고유명사가 그렇습니다.

원인은 안 보고 일단 손부터 댔습니다

부끄럽지만 처음엔 원인을 파악할 생각도 없이 그냥 이것저것 건드렸습니다. 백엔드 9년을 하고도 이럽니다.

제일 먼저 청크 크기를 의심했습니다. 청크가 너무 크면 그 안에서 날짜가 묻히는 거 아닐까 싶어서 512 토큰을 256으로 줄였습니다. 결과는, 날짜 검색은 그대로고 오히려 맥락이 짧아져서 답변 품질만 떨어졌습니다. 바로 되돌렸습니다.

그다음엔 날짜를 따로 빼서 메타데이터 필터로 처리했습니다. 질문에서 “3월 15일” 같은 걸 파싱해서 필터로 넘기는 방식인데, 명시적인 날짜는 잘 됐습니다. 문제는 “저번 주”, “오늘 아침” 같은 상대적인 표현은 파싱이 안 된다는 거였고, 장소명 틀리는 건 여전했습니다. 딱 절반짜리였습니다.

마지막으로 시스템 프롬프트에 “날짜가 일치하는 기록을 우선해줘”라고 적어 넣었습니다. 이게 제일 안 좋았습니다. 응답은 느려지고, 가끔 “관련 기록을 찾지 못했습니다”라고 포기해버렸습니다. 생각해보니 당연했습니다. 검색이 애초에 엉뚱한 후보들을 넘기는데, 거기서 LLM더러 날짜 맞는 걸 골라내라고 시킨 거니까요. 재료가 틀렸는데 요리사를 탓한 것과 같았습니다.

세 개 다 말아먹고 나서야 “아, 검색 자체가 문제구나” 싶었습니다.

벡터 검색은 원래 날짜에 약합니다

왜 이러는지 Claude한테 물어봤습니다. 설명을 듣고 나니 납득이 됐습니다.

벡터 검색은 텍스트의 의미가 비슷한 걸 찾습니다. “3월 15일 팀 미팅”이랑 “4월 7일 팀 미팅”은 날짜만 다를 뿐, 둘 다 “팀 미팅에서 나눈 얘기”라는 점에서 의미가 거의 똑같습니다. 그래서 벡터 공간에서 둘이 바짝 붙어 있습니다.

쿼리: "3월 15일 팀 미팅 내용"

코사인 유사도 검색 결과:
  4월 7일 팀 미팅   ← 유사도 높음   ← 1위로 올라옴
  3월 15일 팀 미팅  ← 유사도 조금 낮음 ← 2위
  5월 2일 팀 미팅   ← 유사도 낮음    ← 3위

내가 찾던 게 2위였습니다. “3월 15일”이라는 숫자 자체는 의미적으로 무게가 별로 없어서, 맥락이 더 비슷한 다른 날 기록이 그 위로 올라온 거예요. 이건 버그가 아니라 벡터 검색이 설계상 그렇게 동작하는 겁니다. 의미로 줄을 세우는 방식이라, 의미가 거의 안 실린 날짜 숫자는 힘을 못 씁니다.

BM25라는 걸 처음 알았습니다

그럼 어떻게 하냐고 물었더니 BM25를 써보라고 하더군요. 처음 들어봤습니다.

1994년에 나온 알고리즘이라길래 “이렇게 오래된 걸 쓴다고?” 했는데, 설명이 의외로 단순했습니다. 단어가 그 문서에 실제로 박혀 있으면 점수를 주고, 없으면 0점. 그게 전부예요.

BM25는 문서에 나타난 검색 토큰의 빈도, 전체 문서에서의 희소성, 문서 길이 등을 반영해 순위를 매깁니다. 정확한 구문 일치를 보장하는 필터는 아닙니다. 예를 들어 분석기가 3월, 15일, 미팅으로 분리하고 OR 형태로 검색한다면 “4월 15일 미팅”도 일부 토큰이 겹쳐 점수를 받을 수 있습니다. 실제 동작은 사용한 분석기와 검색 엔진의 쿼리 설정에 달려 있습니다. 날짜가 반드시 일치해야 하는 질문은 날짜를 파싱해 메타데이터 범위로 필터링한 뒤 후보를 정렬해야 합니다.

알고 보니 검색 엔진 쪽에선 기본 중의 기본이었습니다. Elasticsearch도 속을 들여다보면 BM25가 깔려 있었습니다. 검색을 제대로 안 파봤으니 제가 몰랐던 겁니다.

그럼 BM25로 갈아타면 끝? 아니었습니다

BM25가 좋다고 벡터를 버리면 이번엔 반대쪽이 무너집니다. BM25의 약점이 딱 벡터의 강점입니다.

이를테면 “저번에 갔던 그 맛집, 뭐 먹었더라?” 같은 질문입니다. 기록에는 “홍대 이탈리안 식당 방문, 파스타 주문”이라고 저장돼 있는데, 질문에 쓴 “저번에”, “그 맛집”, “뭐 먹었더라”는 그 문서에 한 글자도 없습니다. BM25한테는 0점이라 아예 못 찾습니다. 반면 벡터는 “맛집 = 식당”, “뭐 먹었어 = 파스타 주문”을 의미로 연결해서 잘 찾습니다.

이런 질문벡터 검색BM25
“저번에 간 이탈리안 식당 어땠어”잘 찾음못 찾음
“3월 15일 미팅 내용”자주 틀림잘 찾음
“커피빈에서 한 미팅”자주 틀림잘 찾음
“ERR_MODULE_NOT_FOUND 해결책”가끔 틀림잘 찾음

한쪽이 약한 데서 다른 쪽이 강합니다. 그래서 답은 “둘 다 쓴다”였습니다.

붙이는 건 생각보다 간단했습니다

다행히 LanceDB가 BM25 기반 전문 검색(FTS, Full-Text Search)을 기본으로 지원합니다. 별도 검색 엔진을 띄울 필요 없이 쓰던 벡터 DB에 그대로 인덱스만 하나 더 얹으면 됐습니다. 그래서 골랐습니다.

await table.createFtsIndex(['content', 'metadata'], {
  withPosition: true
});

처음 인덱스 만들 때 12만 청크 기준으로 35분쯤 걸렸습니다. 멈춘 줄 알고 한참 들여다봤는데 정상이더라고요. 다행히 한 번만 만들면 그다음부터는 빨랐습니다.

검색은 벡터랑 BM25를 따로 돌리고 결과를 합치는 식입니다. 순서대로 돌리면 두 배로 기다려야 하니까 동시에 던졌습니다.

async function hybridSearch(queryText, queryEmbedding, limit = 10) {
  // 순서대로 하면 느리니까 두 검색을 동시에
  const [vectorResults, ftsResults] = await Promise.all([
    table.search(queryEmbedding).distanceType('cosine').limit(limit * 2).toArray(),
    table.search(queryText, 'fts').limit(limit * 2).toArray()
  ]);

  return mergeResults(vectorResults, ftsResults, limit);
}

문제는 합치는 부분이었습니다. 그냥 두 결과를 이어 붙이면 중복이 생기거나 한쪽이 다른 쪽을 깔아뭉갭니다. 그래서 “두 검색에서 모두 위쪽에 오른 문서를 우대하자”는 방식으로 합쳤습니다. 이걸 RRF(Reciprocal Rank Fusion)라고 부른다는 것도 Claude한테 합치는 법을 물어보다가 알았습니다.

function mergeResults(vectorResults, ftsResults, limit) {
  const scores = new Map();
  const k = 60; // 원래 논문 기본값. 바꿔봐도 체감 차이가 없어서 그냥 둠

  vectorResults.forEach((doc, rank) => {
    scores.set(doc.id, (scores.get(doc.id) || 0) + 1 / (k + rank + 1));
  });

  ftsResults.forEach((doc, rank) => {
    scores.set(doc.id, (scores.get(doc.id) || 0) + 1 / (k + rank + 1));
  });

  return [...scores.entries()]
    .sort(([, a], [, b]) => b - a)
    .slice(0, limit)
    .map(([id]) => getDocById(id));
}

수식이 좀 그럴싸해 보이는데, 뜻은 단순합니다. 벡터에서 1위면서 BM25에서도 3위쯤 한 문서는, 벡터에서만 1위인 문서보다 최종 점수가 높게 나옵니다. 양쪽이 다 “이거 관련 있어”라고 손든 걸 위로 올리는 겁니다.

적용하고 나서

막혔던 케이스들을 다시 돌려봤습니다. 사실 정량 벤치마크를 따로 돌리진 않았고, 그동안 틀렸던 질문들을 직접 던져보는 수준이었습니다. 그래도 차이는 분명했습니다.

질문 유형전후
의미 기반 (“저번에 갔던 맛집”)잘 됨그대로 잘 됨
날짜 기반 (“3월 15일 미팅”)자주 틀림거의 다 맞음
장소명 (“커피빈에서 한 미팅”)자주 틀림거의 다 맞음
에러 코드 (“ERR_MODULE_NOT_FOUND”)가끔 틀림잘 됨

응답은 한 50ms쯤 느려졌습니다. 검색을 두 개 돌리니 당연한데, 1초도 안 되는 차이라 쓰면서는 모릅니다.

처음에 속 터지게 했던 그 질문도 이제 제대로 답합니다.

나: 3월 15일 팀 미팅에서 뭐 얘기했더라?

자비스: 3월 15일 팀 미팅 기록입니다.

  • 안건: 다음 스프린트에서 뭘 먼저 할지
  • 결정: 신규 기능은 미루고 안정화부터
  • 메모: 남은 이슈는 따로 정리해서 공유하기로

4월 얘기가 안 나옵니다. 이게 이렇게 반가울 일인가 싶었습니다.

아직 못 고친 것들

솔직히 다 끝난 건 아닙니다. 두 개가 남아 있습니다.

하나는 한국어 붙임말입니다. 이때 사용한 분석기에서 공백 중심으로 토큰화했다면, “이벤트드리븐아키텍처”처럼 붙여 쓰면 통째로 한 단어로 봅니다. 그래서 “이벤트”로 검색하면 못 잡습니다. 형태소 분석기(단어를 의미 단위로 쪼개주는 도구)를 붙이면 된다는데 아직 못 했습니다. 다행히 기술 용어는 대부분 영어라서 생각보다 자주 터지진 않았습니다.

다른 하나는 시차 문제입니다. FTS 인덱스를 하루 한 번 다시 만드는데, 그사이에 쌓인 대화는 벡터에는 잡혀도 BM25에는 안 잡힙니다. 그래서 방금 나눈 대화를 날짜로 검색하면 아직 틀릴 수 있습니다. 인덱스를 실시간으로 갱신하게 바꿔야 하는데, 다음 숙제로 남겨뒀습니다.

검색 품질을 검증하는 방법

다음은 재현 가능한 평가 설계이지 새로 측정한 결과는 아닙니다. 질문마다 정답 문서 ID, 기준 시각과 접근 권한을 고정하고 같은 문서 스냅샷에서 벡터·BM25·RRF의 top-k를 각각 비교합니다. 날짜 조건은 메타데이터 필터로 따로 적용합니다. “3월 15일”과 “4월 15일”처럼 일부 토큰만 겹치는 반례, 한국어 붙임말, 정답이 없는 질문도 넣습니다.

Recall@k는 정답 문서가 상위 k개에 들어오는 비율이고 MRR은 첫 정답 순위의 역수 평균입니다. 문서 갱신 직후와 다음 날을 비교하면 인덱스 지연도 볼 수 있습니다. 상대 날짜는 질문 시각과 시간대를 고정해야 재현됩니다. 높은 검색 점수만으로 사실 확인이나 권한 검증이 끝나는 것은 아닙니다.

마무리

벡터 검색만 붙이면 RAG의 검색 문제는 다 풀릴 줄 알았습니다. 정작 “3월 15일” 같은 제일 단순한 질문에서 막혔습니다.

돌이켜보면 청크 크기, 날짜 필터, 프롬프트 수정까지 전부 같은 실수였습니다. 검색이 엉뚱한 답을 넘기는 걸 LLM더러 수습하라고 떠민 것이고, 정작 검색 자체가 날짜와 고유명사를 못 다룬다는 문제는 보지 않고 뒷단만 만졌습니다. 임베딩이며 코사인 유사도며 그럴듯한 걸 잔뜩 붙여놓고서 “글자가 같은지 보면 되잖아”라는 가장 단순한 방법은 후보에도 올리지 않았습니다. 다음에 RAG를 만들 때는 벡터 검색을 띄우기 전에 BM25부터 깔고 시작하려 합니다.





참고 :

LanceDB Full-Text Search 문서
Reciprocal Rank Fusion 원논문 (Cormack et al., SIGIR 2009)
Okapi BM25 - Wikipedia




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github