Published on

RAG 청킹 망했을 때 - BM25+벡터 하이브리드 튜닝

Authors

RAG에서 제일 흔한 장애는 모델이 아니라 retrieval입니다. 특히 청킹(chunking)이 망하면, 임베딩이 좋아도 검색이 엉뚱한 조각을 가져오고 답변은 그럴듯하게 틀립니다. 이 글은 “청킹이 이미 망한 상태”를 전제로, BM25(lexical) + 벡터(semantic) 하이브리드로 검색을 안정화하고, 점진적으로 청킹/인덱스를 복구하는 튜닝 루트를 다룹니다.

실무에서 자주 보는 패턴은 이렇습니다.

  • 문서가 PDF/HTML에서 추출되며 문단 경계가 깨짐
  • 표/코드/목록이 한 덩어리로 뭉개짐
  • 제목/섹션 메타데이터가 누락되어 “무슨 문서의 어떤 부분인지”가 사라짐
  • chunk 크기를 키워서 해결하려다, 오히려 노이즈가 커짐

이럴 때 **BM25는 ‘단어가 실제로 들어있는지’**를 강하게 보장해 주고, 벡터는 동의어/표현 차이를 메워줍니다. 둘을 섞으면 “청킹이 좀 엉망이어도” 검색이 버티기 시작합니다.

청킹이 망했다는 신호: 검색 로그로 확인하기

청킹 실패는 보통 다음 지표에서 드러납니다.

  1. Top-K 문서에 쿼리 키워드가 거의 없다
  2. 답변이 특정 문서로 편향되거나, 항상 같은 chunk만 나온다
  3. chunk 길이 분포가 극단적이다(너무 짧거나 너무 김)
  4. 같은 문서의 인접 chunk가 아니라, 전혀 상관없는 chunk가 섞인다

가장 먼저 할 일은 “검색 결과에 쿼리 토큰이 실제로 포함되는지”를 로깅하는 것입니다.

// retrieval_debug.ts
export function containsAnyToken(text: string, tokens: string[]) {
  const lower = text.toLowerCase();
  return tokens.some(t => lower.includes(t.toLowerCase()));
}

export function debugHit(query: string, chunkText: string) {
  const tokens = query.split(/\s+/).filter(Boolean);
  return {
    query,
    tokenHit: containsAnyToken(chunkText, tokens),
    preview: chunkText.slice(0, 200)
  };
}

벡터 검색은 유사도를 기준으로 뽑기 때문에, 청킹이 망가지면 “유사해 보이는 잡음”이 상위에 뜰 수 있습니다. 이때 BM25를 섞으면 최소한 lexical anchor가 생깁니다.

하이브리드의 핵심: 점수 스케일을 먼저 맞춰라

BM25 점수와 코사인 유사도는 스케일이 다릅니다. 하이브리드가 실패하는 가장 흔한 이유는 가중치 튜닝이 아니라 정규화 미흡입니다.

권장하는 안전한 접근은 다음 중 하나입니다.

  • rank fusion 방식(순위 기반)인 RRF(Reciprocal Rank Fusion)
  • 점수 기반이면 min-max 또는 z-score 정규화 후 가중합

실무에서는 RRF가 “점수 분포가 이상해도” 잘 버팁니다.

RRF(Reciprocal Rank Fusion)로 빠르게 안정화

RRF는 각 검색기의 순위를 합쳐서 최종 순위를 만듭니다.

// rrf.ts
export type Hit = { id: string; score: number };

export function rrfFuse(
  lists: Hit[][],
  k = 60
): { id: string; score: number }[] {
  const acc = new Map<string, number>();

  for (const hits of lists) {
    hits.forEach((h, idx) => {
      const r = idx + 1;
      const add = 1 / (k + r);
      acc.set(h.id, (acc.get(h.id) ?? 0) + add);
    });
  }

  return [...acc.entries()]
    .map(([id, score]) => ({ id, score }))
    .sort((a, b) => b.score - a.score);
}
  • BM25 Top-n1 리스트
  • 벡터 Top-n2 리스트

두 개를 RRF로 섞고, 그 결과 상위 k개를 컨텍스트로 보냅니다.

장점:

  • 스케일 정규화 불필요
  • BM25가 “키워드 포함”을 보장
  • 벡터가 “표현 차이”를 보완

단점:

  • 점수 해석이 어렵고, 학습 기반 튜닝에는 부적합할 수 있음

점수 기반 가중합을 쓰려면: 정규화가 먼저

점수 기반을 쓰면 튜닝 자유도가 높습니다. 대신 아래 순서를 지키세요.

  1. BM25 점수 min-max 정규화
  2. 벡터 유사도 min-max 정규화
  3. final = w * bm25 + (1 - w) * vector
// normalize.ts
export function minMaxNormalize(scores: number[]) {
  const min = Math.min(...scores);
  const max = Math.max(...scores);
  const denom = max - min || 1;
  return scores.map(s => (s - min) / denom);
}

