본문으로 건너뛰기
19/3030개 중 19장

Production RAG: chunking, retrieval, 정직한 인용

512자 단위로 무작정 자르면 32개 답 중 4개가 retrieval 전에 사라집니다. chunker만 고쳐도 rank 115가 3으로 올라갑니다.

이 페이지에서

실제 assistant의 실제 사용자가 던진 실제 질문이 있습니다. 내 eval set이 20개인데, 이 점수를 믿기에 충분한가요. corpus에는 답이 있습니다. 그것도 한 섹션 전체가 답입니다. 아래는 retriever가 실제로 prompt에 넣은 네 조각입니다.

four fragments, chunked blind at 512 charactersTEXT
[1] d=0.578  ship — that set has been used for fitting, and its score stops being
             unbiased. Measured on this belt: sweeping the threshold on the
             validation set picks 0.196, and the model then scores F1 = 0.4122…

[2] d=0.602  ng when the model is confidently **wrong**. Evaluate both at a few
             scores, for an example whose true label is 1: | score | p | …

[3] d=0.613  ard and watch both numbers: | | reward model's score | true quality
             | length produced | … The reward went up by a factor of 2.5. The…

[4] d=0.617  | 0.6 | +0.97 | +1.00 | +0.27 | … The reward model is working
             perfectly. It has faithfully learned the preferences it was shown…

네 조각 중 세 개는 단어 중간에서 시작합니다. 두 개는 전혀 다른 주제를 다루는 다른 장에서 왔습니다. 그리고 질문에 답하는 조각 — 20개 중 17개로는 85% model과 65% model을 구분할 수 없다는 문장을 담은 조각 — 은 rank 115로 돌아왔습니다.

이제 같은 질문, 같은 embedding model, 같은 prompt template입니다. 바뀐 것은 하나뿐입니다. 문서를 어떻게 잘랐는가입니다.

four fragments, cut on section boundaries with a contextual headerTEXT
[1] d=0.594  [Classification, Cross-Entropy… > How many test examples do I need?]
             Read it backwards, which is how you will use it: ±5 points needs
             about 200 examples. ±2 points needs about 1,230…

[2] d=0.598  [Classification, Cross-Entropy… > Three splits, and the leak…]
             Why three splits and not two? Because the moment you use a set of
             examples to *choose* anything…

[3] d=0.600  [Classification, Cross-Entropy… > How many test examples do I need?]
             The honest reading of 17/20 is *somewhere between 64 % and 95 %*.
             …Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one.

[4] d=0.605  [Classification, Cross-Entropy… > How many test examples do I need?]
             Suppose you score a model on 20 examples and it gets 17 right. You
             report 85 %. …Wilson 95% CI : [0.6396, 0.9476]

rank 115에서 rank 3으로. model도, prompt도, threshold도, slot 수도 건드리지 않았습니다. 이 장은 그 격차에 대한 이야기이자, retrieval 시스템이 조용히 당신에게 거짓말하는 다른 네 지점에 대한 이야기입니다.

세부 정보 보기

이 장이 앞 장들에서 필요로 하는 것, 그리고 용어가 바뀌는 한 지점.

  • 1장 은 dot product와 L2 norm을 정의했습니다. 아래 threshold 섹션은 이 둘만 사용하며, 그 밖의 것은 없습니다.
  • 8장 은 language model의 embedding table과 pair에 대해 contrastive하게 학습된 retrieval embedding model을 구분했고, cosine similarity를 측정했으며, 19장에서 구체적인 cut-off에 도달하겠다고 약속하며 끝났습니다. 그 약속이 여기서 이행됩니다. 그 내용은 반복하지 않습니다.
  • 4장 은 Wilson interval을 만들었고, 15장 은 evaluation harness를 만들었습니다. 아래의 모든 표는 전자를 포함하며 후자로 생성했습니다.
  • 16장 은 context window의 비용을 계산했습니다. 이 장 끝에서 조립되는 prompt는 591 tokens이고, 이것이 조각들이 경쟁하는 예산입니다.

여기의 모든 코드는 14장 이후와 마찬가지로 TypeScript이며, 이 장은 그 규칙이 스스로의 가치를 입증하는 지점입니다. ingestion은 queue와 storage이고, search는 network call이며, 인용과 함께 prompt를 조립하는 일은 server의 일입니다. 측정은 의도적으로 같은 코드에 scoreboard를 둘러싼 것입니다. 두 번째 구현으로 점수화한 retriever는 당신이 shipping하지 않는 software에 대한 숫자일 뿐이며, 아래의 cosine threshold가 믿을 만한 이유는 production에서 실행될 chunker가 그것을 sweep하는 모습을 직접 보기 때문입니다.

corpus, 그리고 올바른 답으로 치는 것

섹션 링크: corpus, 그리고 올바른 답으로 치는 것

아래의 모든 측정은 하나의 corpus를 기준으로 합니다. 이 course의 첫 13개 장 — 13개 문서, 359,067자, 127개 섹션이며, front matter와 bibliography는 제거했습니다. prose, table, formula, code block이 들어 있는 실제 기술 corpus이고, 사람들이 knowledge base에 넣은 뒤 불평하게 되는 바로 그런 종류의 자료입니다.

ground truth는 32개 질문이며, 각 질문은 needle 하나와 짝지어져 있습니다. needle은 질문에 답하는 corpus의 짧은 원문 문장입니다. 각 needle은 359,067자 안에 정확히 한 번만 등장하고, 어떤 것도 section heading이 아닙니다. 이 확인이 중요한 이유는 heading을 모든 chunk에 복사하는 chunker라면 그렇지 않을 경우 스스로 점수를 올릴 수 있기 때문입니다. 각 질문은 두 번 묻습니다. 한 번은 course의 영어로, 한 번은 support ticket이 쓰는 표현으로 묻습니다. 즉 32개 ground truth에 대해 64개 query입니다.

