- Published on
RAG 리트리버 품질 2배 - ColBERT+FAISS 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG에서 “답변 품질”을 올리는 가장 빠른 길은 생성 모델을 바꾸는 게 아니라 리트리버의 상위 k 후보 품질을 올리는 것입니다. 특히 도메인 문서가 길고, 용어가 다양하며, 질문이 짧거나 애매할수록 단일 벡터 임베딩 기반(예: cosine 한 방) 검색은 문맥 손실이 커집니다.
ColBERT는 문서를 토큰 단위 임베딩으로 쪼개고, 질의 토큰과의 late interaction(MaxSim) 으로 점수를 계산해 이 문제를 크게 완화합니다. 다만 ColBERT는 “계산량이 많다”는 인식 때문에 운영에서 망설이는데, 실제로는 FAISS를 이용한 후보 생성 + ColBERT 재랭킹으로 비용을 제어하면서도 품질을 크게 올릴 수 있습니다.
이 글은 ColBERT+FAISS 조합으로 리트리버 품질을 2배 체감(리콜/정확도 개선, 근거 문서 적중률 상승)시키기 위해 현장에서 가장 효과가 컸던 튜닝 포인트를 정리합니다.
벡터DB 인덱스 파라미터 튜닝 관점은 pgvector RAG 인덱스 튜닝 - IVFFlat·HNSW 실전도 같이 보면 비교가 잘 됩니다.
왜 ColBERT가 RAG에 강한가
일반적인 bi-encoder 임베딩 검색은 문서를 하나의 벡터로 압축합니다. 문서가 길수록 “중요한 문장”이 희석되고, 질의가 특정 구절을 겨냥할 때 매칭이 약해집니다.
ColBERT는 다음 특징으로 RAG에 유리합니다.
- 토큰 단위 표현: 문서의 여러 부분이 질의 토큰과 각각 매칭될 수 있음
- late interaction(MaxSim): 질의 토큰마다 문서 토큰 중 가장 유사한 것을 골라 합산
- 정확한 구절 매칭: 키워드/약어/코드/에러 메시지 같은 “스파이크” 신호에 강함
결과적으로 RAG에서 중요한 근거 chunk 적중률이 올라가고, LLM이 헛소리할 확률이 내려갑니다.
전체 아키텍처: 후보 생성과 재랭킹을 분리하라
운영에서 가장 현실적인 구조는 아래입니다.
- 1차 후보 생성(cheap): FAISS로 빠르게 top
K후보를 뽑음 - 2차 재랭킹(expensive): ColBERT로 top
K를 다시 점수화해 topk를 최종 반환
여기서 핵심은 “ColBERT를 처음부터 전체 코퍼스에 풀스캔하지 않는다”는 점입니다. ColBERT는 재랭커로 쓰면 계산량이 급감합니다.
추천 기본값(출발점)
- 1차 후보
K:50~200 - 최종 반환
k:5~20 - chunk 길이:
200~400tokens(도메인에 따라) - overlap:
30~80tokens
K를 늘리면 리콜이 오르지만 재랭킹 비용이 증가합니다. “2배 품질”을 노릴 때는 보통 K를 먼저 올리고, 그 다음 비용을 줄이는 방향(인덱스/배치/양자화)으로 최적화합니다.
ColBERT 인덱싱 튜닝: chunking이 절반이다
ColBERT는 문서를 토큰 임베딩 집합으로 저장하기 때문에, chunk 설계가 성능과 품질을 동시에 좌우합니다.
1) chunk는 “의미 단위”로 자르되, 길이 상한을 둔다
- API 문서/가이드: 제목
H2/H3단위로 자른 뒤 토큰 상한 적용 - 로그/트러블슈팅: 에러 블록(스택트레이스) 단위 유지
- 코드: 함수 단위가 가장 안정적
너무 짧으면 문맥이 끊기고, 너무 길면 토큰 수가 많아져 인덱스가 비대해지고 MaxSim이 노이즈를 더 주워옵니다.
2) 메타데이터 필터링을 “검색 전”에 적용
ColBERT는 재랭킹이 비싸므로, 가능한 한 1차 후보 생성에서 테넌트/제품/버전/언어 같은 필터를 먼저 적용하세요.
- 다국어 문서라면 언어 필터는 필수
- 버전이 중요한 문서(예:
v1/v2API)는 버전 필터 없으면 환각이 늘어납니다
FAISS 후보 생성 튜닝: recall을 먼저 확보
ColBERT 재랭킹은 후보 안에 정답이 있어야 의미가 있습니다. 그래서 1차 후보 생성은 리콜 중심으로 튜닝합니다.
아래는 가장 흔한 구성입니다.
- 임베딩: bi-encoder(예:
bge,e5,gte)로 chunk를 단일 벡터화 - 인덱스:
HNSW또는IVF,PQ계열
HNSW에서 가장 많이 만지는 파라미터
M: 그래프 연결도(메모리와 recall에 영향)efSearch: 검색 시 탐색 폭(높을수록 recall 증가)
실전에서는 efSearch를 올리는 게 가장 쉽고 효과가 큽니다. 지연이 늘면 트래픽 구간별로 efSearch를 다르게 적용하거나, 캐시를 넣는 식으로 대응합니다.
MaxSim 점수 튜닝: “정규화”와 “길이 편향”을 잡아라
ColBERT의 late interaction은 강력하지만, 그대로 쓰면 다음 문제가 자주 생깁니다.
- 문서가 길수록 유리: 토큰이 많으면 질의 토큰과 우연히 비슷한 토큰이 나올 확률이 증가
- 불용어/일반어 토큰이 점수에 기여: 의미 없는 매칭이 누적
해결책은 크게 3가지입니다.
1) 질의 토큰 가중치(불용어 다운웨이트)
- 질의 토큰의
idf(희소성)나 도메인 사전을 이용해 가중치를 부여 - “the”, “is”, “방법”, “설정” 같은 일반어는 기여도를 줄임
2) 길이 정규화
문서 토큰 수가 많은 chunk가 유리해지는 편향을 완화하기 위해, 점수에 패널티를 줍니다.
예: 최종 점수 score = maxsim_sum / (len_doc_tokens ^ alpha) 형태로 alpha를 0.2 ~ 0.5에서 튜닝
3) top-n MaxSim만 합산
질의 토큰 전체를 더하지 말고, 상위 n개 토큰 매칭만 합산하면 노이즈가 줄어드는 경우가 많습니다.
- 짧은 질의:
n = all이 낫기도 함 - 긴 질의(문장형):
n = 8~16같은 캡이 도움이 됨
실전 파이프라인 예제(Python): FAISS 후보 + ColBERT 재랭킹
아래 코드는 “구조”를 보여주는 예시입니다. 실제 ColBERT 라이브러리/체크포인트에 따라 API는 달라질 수 있지만, 운영에서 중요한 포인트(후보 K, 배치, 메타필터, 재랭킹)를 그대로 담았습니다.
import faiss
import numpy as np
from typing import List, Dict, Any, Tuple
# 1) bi-encoder 임베딩(후보 생성용)
def embed_dense(texts: List[str]) -> np.ndarray:
# TODO: e5/bge/gte 등으로 교체
# return shape: (n, dim)
raise NotImplementedError
# 2) ColBERT 임베딩/스코어(재랭킹용)
def colbert_score(query: str, docs: List[str]) -> np.ndarray:
# TODO: ColBERT late interaction(MaxSim) 점수
# return shape: (len(docs),)
raise NotImplementedError
class Retriever:
def __init__(self, faiss_index: faiss.Index, corpus: List[Dict[str, Any]]):
self.index = faiss_index
self.corpus = corpus # each: {"text": str, "meta": {...}}
def search(
self,
query: str,
k_final: int = 10,
k_candidates: int = 100,
meta_filter: Dict[str, Any] | None = None,
) -> List[Tuple[float, Dict[str, Any]]]:
# (A) 메타데이터 필터를 먼저 적용하고 싶다면
# 실제 운영에서는 tenant/version/lang 별로 인덱스를 분리하거나
# 필터링된 id 리스트로 서브 인덱스를 구성하는 전략을 씁니다.
q = embed_dense([query]).astype("float32")
# (B) FAISS 후보 검색
scores, ids = self.index.search(q, k_candidates)
ids = ids[0].tolist()
candidates = []
for doc_id in ids:
doc = self.corpus[doc_id]
if meta_filter:
ok = True
for key, val in meta_filter.items():
if doc.get("meta", {}).get(key) != val:
ok = False
break
if not ok:
continue
candidates.append((doc_id, doc["text"], doc))
if not candidates:
return []
# (C) ColBERT 재랭킹(배치 처리 권장)
doc_texts = [c[1] for c in candidates]
rerank_scores = colbert_score(query, doc_texts)
# (D) 상위 k_final 반환
ranked = sorted(
zip(rerank_scores.tolist(), [c[2] for c in candidates]),
key=lambda x: x[0],
reverse=True,
)
return ranked[:k_final]
운영 팁: 재랭킹은 반드시 배치로
ColBERT 재랭킹은 GPU/CPU 모두에서 배치 크기에 따라 처리량이 크게 달라집니다.
- 온라인 요청당
K=100이면,100개 문서를 한 번에 스코어링하는 배치가 효율적 - 트래픽이 높으면 “micro-batching(예:
5~20ms윈도우)”로 합쳐 처리량을 올림
스트리밍 응답을 쓰는 경우, 리트리버가 느려지면 TTFB가 튀면서 499/502가 늘 수 있습니다. 프록시/버퍼링/타임아웃 관점은 LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트에서 네트워크 레이어까지 함께 점검하는 걸 권합니다.
튜닝 체크리스트: “2배”는 보통 여기서 나온다
현장에서 체감 개선이 컸던 순서대로 정리하면 아래와 같습니다.
1) K를 올리고, 최종 k는 유지
k_final=10고정k_candidates를50에서200으로 올려 리콜 확보- 그 다음 지연/비용을 최적화
이 한 가지로 “정답 chunk가 후보에 들어오는 비율”이 확 뛰는 경우가 많습니다.
2) chunk overlap을 늘려 “경계 손실” 제거
overlap이 너무 작으면 중요한 문장이 경계에서 잘려 매칭이 깨짐- 특히 기술 문서의 “조건 + 결과”가 서로 다른 chunk로 갈라지면 LLM이 오답을 만들기 쉽습니다
3) 도메인 동의어/약어 사전으로 query 확장
- 예:
OOM=out of memory,IRSA=IAM Roles for Service Accounts - ColBERT가 강하더라도, 질의 토큰 자체가 없으면 매칭이 약해집니다
확장은 “무작정 추가”가 아니라, 동의어 후보를 1차 후보 생성에서만 사용하고 2차 재랭킹은 원 질의로 하는 식이 안전합니다.
4) 하이브리드(lexical + dense)로 후보를 섞기
ColBERT가 구절 매칭에 강해도, 1차 후보 생성이 dense만이면 희귀 토큰(에러 코드, 설정 키)에서 miss가 납니다.
- BM25 top
K1 - dense top
K2 - 합쳐서
K=K1+K2를 ColBERT로 재랭킹
이 조합은 “정답이 후보에 들어올 확률”을 크게 올립니다.
5) hard negative로 도메인 미세튜닝
“비슷해 보이지만 답이 아닌 문서”를 잘 구분하도록 학습시키면, 재랭킹의 정밀도가 크게 오릅니다.
- 쿼리-정답 chunk(positive)
- 같은 섹션/같은 키워드지만 답이 아닌 chunk(hard negative)
이 데이터는 로그 기반(클릭/채택/재질문)으로 만들면 가장 강합니다.
평가 지표: 무엇이 “품질 2배”인가
리트리버 튜닝은 지표를 잘 잡아야 과적합을 피합니다.
Recall@k: 정답 chunk가 topk에 포함되는 비율MRR@k: 정답이 얼마나 상위에 오는지nDCG@k: 다중 정답/부분 정답에 유리- RAG 전용: grounded answer rate(근거 인용이 정답 chunk에 기반하는 비율)
실무에서는 Recall@10과 MRR@10을 기본으로 보고, 생성까지 포함한 종단 지표로 “정답률/재질문율/에스컬레이션율”을 같이 봅니다.
성능 최적화: ColBERT를 ‘빠르게’ 쓰는 법
ColBERT+FAISS는 품질이 좋아도 지연이 늘면 못 씁니다. 아래는 비용을 줄이는 현실적인 방법입니다.
1) 재랭킹 대상 문서 텍스트를 최소화
- chunk 본문만 재랭킹에 넣고, 표/코드블록/긴 예시는 별도 필드로 분리
- 또는 “요약된 chunk”(핵심 문장)로 재랭킹하고, 최종 컨텍스트에는 원문을 넣는 2단 구성
2) ONNX/INT8로 재랭커 가속
ColBERT 계열도 결국 Transformer 추론이므로, ONNX 변환과 INT8 양자화가 통하는 경우가 많습니다. 모델 경량화 접근은 PyTorch 모델을 ONNX+INT8로 경량화해 3배 빠르게 글의 체크리스트를 그대로 응용할 수 있습니다.
3) 캐시: 같은 질문은 생각보다 많이 반복된다
- 쿼리 정규화(소문자화, 공백 정리, 버전 문자열 정규화)
- top
k결과를 짧은 TTL로 캐시
주의할 점은 “코퍼스가 자주 바뀌는” RAG에서 stale 캐시가 사고를 만든다는 것입니다. 캐시 무효화/버전 키 설계는 Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법처럼 데이터 버전 기반으로 가져가면 운영이 편해집니다.
흔한 장애 포인트와 디버깅 방법
증상 1) 특정 문서가 과도하게 상위에 뜬다
- chunk가 지나치게 길거나, 중복 텍스트가 많아 MaxSim이 “우연 매칭”을 주워오는 경우
- 해결: 길이 정규화, 중복 제거, chunk 상한 축소
증상 2) 에러 코드/설정 키 검색이 약하다
- dense 후보 생성에서 누락되는 경우가 많음
- 해결: BM25 혼합, 약어 확장, 숫자/특수 토큰 보존 전처리
증상 3) 지연이 스파이크 난다
K가 커졌는데 배치가 깨져서 문서별로 재랭킹 호출하는 경우- 해결: 재랭킹 배치화, micro-batching, GPU 워밍업, 타임아웃/서킷브레이커
마무리: ColBERT+FAISS는 “정답이 후보에 들어오게” 만든다
RAG에서 리트리버 품질을 2배 체감으로 올리는 핵심은 간단합니다.
- FAISS로 리콜을 확보하고(
K를 충분히) - ColBERT로 정밀도를 회복하며(재랭킹)
- chunk/필터/정규화로 편향과 노이즈를 제거합니다.
이 조합은 특히 기술 문서, 장애 대응 플레이북, 사내 위키처럼 “구절 매칭이 중요한 코퍼스”에서 강력합니다. 다음 단계로는 하이브리드 후보 생성과 hard negative 기반 미세튜닝을 붙여, 도메인에서 헷갈리는 문서들까지 확실히 구분하는 쪽으로 확장해 보세요.