- Published on
RAG 청킹 망했을 때 - BM25+벡터 하이브리드 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG에서 제일 흔한 장애는 모델이 아니라 retrieval입니다. 특히 청킹(chunking)이 망하면, 임베딩이 좋아도 검색이 엉뚱한 조각을 가져오고 답변은 그럴듯하게 틀립니다. 이 글은 “청킹이 이미 망한 상태”를 전제로, BM25(lexical) + 벡터(semantic) 하이브리드로 검색을 안정화하고, 점진적으로 청킹/인덱스를 복구하는 튜닝 루트를 다룹니다.
실무에서 자주 보는 패턴은 이렇습니다.
- 문서가 PDF/HTML에서 추출되며 문단 경계가 깨짐
- 표/코드/목록이 한 덩어리로 뭉개짐
- 제목/섹션 메타데이터가 누락되어 “무슨 문서의 어떤 부분인지”가 사라짐
- chunk 크기를 키워서 해결하려다, 오히려 노이즈가 커짐
이럴 때 **BM25는 ‘단어가 실제로 들어있는지’**를 강하게 보장해 주고, 벡터는 동의어/표현 차이를 메워줍니다. 둘을 섞으면 “청킹이 좀 엉망이어도” 검색이 버티기 시작합니다.
청킹이 망했다는 신호: 검색 로그로 확인하기
청킹 실패는 보통 다음 지표에서 드러납니다.
- Top-K 문서에 쿼리 키워드가 거의 없다
- 답변이 특정 문서로 편향되거나, 항상 같은 chunk만 나온다
- chunk 길이 분포가 극단적이다(너무 짧거나 너무 김)
- 같은 문서의 인접 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가 “키워드 포함”을 보장
- 벡터가 “표현 차이”를 보완
단점:
- 점수 해석이 어렵고, 학습 기반 튜닝에는 부적합할 수 있음
점수 기반 가중합을 쓰려면: 정규화가 먼저
점수 기반을 쓰면 튜닝 자유도가 높습니다. 대신 아래 순서를 지키세요.
- BM25 점수
min-max정규화 - 벡터 유사도
min-max정규화 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);
}
운영 팁:
- 청킹이 망한 초기에는
w를0.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 변동성이 섞여 원인 파악이 어려워집니다.
청킹을 결국 고쳐야 한다: 최소 복구 체크리스트
하이브리드로 응급처치 후, 아래를 순서대로 복구하면 장기적으로 비용이 내려갑니다.
- 문서 구조 복원: 제목/헤더/목차/섹션 경계 추출
- chunk 단위 메타데이터:
docId,section path,offset저장 - chunk 길이 정책: 토큰 기준
300~800사이에서 시작, 섹션 경계를 우선 - 오버랩 전략: 문단 경계가 불안하면 오버랩을
10%~20%로 - 표/코드 분리: 표는 행 단위, 코드는 블록 단위로 별도 chunk
특히 “표/코드가 본문과 섞여 chunk가 오염되는 문제”는 벡터 임베딩 품질을 크게 떨어뜨립니다. 이건 하이브리드로도 한계가 있습니다.
운영에서 자주 터지는 함정: 네트워크/타임아웃이 검색 품질로 보인다
하이브리드 구성은 보통 외부 컴포넌트(검색엔진, 벡터DB, 재랭커)를 늘립니다. 이때 ECONNRESET, ETIMEDOUT 같은 네트워크 오류가 나면, 시스템은 조용히 fallback을 타거나 Top-K가 비어버려 “갑자기 검색 품질이 떨어진 것처럼” 보입니다.
Node.js에서 fetch 기반으로 붙였다면 아래 글처럼 타임아웃/재시도/커넥션 이슈를 먼저 잡아두는 게 좋습니다: Node.js fetch ECONNRESET·ETIMEDOUT 해결법
추천하는 실전 설정 프리셋
청킹이 망한 상황에서 빠르게 효과를 보려면 아래 조합이 무난합니다.
- 후보 생성
- BM25 Top-
100 - 벡터 Top-
100 - 결합: RRF
k=60
- BM25 Top-
- 후보 정제
- 문서당 최대
3chunk - 섹션 메타가 있으면 섹션당 최대
2
- 문서당 최대
- 재랭킹
- 가능하면 cross-encoder로 Top-
50재랭킹 후 Top-10
- 가능하면 cross-encoder로 Top-
- 컨텍스트
- Top-
8전후, 인접 chunk는 병합
- Top-
이 프리셋은 “BM25로 키워드 안전장치”를 확보하고, 벡터로 질문 표현의 다양성을 흡수하며, 재랭킹으로 최종 품질을 끌어올리는 구조입니다.
마무리: 하이브리드는 응급처치이자 장기 전략
청킹이 망한 RAG는 벡터만으로는 복구가 어렵습니다. BM25+벡터 하이브리드는 가장 현실적인 응급처치이고, 제대로 튜닝하면 장기적으로도 강력한 기본기입니다.
정리하면 실행 순서는 이렇게 가져가세요.
- 검색 로그로 “키워드 부재”를 확인
- RRF로 빠르게 하이브리드 도입(정규화 이슈 회피)
- 문서 단위 후보군 축소 같은 백업 채널 추가
- 재랭킹과 컨텍스트 구성 규칙으로 Top-K 노이즈 제거
- 오프라인 평가 루프로 가중치/Top-K를 수치로 튜닝
- 마지막에 청킹/메타데이터를 근본 복구
이 루트대로 하면 “청킹이 이미 망한 상태”에서도 검색을 다시 사용 가능한 수준으로 끌어올릴 수 있습니다.