반환된 chunk가 needle을 온전히 포함할 때 retrieval이 correct입니다. 이것이 generator가 필요로 하는 것과 일치하는 유일한 정의입니다. prompt 안의 반쪽 문장은 답이 아니라 위험 요소입니다.

embedding model은 all-MiniLM-L6-v2입니다. 384 dimensions, mean-pooled, normalised이며, 8장에서 측정한 contrastive 학습 model입니다. corpus를 indexing하는 데 CPU에서 20.8초, chunk당 22ms가 걸립니다. query 하나를 embedding하는 데는 13ms가 걸립니다.

여섯 방식으로 측정한 chunking

섹션 링크: 여섯 방식으로 측정한 chunking

세 가지 독립 재료에서 나온 여섯 전략입니다. Blind는 text를 보지 않고 512자마다 자릅니다. Boundaries는 paragraph 안에서는 절대 자르지 않으며, 한 paragraph가 예산을 넘을 때만 sentence boundary로 fallback합니다. Header는 각 chunk 앞에 document title과 section path를 붙입니다. Overlap은 이전 chunk의 마지막 64자를 다음 chunk에 복사합니다.

strategychunksanswers destroyedR@1R@4R@8R@20MRR
A blind 5127084 / 320.1250.2970.4220.5940.241
B blind + overlap80900.1720.3910.4530.6250.286
C boundaries94000.1560.4220.5310.6720.293
D boundaries + overlap94000.1560.3590.5160.6560.277
E boundaries + header94000.0940.4220.5780.8280.280
F boundaries + header + overlap94000.1560.3910.5620.7660.298

64개 query에서 R@20의 95% Wilson interval은 A가 [0.471, 0.705], E가 [0.718, 0.901]입니다. 둘은 겹치지 않지만 다른 대부분의 열은 겹치며, unpaired table만으로는 구분할 수 없습니다. 모든 strategy가 같은 query에 답하므로 정직한 test는 paired입니다. 한 strategy의 win과 loss를 다른 strategy와 비교해 세고, discordant pair에 sign test를 실행합니다. 세 결과만 살아남습니다.

Blind chunking은 32개 답 중 4개를 아예 파괴합니다. rank를 나쁘게 만드는 것이 아니라, 파괴합니다. needle이 512자 boundary에 걸치므로 index 안의 어떤 chunk도 그것을 포함하지 않고, 해당 query의 recall ceiling은 0입니다. 어떤 reranker도 되살리지 못하고, 어떤 threshold도 도움되지 않으며, 더 큰 model도 도움되지 않습니다. index 안 어디에도 한 조각으로 존재하지 않는 text는 retrieve할 수 없습니다. 이것은 RAG에서 가장 과소 보고되는 failure입니다. 나쁜 retriever와 정확히 똑같이 보이기 때문입니다.

Overlap은 그것만 고치고, 그 밖에는 아무것도 고치지 않습니다. overlap이 있는 모든 strategy는 답을 하나도 잃지 않습니다. overlap은 그 일을 하라고 존재합니다. 하지만 ranking을 개선하지는 않습니다. R@8에서 B는 A 대비 +8/−6, p = 0.79이고, R@20에서는 +9/−7, p = 0.80입니다. 더 나쁘게는 header 위에 overlap을 추가하면 오히려 해칩니다. R@20에서 F는 E 대비 +2/−6입니다. 이유는 기계적입니다. chunk의 vector는 token들의 평균이므로, 이전 chunk의 64자가 그 평균을 이웃 주제 쪽으로 끌고 갑니다. overlap은 split answer에 대한 보험이고, precision으로 보험료를 냅니다.

retrieval을 사 오는 것은 contextual header입니다. E는 A 대비 R@20에서 +18/−3, p = 0.0015입니다. 그리고 ablation은 boundaries가 원인이 아님을 말합니다. E와 C는 같은 cut이고 header만 다릅니다. 결과는 +12/−2, p = 0.0129입니다. paragraph 앞에 “Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?”를 붙이면 embedding model에게 그 paragraph가 무엇에 관한 것인지 알려줍니다. paragraph 자체는 그 말을 하지 않는 경우가 많습니다. 이것은 문서용 pronoun resolver입니다.

이것이 chunker의 형태와, 틀리기 쉬운 한 가지 규칙을 정합니다.

chunk.tsTS
export interface Chunked {
  /** What gets EMBEDDED: contextual header + this chunk's own content. */
  text: string;              
  /** ONLY this chunk's own content: what is quoted back to the user. */
  content: string;           
  section: string;
  /** Character range in the document's canonical text. Sliceable. */
  from: number;
  to: number;
}

