RSS

RAG 답변이 얕은 이유: 검색이 아니라 청크 크기

사내 문서 1,290건을 넣어 둔 RAG 챗봇이 있습니다. 검색은 그럭저럭 맞는 문서를 찾아오는데, 답변이 계속 얕았습니다. 맞는 말이긴 한데 한 줄로 끝나거나, "문서에서 충분한 근거를 찾지 못했습니다"가 나오는 일이 잦았습니다.

저는 검색 순위가 문제라고 생각했습니다. 벡터 검색과 키워드 검색을 RRF로 섞어 쓰고 있었으니, 다음 단계는 리랭커를 붙이는 것이라고 봤습니다.

재봤더니 아니었습니다.

근거가 1,200자밖에 안 됐습니다

먼저 지표부터 봤습니다.

SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY length(content))
FROM doc_chunk;

중앙값 207자였습니다. 분포를 나눠 보니 더 분명했습니다.

청크 길이 개수 비율
100자 미만 2,415 24.6%
100~300자 3,981 40.5%
300~700자 2,418 24.6%
700~1500자 624 6.3%
1500자 이상 391 4.0%

65%가 300자 미만입니다.

답변 하나에 넣는 근거는 최대 여섯 개로 제한해 뒀습니다. 중앙값 207자면 근거 전체가 1,200자 남짓입니다. 프롬프트에 넣을 수 있는 상한은 12,000자로 잡아 뒀는데, 실제로는 그 10분의 1만 채우고 있었습니다.

모델이 답을 못 한 게 아니라 줄 게 없었던 겁니다.

제목이 바뀔 때마다 잘랐습니다

원인은 청킹 코드 한 줄에 있었습니다.

const TARGET_TOKENS = 800;

const headingChanged = content.length > 0 && block.headingPath !== headingPath;
if (headingChanged || (tokens > 0 && tokens + size > TARGET_TOKENS)) {
  push();  // 청크 확정
}

목표 크기는 800토큰인데, 제목이 바뀌면 크기와 상관없이 무조건 잘랐습니다.

소제목이 촘촘한 문서에서 이게 치명적이었습니다. 이런 문서를 생각해 보시면 됩니다.

### 개발 환경
포트 3000. npm run dev.

### 스테이징
포트 3001. 매일 자정 배포.

### 운영
포트 80. 태그를 달면 배포.

세 절이 각각 별도 청크가 됩니다. 하나에 30자쯤 됩니다. "운영 포트가 뭐야?"라고 물으면 포트 80. 태그를 달면 배포.라는 조각 하나가 근거로 갑니다. 앞에 두 환경이 더 있다는 사실은 사라집니다.

제가 이 규칙을 처음 쓸 때는 나름 이유가 있었습니다. 절이 다르면 다른 얘기니까 섞지 말자는 것이었습니다. 방향은 맞는데, 크기를 전혀 보지 않은 게 잘못이었습니다.

세 가지를 손봤습니다

1. 이웃 청크 붙이기

가장 먼저 한 것이자, 셋 중 가장 싼 것입니다. 검색은 청크 단위 그대로 두고, 답변에 넣을 때만 앞뒤를 붙였습니다.

찾기는 좁게, 읽기는 넓게 하자는 겁니다. 짧은 청크는 임베딩이 한 가지 뜻만 담아서 검색에는 오히려 유리합니다. 문제는 그 크기 그대로 모델에게 준다는 것이었습니다.

doc_chunk(source_doc_id, seq) 유니크 인덱스가 이미 있어서, 필요한 조합만 모아 한 번에 읽으면 됩니다.

SELECT source_doc_id, seq, content
  FROM doc_chunk
 WHERE (source_type = 'doc' AND source_doc_id = $1 AND seq = ANY($2::int[]))
    OR (source_type = 'doc' AND source_doc_id = $3 AND seq = ANY($4::int[]))

근거마다 따로 조회하면 여섯 번 왕복인데, 그중 상당수는 같은 문서의 이웃이라 중복입니다.

