Skip to content

RAG가 날짜를 물으면 자꾸 엉뚱한 날을 가져왔습니다 - 벡터 검색의 한계와 BM25 하이브리드

RAG가 날짜를 물으면 자꾸 엉뚱한 날을 가져왔습니다 - 벡터 검색의 한계와 BM25 하이브리드




TL;DR

  • 증상: 자비스한테 “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점. 그게 전부예요.

그러니까 “3월 15일”로 검색하면 진짜로 “3월 15일”이라는 글자가 적힌 문서만 점수를 받습니다. 4월 미팅 기록은 맥락이 아무리 비슷해도 그 글자가 없으니 그냥 0점이고요. 의미는 안 보고 글자만 봅니다. 단순한데, 제가 막혀 있던 걸 정확히 뚫어줬어요.

알고 보니 검색 엔진 쪽에선 기본 중의 기본이었습니다. 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월 얘기가 안 나옵니다. 이게 이렇게 반가울 일인가 싶었어요.


아직 못 고친 것들

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

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

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


시스템 점검 체크리스트

비슷하게 RAG 검색이 자꾸 빗나간다면 아래를 의심해보세요.

  • 벡터 검색만 쓰고 있는가? (날짜·고유명사에서 틀린다면 키워드 검색이 없는 게 원인일 수 있음)
  • 틀리는 질문에 공통 패턴이 있는가? (날짜·장소·에러코드 등 “단어 일치”가 중요한 질문인지)
  • 검색이 넘긴 후보를 LLM이 고치게 떠넘기고 있진 않은가? (재료가 틀리면 LLM도 못 고침)
  • 하이브리드라면 두 검색 결과를 어떻게 합치는가? (단순 이어붙이기는 한쪽이 압도함 → RRF 권장)
  • FTS 인덱스 갱신 주기와 실시간성 요구가 맞는가? (방금 데이터를 바로 검색해야 한다면 배치 인덱싱은 구멍)

마무리

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

돌이켜보면 청크 크기, 날짜 필터, 프롬프트 수정까지 전부 같은 실수였습니다. 검색이 엉뚱한 답을 넘기는 걸 LLM더러 어떻게든 수습하라고 떠민 거죠. 진짜 문제는 검색 자체가 날짜와 고유명사를 못 다룬다는 거였는데, 거기는 안 보고 자꾸 그 뒷단만 만졌습니다.

저를 제일 오래 잡은 건 BM25 자체가 아니라, “이건 신경망으로 푸는 문제다”라는 제 멋대로의 전제였습니다. 임베딩이며 코사인 유사도며 그럴듯한 걸 잔뜩 붙여놓고서, 정작 “글자가 같은지 보면 되잖아”라는 제일 멍청해 보이는 방법은 후보에도 안 올렸거든요. 혼자 만드는 프로젝트라 옆에서 “그거 그냥 키워드 검색이면 되는데?” 해줄 사람이 없었던 게 컸습니다. 다음에 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#Blog OpenSource Demo Site