export function chunkDocument(doc: string, docTitle: string, target = 512): Chunked[] {
  const out: Chunked[] = [];
  const heads = [...doc.matchAll(/^## (.+)$/gm)].map((m) => ({ at: m.index!, title: m[1].trim() }));
  const spans = heads.length
    ? heads.map((h, i) => ({ ...h, end: i + 1 < heads.length ? heads[i + 1].at : doc.length }))
    : [{ at: 0, title: "", end: doc.length }];

  for (const s of spans) {
    const header = s.title ? `${docTitle} > ${s.title}` : docTitle;     
    const skip = /^## .+\n/.exec(doc.slice(s.at, s.end))?.[0].length ?? 0;
    const body = doc.slice(s.at + skip, s.end);
    const origin = s.at + skip;

    // The offset is FOUND in the document, never accumulated: adding up
    // lengths drifts by a character wherever a separator was normalised,
    // and a citation anchor off by one points at the wrong line.
    const emit = (from: number, to: number) => {
      const raw = body.slice(from, to);
      const lead = raw.length - raw.trimStart().length;
      const content = raw.trim();
      if (!content) return;
      out.push({ text: `[${header}]\n${content}`, content, section: s.title,
                 from: origin + from + lead, to: origin + from + lead + content.length });
    };

    let open: [number, number] | null = null;
    for (const m of body.matchAll(/[^\n]([^\n]|\n(?!\n))*/g)) {          // paragraphs
      const [pf, pt] = [m.index!, m.index! + m[0].length];
      if (pt - pf > target) {                                            // one huge paragraph
        if (open) { emit(open[0], open[1]); open = null; }
        let cur: [number, number] | null = null;
        for (const sm of body.slice(pf, pt).matchAll(/[^.!?]*[.!?]*\s*/g)) {
          if (!sm[0]) continue;
          const [sf, st] = [pf + sm.index!, pf + sm.index! + sm[0].length];
          if (cur && st - cur[0] > target) { emit(cur[0], cur[1]); cur = null; }
          cur = cur ? [cur[0], st] : [sf, st];
        }
        if (cur) emit(cur[0], cur[1]);
        continue;
      }
      if (open && pt - open[0] > target) { emit(open[0], open[1]); open = null; }
      open = open ? [open[0], pt] : [pf, pt];
    }
    if (open) emit(open[0], open[1]);
  }
  return out;
}

text는 하나가 아니라 둘입니다. text는 embedding되는 text입니다. header까지 전부 포함합니다. content는 이 chunk 자신의 단어들만이며, 사용자에게 다시 인용되는 text입니다. text를 quote하면 citation은 문서의 그 위치에 존재하지 않는 header를 보여줍니다. 그리고 overlap이 있으면 이전 조각에 속한 반복된 tail도 보여줍니다. 그러면 표시된 text가 자신이 있다고 말하는 위치에 없는 것이 됩니다. 아무것도 보여주지 않는 것보다 더 나쁩니다.

header는 공짜가 아닙니다. 940개 chunk 전체에서 header는 index의 114,275 embedded tokens 중 24,213 tokens를 차지합니다. embedding 비용의 21.2%는 당신이 직접 쓴 header입니다. 또한 chunk를 encoder window에 밀어붙입니다. all-MiniLM-L6-v2는 256 word-pieces를 받습니다. strategy E는 그 선을 넘는 chunk가 17개, F는 28개이며, 모두 어떤 경고도 없이 조용히 잘립니다. effective chunk size는 config에 적힌 숫자가 아닙니다. 그 숫자와 encoder window 중 더 작은 값입니다.

모두가 건너뛰는 BM25 스무 줄

섹션 링크: 모두가 건너뛰는 BM25 스무 줄

Dense retrieval에는 하나의 구조적 약점이 있고, 미묘하지 않습니다. 의미를 맞추기 때문에 당신이 입력한 정확한 문자열에는 무관심합니다. part number, error code, acronym, surname — embedding할 만한 유용한 의미가 없고, error code의 nearest neighbour는 corpus 안의 다른 모든 error code입니다.

고전적 답은 이 모든 것보다 오래되었고 스무 줄이면 됩니다. BM25는 query의 term이 document에 얼마나 자주 나타나는지로 document를 scoring하되, term frequency가 증가할수록 각 term을 damping하고, 길이 때문에 match를 많이 쌓는 긴 document에 penalty를 줍니다.1 term tt의 contribution은 다음과 같습니다.

idf(t)ft,d(k1+1)ft,d+k1(1b+bdd)\mathrm{idf}(t)\cdot\frac{f_{t,d}\,(k_1+1)}{f_{t,d} + k_1\left(1 - b + b\,\frac{|d|}{\overline{|d|}}\right)}

여기서 ft,df_{t,d}는 document에서 term의 count, d|d|는 document length, d\overline{|d|}는 average length, k1=1.2k_1 = 1.2b=0.75b = 0.75는 두 conventional constants입니다. k1k_1는 repetition이 얼마나 빨리 도움을 멈추는지, bb는 length를 얼마나 강하게 punish할지 정합니다.

bm25.tsTS
const toks = (s: string) => s.toLowerCase().match(/[a-z0-9]+/g) ?? [];

export class BM25 {
  private tf: Map<string, number>[] = [];
  private len: number[] = [];
  private idf = new Map<string, number>();
  private avg = 0;
  private k1: number; private b: number;
  constructor(docs: string[], k1 = 1.2, b = 0.75) {
    this.k1 = k1; this.b = b;
    const df = new Map<string, number>();
    for (const d of docs) {
      const t = new Map<string, number>(); const ws = toks(d);
      for (const w of ws) t.set(w, (t.get(w) ?? 0) + 1);
      for (const w of t.keys()) df.set(w, (df.get(w) ?? 0) + 1);
      this.tf.push(t); this.len.push(ws.length);
    }
    this.avg = this.len.reduce((a, b) => a + b, 0) / this.len.length;
    const N = docs.length;
    for (const [w, n] of df) this.idf.set(w, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
  }
  scores(query: string): number[] {
    const q = toks(query);
    return this.tf.map((tf, i) => {
      const L = this.len[i]; let s = 0;
      for (const w of q) {
        const f = tf.get(w); if (!f) continue;
        s += (this.idf.get(w) ?? 0) * (f * (this.k1 + 1)) /
             (f + this.k1 * (1 - this.b + (this.b * L) / this.avg));
      }
      return s;
    });
  }
}

940개 chunk에서 query 하나를 scoring하는 데, 두 hash map 외에는 index가 전혀 없어도 1.14ms가 걸립니다. 그리고 이것은 박물관 유물이 아닙니다.

retrieverR@1R@4R@8MRRcost per query
dense (cosine)0.0940.4220.5780.280embed 13ms + scan 0.3ms
lexical (BM25)0.2190.3750.4690.3131.14ms
hybrid (RRF)0.2030.4840.6090.346둘 다
hybrid + cross-encoder0.3120.5780.7030.447+ 569ms

이 corpus에서 BM25는 dense retriever의 top-1 accuracy를 두 배 이상으로 만들고, rank 8에서는 크게 집니다. 둘은 다른 query에서 실패합니다. 이것이 둘 다 실행해야 하는 전체 논거입니다.

둘을 fuse하는 지점에서는 당연해 보이는 접근이 틀립니다. Cosine distance와 BM25 score는 같은 scale에 있지 않고, 같은 방식으로 bounded되지 않으며, query별로 normalise하면 weight가 그 query의 best hit이 우연히 얼마나 좋았는지에 의존하게 됩니다. Reciprocal rank fusion은 score를 버리고 rank만 유지합니다.2

RRF(d)=lists1k+rank(d),k=60\mathrm{RRF}(d) = \sum_{\text{lists}} \frac{1}{k + \mathrm{rank}(d)}, \qquad k = 60
retrieve.tsTS
/** Reciprocal rank fusion: ranks, not scores. Nothing to calibrate. */
export function rrf(lists: number[][], k = 60): number[] {
  const acc = new Map<number, number>();
  for (const list of lists)
    list.forEach((id, r) => acc.set(id, (acc.get(id) ?? 0) + 1 / (k + r + 1)));
  return [...acc.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
}

여기서는 표보다 표를 정직하게 읽는 일이 더 중요합니다. Hybrid는 R@4에서 BM25를 +10/−3, p = 0.09로 이깁니다. dense는 +10/−6, p = 0.45로 이깁니다. 이 corpus에서, 64개 query로는 hybrid retrieval을 dense retrieval과 구분할 수 없습니다. point estimate와 모든 recall column에서 더 낫지만, evidence는 significance에 도달하지 않습니다. 인터넷의 거의 모든 hybrid-search blog post는 위와 같은 표만 보고 interval은 보고하지 않습니다. interval이 말하는 바는 이것입니다.

bi-encoder, cross-encoder, 그리고 실제 lift가 있는 곳

섹션 링크: bi-encoder, cross-encoder, 그리고 실제 lift가 있는 곳

지금까지의 모든 것은 bi-encoder입니다. query는 model을 혼자 통과하고, 각 chunk도 몇 달 전에 혼자 통과했으며, 둘은 dot product 말고는 만난 적이 없습니다. 이것이 index를 가능하게 합니다. 한 번 embed하고 영원히 재사용합니다. 동시에 이것이 ceiling이기도 합니다. model은 query와 chunk를 함께 보지 않습니다.

cross-encoder는 정확히 그 일을 합니다. pair를 하나의 input으로 받아 relevance score를 반환합니다. 아무것도 precompute할 수 없으므로 index를 rank할 수는 없습니다. 하지만 shortlist를 rerank할 수는 있습니다. hybrid top 25를 ms-marco-MiniLM-L-6-v2로 reranking하면 R@1이 0.094(dense)에서 0.312로, MRR이 0.280에서 0.447로 이동합니다. 이 장에서 가장 큰 단일 개선이며, tail이 아니라 list의 top을 건드리는 유일한 개선입니다.

CPU에서 query당 569ms가 듭니다. BM25의 1.14ms, vector scan의 0.3ms와 비교됩니다. 25개 document에 대해 retrieval 비용의 대략 2천 배입니다. 이것이 bi-encoder/cross-encoder trade-off 전체를 한 숫자로 보여줍니다. 그래서 architecture는 항상 같은 모양입니다. 넓은 recall을 가진 저렴한 retriever, 그 다음 감당할 수 있는 shortlist 위의 비싼 scorer입니다. ColBERT는 둘 사이에 위치하며, per-token vector를 precompute하고 late interaction을 수행합니다. cross-encoder보다 싸고 dot product보다 날카롭습니다.3

L2, cosine, 그리고 아직 자격을 얻지 못한 threshold

섹션 링크: L2, cosine, 그리고 아직 자격을 얻지 못한 threshold

Vector database는 distance를 보고하며, 어떤 distance인지는 configuration option입니다. normalised vector에서는 선택이 겉모습에 가깝습니다. 그리고 이 identity는 한 번 해볼 가치가 있습니다. 이후 모든 것이 vector가 정말 unit이라는 데 의존하기 때문입니다. a=b=1\lVert a \rVert = \lVert b \rVert = 1에 대해:

ab2=a2+b22ab=22cosθ\lVert a - b \rVert^2 = \lVert a \rVert^2 + \lVert b \rVert^2 - 2\,a \cdot b = 2 - 2\cos\theta

따라서 cosine distance 1cosθ1 - \cos\theta는 정확히 d2/2d^2/2입니다. 이것이 1장의 dot product와 norm을 실제로 현금화한 것입니다. 위 index의 실제 chunk vector 두 개에서 확인하고, 이어 40,000개 pair에서 확인했습니다.

TEXT
||a|| = 1.000000   ||b|| = 1.000000
L2 = 0.795183   L2^2/2 = 0.316158   1 - cos = 0.316158   diff = 7.66e-08
max |L2^2/2 - (1 - cos)| over 200 x 200 pairs = 8.3e-07

floating-point noise 수준까지 정확합니다. 그리고 vector가 normalised되어 있기 때문에 그때만 정확합니다. normalisation을 건너뛰면 identity는 false가 되고, threshold는 아무 의미가 없으며, document가 보고하는 distance는 text 길이에 따라 달라집니다.

이제 아무도 derive하지 않는 숫자입니다. retriever는 항상 무언가를 반환합니다. 답이 corpus 어디에도 있든 없든 전체 index를 sort하고 list의 top을 건넵니다. threshold는 시스템에서 아니오라고 말할 수 있는 유일한 부분입니다. 그리고 threshold를 정하려면 아무것도 돌아오지 않아야 하는 query가 필요합니다. 여기 30개가 있습니다. 21개는 이 corpus가 실제로 다루지 않는 것들 — streaming, rate limits, prompt caching, JSON schemas, agent loops, vector databases, prompt injection, image generation — 에 관한 것이고, 9개는 paella, passport, refund policy에 관한 것입니다. 같은 index에 대해:

top-1 cosine distance
in-domain queries, all 64mean 0.445, range 0.270 – 0.721
in-domain, top-1 actually correctmean 0.370
in-domain, top-1 wrongmean 0.452
out-of-domain, all 30mean 0.699, range 0.497 – 0.867

distribution은 분리되지만, 겹칩니다. 최악의 in-domain query는 자신의 답보다 더 멀고(0.721), 최고의 out-of-domain query는 관련 없는 paragraph와 더 가깝습니다(0.497). 따라서 어떤 threshold도 둘 다 맞히지 못합니다. 실제 gate 위에서 sweep합니다. chunk는 최대 4개까지만 유지하고, cut 아래의 것만 유지합니다.

thresholdin-domain answeredof which the answer was inout-of-domain answered
0.40017 / 6460 / 30
0.45038 / 64130 / 30
0.50050 / 64191 / 30
0.52552 / 64202 / 30
0.55055 / 64213 / 30
0.60060 / 64255 / 30
0.67562 / 642710 / 30
0.80064 / 642726 / 30
none64 / 642730 / 30

마지막 열은 bluff로 읽으십시오. threshold가 없으면 assistant는 backpropagation에 관한 corpus에서 “스페인 여권을 어떻게 갱신하나요”에 대해 confident하고 well-cited한 답을 30번 중 30번 생성합니다. 0.675에서는 30번 중 10번 그렇게 합니다. 0.525에서는 두 번 그렇게 하고, 답할 수 있었던 질문 12개를 포기합니다.

그 trade-off는 product decision이며, 올바른 끝점은 잘못된 답이 당신에게 얼마의 비용을 유발하는지에 달려 있습니다. 협상할 수 없는 것은 마지막 열이 반드시 존재해야 한다는 점입니다. refuse해야 하는 질문에 대해 retriever를 측정해 본 적이 없다면, 당신에게 threshold는 없습니다. 숫자만 있을 뿐입니다.

0.675에서의 bluff 10개 중 2개는 이 실패의 두 방식을 보여줍니다.

the two shapes of a confident wrong retrievalTEXT
query: "how much does prompt caching save on a long conversation"
  [1] d=0.497  13-inference-optimization > Prefill and decode are two different machines
  [2] d=0.532  13-inference-optimization > The cache is also the bill

query: "what is the capital of france"
  [1] d=0.671  12-reasoning > The model does not think. It computes for longer.
      "…it is why 'think step by step' does nothing for what is the capital of France."

첫 번째는 near miss입니다. corpus는 KV cache를 자세히 설명합니다. query는 prompt cache에 관한 것입니다. 단어는 같은 단어이고, 0.497은 전체 실험의 대부분 correct in-domain retrieval보다 가깝습니다. embedding은 이름이 같은 두 cache가 다른 machine이라는 것을 알지 못합니다. 두 번째는 답 없는 literal match입니다. corpus에는 “what is the capital of France”라는 정확한 phrase가 들어 있습니다. 추론이 필요 없는 질문의 예로 사용된 것입니다. retriever는 맞습니다. 답이 거기에 없을 뿐입니다. “비슷한 것을 찾았다”를 “답을 찾았다”로 읽는 시스템은 그 evidence로 Paris를 단언할 것입니다. 혹은 더 나쁘게는, 단언하지 않을 것입니다.

왜 citation은 model이 쓰지 않는가

섹션 링크: 왜 citation은 model이 쓰지 않는가

model에는 사실을 위한 별도 능력이 없습니다. 참인 문장을 만드는 것과 그럴듯한 문장을 만드는 것은 같은 operation입니다. 8장의 next-token prediction입니다. 그리고 그 operation 안에는 어느 쪽인지 표시하는 것이 없습니다. 이를 새롭게 frame한 2025년 분석은 training과 evaluation pipeline이 적극적으로 guessing을 reward한다고 주장합니다. benchmark는 binary accuracy로 score하고 abstention에는 credit을 주지 않으므로, 항상 답하는 model은 모를 때 “I don’t know”라고 말하는 동일한 model보다 늘 높은 점수를 받고, post-training도 그에 맞춰 optimize됩니다.6 이런 해석에서 hallucination은 신비한 결함이 아닙니다. 틀린 답에 penalty가 없는 multiple-choice exam을 채점하면 얻는 결과입니다.

그 모양을 보십시오. contrastive sentence embeddings에 관한 paper 8개를 identifier와 함께 요청하자, Qwen2.5-0.5B-Instruct는 완벽한 format의 8줄을 만들었습니다. 8개 identifier 모두 well-formed입니다. 8개 모두 arXiv의 실제 paper로 resolve됩니다. 8개 중 0개가 주장된 paper입니다.

8 references, checked one by one against the arXiv APITEXT
claimed  arXiv:1907.06432 - Contrastive Sentence Embeddings for Text Retrieval
actual   A Neural Turing~Machine for Conditional Transition Graph Modeling

claimed  arXiv:1809.08669 - Contrastive Learning of Sentence Representations…
actual   Collapsing Superstring Conjecture

claimed  arXiv:1807.08669 - Contrastive Learning of Sentence Representations…
actual   Automatic Speech Recognition for Humanitarian Applications in Somali

이것은 작은 model이고 rate는 그 model의 것입니다. frontier model은 훨씬 덜 꾸며냅니다. 하지만 mechanism은 generalise되며, 이것이 이어지는 규칙의 이유입니다. “이 identifier가 존재하는가”를 확인하는 validator는 8개 모두를 통과시킵니다. 사용자가 클릭하면 실제 archive의 실제 page에 도착하며, mapping이 꾸며졌다는 것을 알 방법이 없습니다. failure는 identifier나 format에 있지 않습니다. association에 있습니다. 바로 language model이 plausibility로 만들어내는 것입니다.

따라서: model은 [1][2]를 쓰고, link는 절대 쓰지 않습니다. 숫자는 server가 retrieved한 fragment를 가리키며, server — 각 숫자가 정확히 어느 document와 어느 offset에서 왔는지 아는 server — 가 document, label, URL을 나중에 붙입니다. model에게는 invent할 것이 없습니다. invent할 바로 그 한 가지를 요청받지 않기 때문입니다.

prompt.tsTS
export function buildContext(question: string, hits: Scored[]) {
  const citations: Citation[] = hits.map((h, i) => ({
    index: i + 1,
    documentId: h.chunk.documentId,
    documentName: h.chunk.documentName,
    locatorLabel: label(h.chunk),
    fragment: `#char=${h.chunk.locator.flow.from},${h.chunk.locator.flow.to}`,  
    quote: h.chunk.content,          // the OWN content, never `text`
    cosineDistance: h.cosineDistance,
  }));
  const blocks = citations
    .map((c) => `[${c.index}] ${c.documentName} - ${c.locatorLabel}\n${c.quote}`)
    .join("\n\n");
  const prompt =
    `Answer using ONLY the numbered sources below. Cite every claim as [n].\n` +
    `If the sources do not contain the answer, say so and stop.\n\n` +
    `SOURCES\n${blocks}\n\nQUESTION\n${question}`;
  return { prompt, citations };
}

opening question에 실행하면 네 chunk는 591-token prompt와 model이 절대 보지 않는 table이 됩니다.

TEXT
[1] 04-classification  How many test examples do I need?      #char=28215,28701  d=0.594
[2] 04-classification  Three splits, and the leak…            #char=20329,20839  d=0.598
[3] 04-classification  How many test examples do I need?      #char=25873,26272  d=0.600
[4] 04-classification  How many test examples do I need?      #char=25554,25871  d=0.605

locator는 사람들이 건너뛰고 나중에 추가하지 못하는 부분입니다. #char=25873,26272는 document의 canonical text 안 range입니다. PDF에서 equivalent는 #page=12이고, audio나 video에서는 #t=132.4,158.9이며, spreadsheet에서는 sheet와 A1 range입니다. 이 둘은 발명이 아닙니다. #page=는 PDF Open Parameters이고 #t=는 W3C Media Fragments이며, browser가 video와 audio element에서 native로 honour합니다. locator 없는 citation은 document name이고, document name은 citation이 아닙니다. 사용자에게 가서 찾아보라고 제안하는 것입니다.

그리고 threshold를 통과하는 것이 없으면 pipeline은 model에 도달하지도 않습니다.

TEXT
NO ANSWER: nothing under cosine distance 0.675 for "what is the offside rule in football"
NO ANSWER: nothing under cosine distance 0.675 for "how do i renew my spanish passport"
NO ANSWER: nothing under cosine distance 0.675 for "how do i build an agent loop with tools"

이것은 system prompt 안의 어떤 instruction보다 싸고 reliable한 refusal입니다. probabilistic system에 대한 요청이 아니라 두 숫자의 비교이기 때문입니다.

generator와 분리해 retriever를 평가하라

섹션 링크: generator와 분리해 retriever를 평가하라

이 장의 모든 측정은 retriever를 score하며, model에게 답을 쓰라고 한 적이 한 번도 없습니다. 이는 의도적이고, 대부분의 team이 건너뛰는 부분입니다.

RAG 시스템에는 바깥에서 보면 동일해 보이는 두 failure mode가 있습니다. retriever가 passage를 찾지 못했거나, 찾았지만 generator가 무시하거나, contradict하거나, 이미 믿고 있던 것과 섞은 것입니다. final answer만 score하면 둘은 구분되지 않으므로, chunker에 있는 문제를 prompt로 tune하게 됩니다. Recall@k, MRR, answer-destroyed count에는 generation call이 전혀 필요 없고, 모든 deploy에서 실행할 만큼 저렴합니다. 이것들은 다른 scoring function을 가진 15장의 harness입니다. live conversation 대신 fixed question set 위에서 같은 request, deadline, concurrency, tally를 사용합니다.

interval과 함께 report하십시오. 4장의 arithmetic은 그대로 적용됩니다. 64개 query에서 recall 0.5는 대략 ±0.12의 95% Wilson interval을 가지므로, 다른 strategy보다 4 points 앞선 strategy는 아무것도 말해준 것이 없습니다. 두 strategy가 같은 질문에 답할 때마다 paired test를 사용하십시오. 여기서는 항상 그렇습니다. 이것이 “E가 A보다 좋아 보인다”를 p = 0.0015로 바꿨습니다.

그리고 마지막 정직함입니다. RAG는 hallucination을 줄이지만 없애지는 않습니다. prompt에 올바른 passage를 넣는다고 model이 그것을 사용해야 하는 것은 아니며, 문헌은 original paper 이후 계속 그렇게 말해 왔습니다.7 production에서는 두 가지가 이를 악화합니다. 긴 context는 degraded됩니다. model은 긴 prompt의 가운데보다 시작과 끝의 정보를 더 reliable하게 찾으므로, 4개 대신 20개 chunk를 넣으면 bill은 올리면서 accuracy는 낮출 수 있습니다. 이 효과는 24장에서 측정했습니다. 그리고 retrieval은 맞을 수 있지만 여전히 insufficient할 수 있습니다. 위의 두 cache가 보여준 것처럼요. SelfCheckGPT는 resampling에서 살아남지 못하는 claim을 flag합니다.8 Self-RAG는 model이 자체 retrieve-and-critique tokens를 emit하도록 train합니다.9 TruthfulQA는 애초에 이 failure mode를 읽을 수 있게 만들었습니다.10 어떤 것도 gap을 닫지 못합니다. retrieved text를 proof로 제시하는 시스템은 sourcedtrue를 혼동한 것입니다.

query 전에 실행되는 시스템의 절반

섹션 링크: query 전에 실행되는 시스템의 절반

retriever는 더 앞의 어둠 속에서 이미 실패가 발생하는 pipeline의 visible part입니다. 세 가지가 반복됩니다.

Extraction은 content가 죽는 곳입니다. PDF는 text가 아닙니다. drawing instructions입니다. two-column layout은 서로 섞이고, table은 word soup가 되며, page header는 모든 chunk에 반복되고, scan page에는 OCR이 confidence와 함께 text를 주기 전까지 text가 전혀 없습니다. 위에서 측정한 모든 것은 extractor가 제 역할을 했다고 가정했습니다. production에서는 그렇지 않은 경우가 많고, symptom은 세 layer 떨어진 bad retrieval로 나타납니다.

index에는 그것을 만든 model이 stamp되어 있습니다. 서로 다른 model의 embeddings는 비교할 수 없습니다. “덜 accurate”한 것이 아니라 비교 불가능합니다. 서로 다른 space의 point이기 때문입니다. embedding model을 바꾸면 store의 모든 vector는 rebuild되기 전까지 garbage입니다. 그래서 model name, dimension count, pipeline version, extractor version은 index time에 각 document 옆에 기록됩니다. 이것이 없으면 upgrade day에 어떤 document가 stale이고 어떤 document가 current인지 알 수 없으며, half-migrated index는 어디에도 error를 내지 않은 채 confident nonsense를 반환합니다.

broken document 하나가 folder를 망가뜨리면 안 되며, counter는 실제로 일어난 일을 count해야 합니다. extraction에 실패한 document는 reason과 함께 failed state로 끝나야 하며, visible하고 retryable해야 합니다. 다른 99개는 searchable 상태를 유지합니다. 그리고 indexed chunk 수는 upload할 때 client가 선언하는 것이 아니라, server가 끝났을 때 써야 합니다. 400개 fragment를 report하지만 실제로는 40개만 들고 있는 folder는, 답할 수 없는 질문으로만 드러나는 거짓말입니다.

이 장의 시스템은 답이 문서에 적혀 있는 질문에 답합니다. 그것을 retrieve하고, rank하고, 불가능하면 refuse하며, 어디를 보았는지 cite합니다. 사람들이 자신의 문서 위에서 assistant에게 원하는 대부분이 이것입니다. 그리고 이것은 한 가지 구체적인 방식으로 bounded되어 있습니다. retrieval은 누군가 쓴 것만 반환할 수 있습니다.

그러면 나머지 절반이 남습니다. model에게 시키고 싶은 일 중 일부는 문서 속 fact가 전혀 아닙니다. 유지해야 하는 format, tone, 400개 label을 가진 taxonomy, 과거 예시 1만 개 안에는 있지만 어느 paragraph에도 없는 판단 방식입니다. retrieval은 그것들을 전달할 수 없습니다. retrieve할 것이 없기 때문입니다. 더 긴 prompt는 skill 대신 skill의 description에 대해 16장의 bill만 지불합니다.

20장은 그 결정 — fine-tune, retrieve, or prompt — 이며, 그 결론은 이 결정이 기술적이기 전에 경제적이라는 것입니다. 세 가지를 같은 질문 위에서 end to end로 pricing하고, crossover는 token count입니다. 그 장을 여는 질문은 이 장이 답할 수 없는 질문입니다. 답이 어디에 쓰여 있는가가 아니라, 애초에 쓰인 적이 없다면 무엇을 할 것인가입니다.


이 장에서 측정한 모든 것은 하나의 corpus와 하나의 instrument를 사용했으며, 둘 다 reproducible합니다. corpus는 2026년 9월 7일 기준 이 course의 1장부터 13장입니다. 13개 document, 359,067자, 127개 section이며, front matter와 bibliography는 제거했습니다. 이 장들은 계속 편집되므로 오늘 같은 rule을 적용하면 몇천 자 더 많아집니다. section count는 변하지 않고 아래 모든 conclusion도 같습니다. 하지만 character total은 snapshot이며 그렇게 label되어 있습니다. ground truth는 32개 질문이고, 각 질문은 corpus에 정확히 한 번 나타나며 section heading이 아닌 원문 문장과 paired됩니다. 두 phrasing으로 물어 64개 query가 됩니다. Retrieval embeddings는 sentence-transformers/all-MiniLM-L6-v2(384 dimensions, mean-pooled, L2-normalised, 256-token window)입니다. reranking은 top 25 위의 cross-encoder/ms-marco-MiniLM-L-6-v2이고, generation example은 greedy decoding의 Qwen/Qwen2.5-0.5B-Instruct입니다. 모든 timing은 single-threaded CPU입니다. 이 장을 만들기 위해 paid API는 호출하지 않았습니다. 그래서 여기의 모든 latency는 local latency이며 그렇게 label되어 있습니다.

TypeScript로 보인 chunker는 측정된 chunker입니다. 같은 rule과 ts/chunk.ts를 구현한 Python instrument를 전체 corpus에서 chunk by chunk로 비교했으며, 940개 chunk 전체에서 text와 offset까지 모두 일치합니다. interval은 95% Wilson이고, paired comparison은 discordant pair에 대한 two-sided exact sign test입니다.

위에서 cite한 14개 identifier는 모두 arXiv API에 대해 resolve했고 2026년 9월 7일 title by title로 확인했습니다. 그렇지 않았던 8개를 생각하면, 이 particular chapter가 할 수 있는 최소한은 그것처럼 보였습니다.

  1. Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). 위에서 사용한 saturation function과 두 constant의 출처이며, 애초에 bb가 왜 존재하는지 읽을 곳입니다.

  2. Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. k=60k = 60는 이들의 것이며, method의 핵심은 fuse하는 score scale 사이의 calibration이 필요 없다는 점입니다.

  3. Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). dot product와 cross-encoder 사이의 중간 지대입니다. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019)는 이 장의 index가 기반으로 삼는 bi-encoder이며 8장에서 측정했습니다.

  4. Malkov, Yu. A. and Yashunin, D. A. Efficient and Robust Approximate Nearest Neighbor Search using Hierarchical Navigable Small World Graphs. arXiv:1603.09320 (2016). 현재 판매되는 대부분의 vector database 뒤에 있는 graph index입니다.

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS이며, 위 box에서 측정한 IVF의 reference implementation입니다.

  6. Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). hallucination이 abstention을 reward하지 않는 binary-accuracy grading에서 생성되므로 modelling 문제이기 전에 evaluation 문제라는 논거입니다.

  7. Lewis, P., Perez, E., Piktus, A., Petroni, F., Karpukhin, V., Goyal, N., Küttler, H., Lewis, M., Yih, W., Rocktäschel, T., Riedel, S. and Kiela, D. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. arXiv:2005.11401 (2020). 이 pattern에 이름을 붙인 paper이며, 이것이 무엇을 고치고 무엇을 고치지 못하는지 읽어야 할 글입니다. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020)은 retriever를 model에 bolt-on하지 않고 함께 train한 동시대 연구입니다. Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020)은 이 장 전반에서 쓰는 two-encoder dense retriever의 출처입니다. Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020)은 많은 passage를 하나의 generator에 넣는 fusion-in-decoder 구성입니다. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023)은 그 이후의 모든 것을 그린 지도이며, 질문 대신 hypothetical answer를 embed하는 HyDE(Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022)를 포함합니다.

  8. Manakul, P., Liusie, A. and Gales, M. J. F. SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models. arXiv:2303.08896 (2023). model 내부 접근도 external knowledge base도 없이 resampling으로 detection합니다.

  9. Asai, A., Wu, Z., Wang, Y., Sil, A. and Hajishirzi, H. Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection. arXiv:2310.11511 (2023). 매 turn마다 retrieve하는 대신 언제 retrieve할지 model이 결정하도록 train합니다.

  10. Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). plausible answer와 true answer가 다른 질문들로 만든 benchmark이며, 이 어려움 전체를 한 문장으로 보여줍니다.