한 가지 걸린 게 있었습니다. 청킹할 때 문맥 유지를 위해 이전 청크의 꼬리를 다음 청크 앞에 겹쳐 넣어 뒀습니다. 그냥 이어 붙이면 같은 문장이 두 번 나옵니다. 앞 조각의 끝과 뒤 조각의 시작이 겹치는 만큼을 찾아 잘라냈습니다.

function joinWithoutOverlap(previous: string, next: string): string {
  const max = Math.min(400, previous.length, next.length);
  for (let length = max; length >= 24; length--) {
    if (previous.endsWith(next.slice(0, length))) return previous + next.slice(length);
  }
  return `${previous}\n\n${next}`;
}

이 방법의 장점은 재색인이 필요 없다는 것입니다. 저장된 벡터를 하나도 건드리지 않습니다.

2. 청킹 규칙 고치기

근본 원인은 따로 있으니 그것도 고쳤습니다. 제목이 바뀌어도 목표치의 절반은 차야 자르도록 했습니다.

const MIN_SPLIT_TOKENS = TARGET_TOKENS / 2;

const splitHere =
  (headingChanged && tokens >= MIN_SPLIT_TOKENS) ||
  (tokens > 0 && tokens + size > TARGET_TOKENS);

여기서 놓치기 쉬운 게 하나 있습니다. 여러 절을 한 청크에 합치면 "이 문단이 어느 절 얘기인지"가 사라집니다. 합쳐서 얻은 문맥만큼 다른 문맥을 잃는 셈입니다. 그래서 합칠 때 제목을 본문에 남기게 했습니다.

function headingMarker(previous: string | null, next: string | null): string | null {
  if (!next || next === previous) return null;
  // 앞 절과 공통인 상위 경로는 빼고, 달라지는 부분만 표시한다
  const previousParts = previous?.split(" > ") ?? [];
  const nextParts = next.split(" > ");
  let shared = 0;
  while (shared < nextParts.length && nextParts[shared] === previousParts[shared]) shared++;
  return `${"#".repeat(Math.min(shared + 1, 6))} ${nextParts.slice(shared).join(" > ")}`;
}

바꾸기 전에 운영 문서 전체에 적용해서 분포만 계산해 봤습니다. 쓰기는 하지 않고 숫자만 보는 스크립트입니다.

문서 1290건 → 청크 3547개 (문서당 2.7)
중앙값 989자 · 25% 617자 · 75% 1235자

중앙값 207자 → 989자. 청크 개수는 10,813 → 3,547개로 줄었습니다.

3. 리랭커

마지막이 원래 제일 먼저 하려던 것입니다. 검색 점수는 "질문과 비슷한가"를 봅니다. 그런데 필요한 건 "이 질문의 답이 여기 있는가"입니다. 배포 절차를 물으면 배포를 언급하기만 한 회의록이 정작 절차 문서보다 높은 점수를 받는 일이 생깁니다.

정석은 cross-encoder 리랭커인데, Ollama에는 rerank 엔드포인트가 없습니다. 그래서 다르게 했습니다. 후보 20개를 한 번의 호출로 작은 모델에게 통째로 보여 주고 번호만 고르게 했습니다.

const SYSTEM = `질문과 후보 목록을 받습니다. 질문에 답하는 데 실제로 쓸 수 있는 후보의 번호만 고르세요.

- 질문의 주제를 언급하기만 한 후보는 고르지 않습니다. 답이 담긴 후보만 고릅니다.
- 쓸 만한 후보가 없으면 빈 배열을 돌려줍니다. 억지로 채우지 않습니다.
- {"picks": [번호, 번호, ...]} 형태의 JSON만 출력합니다.`;

후보마다 부르면 스무 번 왕복이지만 한꺼번에 주면 한 번입니다. 처음에는 2B 모델을 붙였습니다.

한 가지 안전장치를 뒀습니다. 모델이 전부 버리면 그 판단을 따르지 않습니다.

if (!chosen.length) return hits;  // 원래 순위로 되돌린다

작은 모델은 질문이 추상적일 때 빈 배열을 내놓는 버릇이 있습니다. 그대로 따르면 답할 근거가 아예 사라집니다. 리랭크는 보조 장치이지 관문이 아니라고 정리했습니다.

재색인하다 만난 것