export function weightedHybrid(
  bm25: { id: string; score: number }[],
  vec: { id: string; score: number }[],
  w = 0.5
) {
  const bmMap = new Map(bm25.map(x => [x.id, x.score]));
  const vMap = new Map(vec.map(x => [x.id, x.score]));

  const ids = new Set<string>([...bmMap.keys(), ...vMap.keys()]);
  const bmScores = [...ids].map(id => bmMap.get(id) ?? 0);
  const vScores = [...ids].map(id => vMap.get(id) ?? 0);

  const bmNorm = minMaxNormalize(bmScores);
  const vNorm = minMaxNormalize(vScores);

  const idList = [...ids];
  return idList
    .map((id, i) => ({
      id,
      score: w * bmNorm[i] + (1 - w) * vNorm[i]
    }))
    .sort((a, b) => b.score - a.score);
}

운영 팁:

  • 청킹이 망한 초기에는 w0.6~0.8로 BM25 쪽에 더 줘서 안정화
  • 쿼리가 짧고 키워드성이 강할수록 BM25 가중치를 올림
  • 자연어 질문(길고 서술형)일수록 벡터 비중을 늘림

“청킹이 망한 상태”에서의 튜닝 순서

청킹을 당장 고치기 어렵다면, 검색 파이프라인을 아래 순서로 손보는 게 비용 대비 효과가 큽니다.

1) BM25 인덱스에 필드/분석기부터 제대로

BM25는 토큰화 품질에 민감합니다.

  • 한글이면 형태소 분석기/토크나이저를 신중히 선택
  • 코드/에러로그/식별자(snake_case, camelCase)는 별도 토큰화 고려
  • 제목/헤더/경로/태그 메타데이터를 별도 필드로 인덱싱

예: Elasticsearch에서 제목에 부스트를 주는 쿼리(개념 예시)

{
  "query": {
    "multi_match": {
      "query": "revalidateTag 캐시 무효화",
      "fields": [
        "title^3",
        "headings^2",
        "body"
      ],
      "type": "best_fields"
    }
  }
}

청킹이 깨져도 title/headings 같은 메타가 살아있으면 BM25가 훨씬 잘 버팁니다.

2) 벡터 검색은 “문서 단위” 백업 채널을 둔다

chunk가 엉망이면 chunk 임베딩 자체가 노이즈일 수 있습니다. 이때는 임시로라도 문서 단위 임베딩(요약 또는 앞부분+목차 기반)을 만들어 문서 후보군을 먼저 좁히고, 그 안에서 BM25로 chunk를 고르는 방식이 효과적입니다.

  • 1단계: 문서 벡터 Top-D
  • 2단계: 해당 문서의 chunk들만 BM25/벡터로 재검색

이 구조는 “chunk 품질이 낮아도” 문서 레벨에서 큰 방향을 맞춥니다.

3) 재랭킹을 붙여서 Top-K의 품질을 보정

하이브리드만으로도 좋아지지만, 청킹이 망한 경우에는 Top-20 안에 노이즈가 섞이기 쉽습니다. 여기서 cross-encoder 재랭킹(또는 LLM 기반 스코어링)을 붙이면 체감 품질이 크게 올라갑니다.

  • 후보 생성: BM25+벡터 하이브리드로 Top-50
  • 재랭킹: cross-encoder로 Top-10
  • 컨텍스트 구성: Top-6~12

재랭킹은 비용이 있으니, 캐시와 함께 설계하는 게 좋습니다. 캐시가 꼬이면 품질만큼이나 운영이 망가지므로, Next.js 기반이라면 캐시 무효화 전략도 같이 점검해 두는 편이 안전합니다: Next.js 14 RSC 캐시 꼬임, revalidateTag로 푸는 법

“청킹 망함”을 하이브리드로 가렸을 때의 부작용

하이브리드는 응급처치로 강력하지만, 청킹 문제를 영원히 숨기진 못합니다. 대표 부작용은 다음입니다.

  • BM25가 강해지면 “키워드만 맞는” 문서가 상위로 올라와 맥락이 약해질 수 있음
  • 벡터가 강해지면 “의미만 비슷한” 잡음이 섞임
  • 둘 다 쓰면 Top-K가 다양해지지만, 컨텍스트 길이 제한 때문에 오히려 답변 근거가 분산될 수 있음

따라서 하이브리드 튜닝과 동시에, 컨텍스트 구성 정책도 같이 손봐야 합니다.

컨텍스트 구성: 다양성 제약을 걸어라

Top-K에서 같은 문서/같은 섹션 chunk가 과도하게 뽑히면 정보가 편향됩니다. 반대로 너무 다양한 문서에서 조금씩 가져오면 근거가 얕아집니다.

실전 규칙 예시:

  • 문서당 최대 m개 chunk
  • 섹션(heading path)당 최대 n개 chunk
  • 인접 chunk 병합(같은 문서에서 연속이면 합치기)
// context_pack.ts
type Chunk = {
  id: string;
  docId: string;
  sectionId?: string;
  text: string;
};