제작자

David Vicente Campos

NeuraLIA Labs 창립자 & MyRealFood 공동 창립자

저는 레온 대학교 출신의 컴퓨터 엔지니어입니다. MyRealFood를 공동 창업해 수백만 명이 더 건강하게 먹기 위해 사용해 온 앱을 CTO로서 만들었고, NeuraLIA Labs를 설립해 그곳에서 AI 제품을 만들고 있습니다. 여기서는 제가 그 과정에서 이해해야 했던 것들에 대해, 누군가 제게 설명해줬으면 했던 방식으로 쓰고 있습니다.

저자 더 알아보기

NeuraLIA Labs에서 발행합니다.

새 글을 받은편지함에서 받아보세요

AI 뉴스, 가이드, 제품 업데이트 — 읽을 만한 소식이 있을 때 짧은 이메일로 보내드려요.

메시지가 편하다면 같은 글을 여기서도 받아보세요:WhatsApp 커뮤니티 (새 탭에서 열림)Telegram 채널 (새 탭에서 열림)

강좌 목차

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev13분 읽기

Jev AI 모델은 글쓰기가 아니라 결정을 위해 만들어졌다

TypeSafe AI의 Jev가 주목받는 이유는 소프트웨어 지능을 확률 문제로 다루기 때문입니다. 올바른 분기를 선택하고, 신뢰도를 붙이며, 코드에 필요한 것이 결정일 때 LLM에 텍스트 작성을 맡기는 비용을 피합니다.

이제 모델 선택은 LIA에게 맡기세요

모든 AI 모델을 한곳에서. 오늘 무료로 시작하세요.