청킹 규칙을 바꿨으니 기존 청크를 다시 만들어야 했습니다. 인덱서에 force 플래그가 있길래 그걸 켜고 루프를 돌렸습니다.

1회차 · 출처 20개 · 남음 2249
2회차 · 출처 40개 · 남음 2249
3회차 · 출처 60개 · 남음 2249

남음이 줄지 않았습니다. 코드를 보니 이랬습니다.

const pending = all.filter(source => force || 해시가_다름(source));
for (const source of pending.slice(0, 20)) { ... }

force는 대상을 "전부"로 만들 뿐, 처리가 끝난 것을 목록에서 빼지 않습니다. 매번 같은 앞 20개만 돌고 있었습니다.

플래그를 고치는 대신 다른 방법을 택했습니다. 청킹 규칙의 판 번호를 해시에 섞었습니다.

const CHUNKING_VERSION = "2";
const hash = (source) =>
  createHash("sha256").update(`v${CHUNKING_VERSION}\n${source.title}\n\n${source.content}`).digest("hex");

이러면 force 없이도 규칙이 바뀐 순간 모든 문서의 해시가 어긋나 대상이 되고, 끝난 것부터 목록에서 빠집니다. 다음에 규칙을 또 바꿔도 판만 올리면 저절로 돌아갑니다.

재색인 비용은 미리 재봤습니다. bge-m3에 배치 32개를 한 번에 넣으면 초당 12.6청크였습니다. 1만 청크 기준 14분, 새 규칙으로 개수가 3,547개로 줄어드니 실제로는 그 3분의 1입니다.

돌리는 도중에 임베딩 서버 연결 실패가 몇 건 났습니다. 해시를 저장하지 않았으니 다음 회차에 자동으로 다시 대상이 됩니다. 이건 우연히 잘 맞은 경우입니다.

배포하고 물어보니 두 개가 틀려 있었습니다

여기까지 하고 배포한 다음, 실제로 질문을 던져 봤습니다. 근거는 여섯 개, 합계 12,004자였습니다. 1,200자에서 열 배로 늘어 상한에 닿았습니다. 답변도 눈에 띄게 구체적이었습니다.

그런데 근거 목록을 하나씩 보다가 두 가지를 발견했습니다.

[엣지 캐시를 켜기 전 확인할 것]   2008자  expanded=True  reranked=None
[엣지 캐시를 켜기 전 확인할 것]   2008자  expanded=True  reranked=None
[A 레코드 유형]                 2038자  expanded=True  reranked=None
[캐시 퍼지 순서]                2343자  expanded=True  reranked=None
[캐시 퍼지 순서]                1219자  expanded=None  reranked=None
[Expo EAS 자격 증명]            2388자  expanded=True  reranked=None

리랭커가 한 번도 동작하지 않았습니다

reranked가 전부 비어 있습니다. 실패해도 원래 순위로 돌아가게 만들어 뒀으니 답변은 나오지만, 그래서 조용히 실패하고 있었습니다.

Ollama에 직접 같은 프롬프트를 던져 보니 원인이 나왔습니다.

$ curl ... -d '{"model":"qwen3.5:2b", "format":"json", ...}'
응답: '2'

{"picks": [2]}가 아니라 그냥 2였습니다. format: "json""JSON이어라"까지만 강제하고 모양은 강제하지 않습니다. 그리고 2는 유효한 JSON입니다. JSON.parse("2")는 성공하고, parsed.picksundefined이고, 고른 게 없으니 원래 순위로 되돌아갑니다. 전 과정이 예외 없이 진행됩니다.

JSON 스키마를 format에 넘겨 보기도 했는데(Ollama 0.31.1) 2b도 4b도 9b도 지키지 않았습니다. 나온 응답은 이랬습니다.

qwen3.5:2b → '2. [캐시 퍼지 순서] 오리진을 먼저 비우고 엣지를 나중에 비웁니다'
qwen3.5:4b → '2, 4'
qwen3.5:9b → '2'

두 가지를 했습니다.

먼저 출력 형식 지시를 규칙 목록에서 떼어 마지막에 따로 뒀습니다. 규칙 다섯 개 중 하나로 섞어 두면 작은 모델이 그 줄을 흘립니다. 이렇게 바꾸니 4b는 {"picks": [2]}를 정확히 냈습니다.