export function packContext(
  ranked: Chunk[],
  maxChunks = 10,
  maxPerDoc = 3
) {
  const perDoc = new Map<string, number>();
  const out: Chunk[] = [];

  for (const c of ranked) {
    const used = perDoc.get(c.docId) ?? 0;
    if (used >= maxPerDoc) continue;
    out.push(c);
    perDoc.set(c.docId, used + 1);
    if (out.length >= maxChunks) break;
  }

  return out;
}

이걸로 “한 문서만 계속 뜨는 현상”과 “근거가 산발적으로 흩어지는 현상”을 둘 다 줄일 수 있습니다.

가중치 튜닝을 감으로 하지 말고: 오프라인 평가 루프

하이브리드에서 w(BM25 비중), Top-k, 후보 수, 재랭킹 여부는 감으로 맞추면 끝이 없습니다. 최소한의 오프라인 평가 루프를 만드세요.

  • 쿼리 50~200개 샘플
  • 정답 문서(또는 정답 chunk) 라벨링
  • 지표: Recall@k, MRR, nDCG

간단한 Recall@k 계산 예시:

# eval_recall.py
def recall_at_k(results, gold, k=10):
    # results: dict[qid] -> list[doc_id]
    # gold: dict[qid] -> set[doc_id]
    hit = 0
    total = 0
    for qid, gold_ids in gold.items():
        total += 1
        topk = results.get(qid, [])[:k]
        if any(doc_id in gold_ids for doc_id in topk):
            hit += 1
    return hit / max(total, 1)

여기서 중요한 건 “하이브리드가 좋아졌는지”를 답변 품질이 아니라 retrieval 지표로 먼저 확인하는 것입니다. 답변 품질은 LLM 변동성이 섞여 원인 파악이 어려워집니다.

청킹을 결국 고쳐야 한다: 최소 복구 체크리스트

하이브리드로 응급처치 후, 아래를 순서대로 복구하면 장기적으로 비용이 내려갑니다.

  1. 문서 구조 복원: 제목/헤더/목차/섹션 경계 추출
  2. chunk 단위 메타데이터: docId, section path, offset 저장
  3. chunk 길이 정책: 토큰 기준 300~800 사이에서 시작, 섹션 경계를 우선
  4. 오버랩 전략: 문단 경계가 불안하면 오버랩을 10%~20%
  5. 표/코드 분리: 표는 행 단위, 코드는 블록 단위로 별도 chunk

특히 “표/코드가 본문과 섞여 chunk가 오염되는 문제”는 벡터 임베딩 품질을 크게 떨어뜨립니다. 이건 하이브리드로도 한계가 있습니다.

운영에서 자주 터지는 함정: 네트워크/타임아웃이 검색 품질로 보인다

하이브리드 구성은 보통 외부 컴포넌트(검색엔진, 벡터DB, 재랭커)를 늘립니다. 이때 ECONNRESET, ETIMEDOUT 같은 네트워크 오류가 나면, 시스템은 조용히 fallback을 타거나 Top-K가 비어버려 “갑자기 검색 품질이 떨어진 것처럼” 보입니다.

Node.js에서 fetch 기반으로 붙였다면 아래 글처럼 타임아웃/재시도/커넥션 이슈를 먼저 잡아두는 게 좋습니다: Node.js fetch ECONNRESET·ETIMEDOUT 해결법

추천하는 실전 설정 프리셋

청킹이 망한 상황에서 빠르게 효과를 보려면 아래 조합이 무난합니다.

  • 후보 생성
    • BM25 Top-100
    • 벡터 Top-100
    • 결합: RRF k=60
  • 후보 정제
    • 문서당 최대 3 chunk
    • 섹션 메타가 있으면 섹션당 최대 2
  • 재랭킹
    • 가능하면 cross-encoder로 Top-50 재랭킹 후 Top-10
  • 컨텍스트
    • Top-8 전후, 인접 chunk는 병합

이 프리셋은 “BM25로 키워드 안전장치”를 확보하고, 벡터로 질문 표현의 다양성을 흡수하며, 재랭킹으로 최종 품질을 끌어올리는 구조입니다.

마무리: 하이브리드는 응급처치이자 장기 전략

청킹이 망한 RAG는 벡터만으로는 복구가 어렵습니다. BM25+벡터 하이브리드는 가장 현실적인 응급처치이고, 제대로 튜닝하면 장기적으로도 강력한 기본기입니다.

정리하면 실행 순서는 이렇게 가져가세요.

  1. 검색 로그로 “키워드 부재”를 확인
  2. RRF로 빠르게 하이브리드 도입(정규화 이슈 회피)
  3. 문서 단위 후보군 축소 같은 백업 채널 추가
  4. 재랭킹과 컨텍스트 구성 규칙으로 Top-K 노이즈 제거
  5. 오프라인 평가 루프로 가중치/Top-K를 수치로 튜닝
  6. 마지막에 청킹/메타데이터를 근본 복구

이 루트대로 하면 “청킹이 이미 망한 상태”에서도 검색을 다시 사용 가능한 수준으로 끌어올릴 수 있습니다.