- Published on
RAG 정확도 급락? 청크·재랭킹 튜닝 9단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG(Retrieval-Augmented Generation)를 운영하다 보면 어느 날부터 답이 엉뚱해지거나, 근거 문서를 못 찾고 환각이 늘어나는 “정확도 급락”을 겪습니다. 이때 많은 팀이 임베딩 모델 교체부터 시도하지만, 실제로는 청크 분할, 검색 파라미터, 재랭킹, 컨텍스트 조립에서 작은 설정 변화가 누적되어 성능이 무너지는 경우가 더 흔합니다.
이 글은 “왜 떨어졌는지”를 감으로 추측하지 않고, 측정 가능한 신호를 바탕으로 청크·재랭킹을 중심으로 RAG를 9단계로 튜닝하는 실전 가이드를 정리합니다.
운영 팁: RAG도 결국 분산 시스템입니다. 타임아웃이나 병목이 섞이면 품질이 떨어진 것처럼 보이기도 합니다. API 호출이 느려져 문서 검색이 일부만 완료되는 케이스가 의심되면 네트워크/타임아웃 진단도 같이 보세요. 예: gRPC 데드라인 전파 실패, 원인과 진단법
0. 먼저 “정확도 급락”을 수치로 정의하기
튜닝에 들어가기 전에, 급락을 다음 중 최소 2개 지표로 고정하세요.
- Recall@k(정답 문서 포함률): 정답 근거가 상위
k개 검색 결과에 포함되는 비율 - MRR / nDCG(순위 품질): 정답이 얼마나 위에 노출되는지
- Answer Faithfulness(근거 일치성): 답변이 제공된 컨텍스트에 의해 뒷받침되는지
- Context Utilization(컨텍스트 사용률): 컨텍스트가 길기만 하고 실제로 쓰이지 않는지
최소한 아래 형태의 로그를 남기면, “검색이 망한 건지 / 재랭킹이 망한 건지 / 생성이 망한 건지”를 분리할 수 있습니다.
{
"query": "...",
"topk_before_rerank": [
{"doc_id": "a", "score": 0.82},
{"doc_id": "b", "score": 0.80}
],
"topk_after_rerank": [
{"doc_id": "b", "score": 9.1},
{"doc_id": "a", "score": 8.7}
],
"context_tokens": 4200,
"answer": "...",
"citations": ["b#chunk-12"],
"latency_ms": {"embed": 12, "retrieve": 35, "rerank": 110, "llm": 900}
}
1단계: 쿼리/정답/근거 데이터셋부터 고정하기
정확도 급락은 종종 “평가 질문이 바뀌었는데도 같은 기준으로 비교”해서 발생합니다. 아래를 먼저 고정하세요.
- 질문 유형 분리: 사실 질의, 절차 질의, 비교/요약, 최신성 요구
- 정답의 형태: 한 문장, 표, 단계 목록 등
- 정답 근거 문서(grounded evidence): 최소 1개 문서/구간을 정답으로 라벨링
권장: 50~200개 정도의 작은 골든셋을 만들고, 배포 전후를 비교하세요.
2단계: 청크 전략을 “문서 구조”에 맞추기
RAG의 품질은 청크에서 시작합니다. 특히 정확도 급락은 다음 변화로 잘 생깁니다.
- 문서 포맷이 바뀜(HTML
h2/h3구조가 깨짐, PDF OCR 품질 저하) - 문서 길이가 길어졌는데 청크 크기는 그대로
- 코드/표/리스트가 청크 경계에서 잘려 의미가 손상
추천 청크 원칙
- 구조 기반 분할: 제목/섹션/문단 단위로 먼저 자르고, 너무 길면 토큰 기반으로 2차 분할
- 의미 단위 유지: 목록은 목록 끝까지, 표는 표 단위로
- 메타데이터 강화:
doc_title,section_path,updated_at,product,version
예시: 구조 기반 청크(파이썬)
import re
def chunk_by_headings(text: str, max_chars: int = 1800):
blocks = re.split(r"\n(?=# )|\n(?=## )|\n(?=### )", text)
chunks = []
buf = ""
for b in blocks:
if len(buf) + len(b) > max_chars and buf:
chunks.append(buf.strip())
buf = b
else:
buf += "\n" + b
if buf.strip():
chunks.append(buf.strip())
return chunks
3단계: 청크 크기·오버랩을 “질문 유형”에 맞춰 튜닝하기
정답이 섹션 전체에 흩어져 있는 질문(비교, 요약)은 작은 청크가 불리하고, 특정 문장/정의 찾기는 큰 청크가 불리합니다.
실무에서 많이 쓰는 시작점:
- 정의/FAQ 위주:
250~500토큰, 오버랩10%내외 - 운영 런북/절차:
500900토큰, 오버랩10%20% - 긴 기술 문서/스펙:
800~1200토큰 + 섹션 경계 우선
오버랩을 과하게 주면 중복 컨텍스트가 늘어 재랭킹이 같은 내용만 상위로 올려 다양성이 깨질 수 있습니다.
4단계: 검색 쿼리 전처리(정규화·확장)로 “찾는 말”을 맞추기
정확도 급락이 “동의어/약어/버전 표기” 문제인 경우가 많습니다.
- 약어 확장:
IRSAIAM Roles for Service Accounts - 버전 정규화:
v1.291.29k8s 1.29 - 제품명 별칭:
ECRElastic Container Registry
간단한 룰 기반 확장만으로도 recall이 크게 오릅니다.
SYNONYMS = {
"irsa": ["iam roles for service accounts"],
"ecr": ["elastic container registry"],
}
def expand_query(q: str) -> str:
tokens = q.lower().split()
extra = []
for t in tokens:
extra += SYNONYMS.get(t, [])
return q + (" " + " ".join(extra) if extra else "")
5단계: 벡터 검색 파라미터(k, 후보풀, 필터)부터 재점검하기
재랭킹이 있어도, 1차 검색에서 후보가 안 들어오면 끝입니다.
top_k를 너무 낮게 잡으면 재랭커가 일할 재료가 없습니다.- 반대로 너무 높으면 재랭킹 비용이 폭증하고 지연이 늘어 타임아웃으로 품질이 떨어질 수 있습니다.
권장 접근:
- 1차 검색:
k=50~200후보 확보 - 재랭킹 후:
k=5~20로 압축 - 메타 필터 적극 활용:
product,lang,version,updated_at
예: 최신 문서 우선이 중요한 도메인은 updated_at 가중치를 주거나, 최소한 오래된 문서를 제외하는 필터를 둡니다.
6단계: 재랭킹 모델을 “과제”에 맞게 고르기
재랭킹은 크게 두 부류가 있습니다.
- Bi-encoder(임베딩) 기반: 빠르지만 미세한 의미 구분이 약함
- Cross-encoder(재랭커) 기반: 느리지만 정확도가 좋음
정확도 급락을 빠르게 복구하려면, 우선 cross-encoder 재랭커를 붙여 “순위 품질”을 안정화하는 게 효과적입니다.
선택 기준:
- 질의가 짧고 문서가 길수록 cross-encoder 이점이 큼
- 다국어/혼합 언어면 다국어 재랭커 필요
- 지연 예산이 빡빡하면 후보풀을 줄이고 캐싱을 강화
7단계: 재랭킹 입력 텍스트를 “청크 그대로” 쓰지 말기
많이 놓치는 포인트가 재랭커 입력 구성입니다.
- 청크 앞부분에 제목/섹션 경로가 없으면 의미를 잃음
- 코드 블록/로그만 잔뜩 들어가면 재랭커가 질의와의 관련성을 낮게 판단
권장 포맷(문서 메타 + 핵심 본문):
[Title] ...
[Section] ... > ...
[Content]
...chunk text...
또한 재랭커에는 너무 긴 텍스트를 넣지 마세요. 모델 입력 길이를 넘기면 뒷부분이 잘려 “정답이 있는 문장”이 사라질 수 있습니다.
8단계: 컨텍스트 조립(중복 제거·다양성·인용)으로 마지막 20% 올리기
재랭킹까지 좋아도, 최종 컨텍스트가 엉망이면 답이 흔들립니다.
체크리스트:
- 중복 제거: 같은 문서의 유사 청크가 5개 들어가면 다양성이 깨짐
- 다양성 확보: 서로 다른 섹션/문서에서 1~2개씩 가져오기
- 인용 가능한 형태:
doc_id#chunk_id를 유지하고 답변에 인용 강제
간단한 중복 제거(유사도 기반) 예시:
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
def dedup_by_embedding(chunks, embs, threshold=0.92):
keep = []
keep_embs = []
for c, e in zip(chunks, embs):
if not keep_embs:
keep.append(c); keep_embs.append(e); continue
sims = cosine_similarity([e], np.array(keep_embs))[0]
if sims.max() < threshold:
keep.append(c); keep_embs.append(e)
return keep
9단계: 실패 케이스를 유형화하고, 운영 가드레일을 걸기
정확도 급락은 재발합니다. 재발 방지를 위해 “실패 유형”을 분류하고 자동 알림을 거세요.
대표 유형:
- Retrieval miss: 정답 문서가 후보에 없음(청크/임베딩/필터 문제)
- Ranking error: 후보에는 있는데 순위가 낮음(재랭커/입력 구성 문제)
- Context overflow: 컨텍스트 길이 초과로 정답 문장이 잘림(조립/토큰 예산 문제)
- Generation drift: 컨텍스트는 맞는데 답이 빗나감(프롬프트/온도/규칙 문제)
운영 가드레일 예시:
- Recall@50이 기준 이하로 떨어지면 자동으로
k를 임시 상향 - 재랭킹 지연이 임계치를 넘으면 후보풀 축소 + 캐시 우선
- 특정 버전 문서가 섞이면 경고(예:
k8s 1.29질문에1.22문서가 상위)
성능(지연/비용)도 같이 봐야 합니다. 로컬 LLM이나 자체 호스팅 재랭커를 쓰는 경우, 추론 최적화가 품질 안정성으로 이어지기도 합니다. 관련해서는 로컬 LLM 속도 2배 - KV 캐시·FlashAttention2 같은 최적화 접근이 참고가 됩니다.
재현 가능한 튜닝 플로우(요약)
아래 순서대로 하면 “어디가 원인인지”가 비교적 빨리 좁혀집니다.
- 골든셋 고정(질문-정답-근거)
- 청크 분할을 구조 기반으로 교정
- 청크 크기/오버랩을 질문 유형에 맞게 조정
- 쿼리 전처리(약어/버전/동의어)
- 1차 검색
k와 필터 재점검 - cross-encoder 재랭커 도입 또는 교체
- 재랭커 입력 텍스트 구성(제목/섹션/본문)
- 컨텍스트 조립(중복 제거/다양성/인용)
- 실패 유형별 모니터링/가드레일
실전 디버깅 체크: “갑자기” 떨어졌다면 의심할 6가지
- 인덱싱 파이프라인 변경(전처리, 문서 파서, OCR)
- 문서 업데이트로 섹션 구조 붕괴
- 메타 필터 기본값 변경(언어/버전/제품)
- 임베딩 모델 버전 변경 또는 차원 불일치
- 재랭커 입력 길이/포맷 변경
- 타임아웃/부분 실패로 검색 결과가 누락
특히 마이크로서비스 환경에서 타임아웃 전파가 깨지면, 일부 요청이 조용히 실패하고도 전체는 성공처럼 보일 수 있습니다. 이런 케이스는 품질 이슈로 가장 먼저 관측됩니다. 필요하면 gRPC 데드라인 전파 실패, 원인과 진단법처럼 “품질 저하로 보이는 장애” 패턴도 함께 점검하세요.
마무리
RAG 정확도 급락은 대개 “모델이 나빠서”가 아니라, 청크 경계에서 의미가 깨지고, 1차 검색 후보가 부족하거나, 재랭킹 입력이 부실해서 발생합니다. 위 9단계를 골든셋과 로그를 기반으로 순서대로 적용하면, 원인을 분리하고 빠르게 복구할 수 있습니다.
다음 단계로는 (1) 질문 유형별로 청크 정책을 다르게 적용하는 멀티 프로파일, (2) 하이브리드 검색(BM25 + 벡터), (3) 쿼리 라우팅까지 확장하면 장기적으로 안정적인 품질을 만들 수 있습니다.