그다음 나오는 모양을 전부 받도록 파서를 고쳤습니다.

function parsePicks(content: string): number[] {
  const text = content.trim();
  try {
    const value = JSON.parse(text) as unknown;
    if (Array.isArray(value)) return value.map(Number);
    if (typeof value === "number") return [value];
    if (value && typeof value === "object") { /* picks 꺼내기 */ }
  } catch { /* JSON 이 아니면 아래 규칙으로 */ }
  // 앞쪽의 숫자·쉼표만 읽고 그 뒤는 버린다
  const leading = text.match(/^[\d,\s]+/)?.[0] ?? "";
  return leading.split(/[,\s]+/).filter(Boolean).map(Number);
}

마지막 줄이 중요합니다. 2. [캐시 퍼지 순서] …처럼 제목까지 붙여 오는 경우, 문자열 전체에서 숫자를 긁으면 본문 속 숫자까지 번호로 오해합니다. 앞쪽 숫자·쉼표만 읽고 나머지는 버립니다.

같은 내용이 두 번 실렸습니다

목록 첫 두 줄을 보시면 같은 문서에서 2,008자가 완전히 똑같이 두 번 들어가 있습니다.

같은 문서의 붙어 있는 청크 두 개를 모두 고른 뒤 각각 앞뒤로 넓히면 서로의 이웃을 끌어옵니다. 결국 같은 범위를 두 번 싣게 됩니다. 근거는 여섯 개가 상한이니 그만큼 다른 문서가 밀려납니다.

처음에는 확장 단계에서 뒤엣것을 버리도록 고쳤습니다. 그런데 그러면 그 자리가 그냥 빕니다. 근거가 여섯 개에서 다섯 개로 줄 뿐입니다. 선별 단계로 옮겨야 걸러진 자리를 다른 문서가 채웁니다.

const adjacent = selected.some(other =>
  같은_문서(other, hit) && Math.abs(other.seq - hit.seq) <= EXPAND_RADIUS * 2);
if (adjacent) continue;

두 실패의 성격이 다릅니다. 리랭커는 동작하지 않는데 조용했고, 중복은 동작하는데 낭비했습니다. 둘 다 배포하고 실제로 물어보기 전에는 못 봤을 것들입니다.

순서를 이렇게 잡은 이유

셋 중 1번(이웃 붙이기)을 가장 먼저 했습니다. 재색인이 필요 없어서 효과를 바로 볼 수 있고, 2번을 한 뒤에도 여전히 쓸모가 있기 때문입니다. 989자짜리 청크에도 앞말과 뒷말은 있습니다.

3번을 마지막에 둔 데도 이유가 있습니다. 근거가 부족해서 틀리는 것과 잘못 골라서 틀리는 것은 증상이 같습니다. 1·2를 먼저 하지 않으면 리랭커를 붙여도 나아졌는지 판단할 수가 없습니다.

남은 것

리랭커는 이제 막 동작하기 시작했습니다. 얼마나 도움이 되는지는 아직 모릅니다. 답변 품질은 자동으로 재기 어려워서 당분간은 쓰면서 눈으로 볼 생각입니다. 질문 세트를 만들어 두고 비교하는 게 맞겠지만 아직 안 했습니다.

이웃 청크는 앞뒤 한 칸씩만 붙입니다. 두 칸으로 늘리면 근거는 더 풍부해지지만 프롬프트가 그만큼 무거워집니다. 어디가 적당한지는 재보지 않았습니다.


처음에 검색 순위를 의심한 게 완전히 틀린 짐작은 아니었습니다. 다만 순서가 틀렸습니다. 줄 게 없는 상태에서 무엇을 먼저 줄지 고민하고 있었던 겁니다.

RAG가 기대만큼 안 나온다면, 검색을 손보기 전에 SQL 한 줄을 먼저 돌려 보시길 권합니다.

SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY length(content)) FROM doc_chunk;

이 숫자에 근거 개수를 곱한 값이, 모델이 실제로 읽는 전부입니다.