Lesson 6.3.3 · 70 min

RAG over reports, prospectuses and KIDs, and how to evaluate it

A financial RAG system is not judged by how convincing it sounds, but by four measurable things: whether the answer comes from the passages, whether it cites the right ones, whether it retrieves everything needed and whether it stays silent when the document does not say. This lesson builds the complete system with a local 0.5B-parameter LLM, evaluates it against questions with gold answers and compares it with an extractive baseline that generates nothing.

Fig. — RAG with the local 0.5B LLM (circles) versus the extractive baseline (squares) on a stratified sample of 12 questions: shares with their 95% Wilson interval; number of questions counted in brackets
Level 6 lessons
  1. 6.1 · Classical and modern financial NLP

    1. 6.1.1 Tokenization, embeddings, financial dictionaries and FinBERT
    2. 6.1.2 Text sources: EDGAR, CNMV, transcripts and social media
    3. 6.1.3 Event studies with text signals, timestamp alignment and topic modeling
  2. 6.2 · LLMs for research and trading

    1. 6.2.1 How an LLM works: from the next token to the API
    2. 6.2.2 Structured prompting: JSON you can validate
    3. 6.2.3 Guidance, KPIs and tone: what an LLM extracts well
    4. 6.2.4 Lightweight fine-tuning for financial tasks: LoRA and QLoRA
    5. 6.2.5 Look-ahead bias in LLMs: the model that already knows the future
  3. 6.3 · Financial RAG

    1. 6.3.1 Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant
    2. 6.3.2 Chunking financial documents: tables, footnotes and XBRL
    3. 6.3.3 RAG over reports, prospectuses and KIDs, and how to evaluate it
  4. 6.4 · AI agents for trading

    1. 6.4.1 Tool-calling agents: tool calling, ReAct and planning
    2. 6.4.2 Agent frameworks and MCP
    3. 6.4.3 TradingAgents-style multi-agent architectures
    4. 6.4.4 Evidence and limits of agents
    5. 6.4.5 Guardrails for agents: the agent proposes, the engine decides
    6. 6.4.6 Research agents: hypotheses, alpha mining and review
  5. 6.5 · AI as the quant's copilot

    1. 6.5.1 Coding assistants with a test-first workflow
    2. 6.5.2 Automated review of backtests and code migrations
    3. 6.5.3 Risks of AI-generated code
  6. 6.6 · Big data for trading

    1. 6.6.1 When data becomes “big”: ticks, order books, options and universes
    2. 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
    3. 6.6.3 Dask, Spark and Ray: from one machine to a cluster
    4. 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
    5. 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
  7. 6.7 · Alternative data

    1. 6.7.1 Overview and size of the alternative data market
    2. 6.7.2 Types of alternative data: what each one measures
    3. 6.7.3 Responsible web scraping
    4. 6.7.4 Alternative data vendors and due diligence
    5. 6.7.5 From alternative data to a trading signal
    6. 6.7.6 Satellite imagery, computer vision and supply chains

From the retrieved chunk to the cited answer

Lesson 6.3.2 left the retriever measured: structure-aware chunks with a context header and BM25 retrieve 87.3% of the gold passages with the top three chunks. This lesson adds the other half, the model that reads those chunks and writes, and above all the evaluation of the whole. The documents are the same: annual reports, ETF prospectuses and KIDs, and research notes, all synthetic, with 263 questions of which 17 have no answer in the documents. The example corpus is in Spanish (documents, questions and the prompt the model receives), so the fragments quoted below carry an English gloss in parentheses.

QuestionBM25 top-3Numbered contextLLM or extractionAnswer and citationsAccuracyFaithfulnessCitationCoverageAbstention
Mermaid source
flowchart TB
  Q[Question] --> B[BM25 top-3]
  B --> C[Numbered context]
  C --> L[LLM or extraction]
  L --> R[Answer and citations]
  R --> E1[Accuracy]
  R --> E2[Faithfulness]
  R --> E3[Citation]
  B --> E4[Coverage]
  R --> E5[Abstention]

A RAG system can fail at every link, and a single overall score (“70% of the answers are right”) does not say where. That is why five things are measured separately:

MeasureQuestion it answersHow it is computed here
CoverageDid the retriever bring the passages needed?Gold passages covered (≥ 50%) by the 3 chunks, by offsets
AccuracyIs the answer the gold one?Gold figures present and no foreign figure; without figures, keywords
FaithfulnessDoes what it says come from the context?Every figure in the answer is in the context delivered
CitationDoes it cite the right fragments?Cited fragments that cover a gold passage, over those cited
AbstentionDoes it stay silent when it is not stated?“No consta” (not stated) on the 17 unanswerable questions, and not on the answerable ones

All five are deterministic: there is no judge model. In financial documents almost all answers are figures, and a figure can be normalized (“13.668,9”, “13668.9”) and looked up. Free-text claims need something more (an LLM judge or a metric like Ragas faithfulness, which breaks the answer into claims and checks each one against the context); this is discussed at the end.

The retriever, in compact form

Structure-aware chunking and BM25 are those of lesson 6.3.2, in a single cell: blocks separated by blank lines, whole tables with their footnotes attached, paragraphs of the same section merged up to 800 characters and a “document > section” header in the indexed text.

IN [01]python
import re
import unicodedata

import numpy as np
import pandas as pd
from rank_bm25 import BM25Okapi

docs = pd.read_parquet("rag6_documentos.parquet")
pas = pd.read_parquet("rag6_pasajes.parquet")
preg = pd.read_csv("rag6_preguntas.csv")
TEXTO = dict(zip(docs.id_doc, docs.texto))
TITULO = dict(zip(docs.id_doc, docs.titulo))
P = pas.set_index("id_pasaje")
NOTA = "¹²³⁴⁵⁶⁷⁸⁹"


def seccion(doc, i):
    hs = re.findall(r"^##+ (.*)$", TEXTO[doc][:i + 1], re.M)
    return hs[-1] if hs else ""


def trocear(doc, n=800):
    t = TEXTO[doc]
    bl = [(m.start(), m.end())
          for m in re.finditer(r"[^\n](?:.|\n(?!\n))*", t)]
    notas = {t[a]: (a, b) for a, b in bl if t[a] in NOTA}
    trozos = []
    for a, b in bl:
        s = t[a:b]
        if s.startswith("#") or s[0] in NOTA:
            continue
        marcas = [notas[c] for c in dict.fromkeys(s) if c in notas]
        fija = s.startswith("|") or bool(marcas)
        ult = trozos[-1] if trozos else None
        if (ult and not fija and not ult["fija"]
                and seccion(doc, a) == seccion(doc, ult["tramos"][0][0])
                and b - ult["tramos"][0][0] <= n):
            ult["tramos"][0] = (ult["tramos"][0][0], b)
            continue
        trozos.append(dict(doc=doc, tramos=[(a, b)] + marcas,
                           fija=fija))
    for c in trozos:
        cuerpo = "\n\n".join(t[i:f] for i, f in c["tramos"])
        sec = seccion(doc, c["tramos"][0][0])
        c["texto"] = f"{TITULO[doc]} > {sec}\n\n{cuerpo}"
    return trozos


VACIAS = set("""de la el en y a los las del que se por con un una
para es al lo su sus o como fue cual cuanto cuantos cuales
segun""".split())


def palabras(s):
    s = unicodedata.normalize("NFKD", s.lower())
    s = "".join(ch for ch in s if not unicodedata.combining(ch))
    return [w for w in re.findall(r"\w+", s) if w not in VACIAS]


def cubierta(trozos, pid):
    """Fraction of the passage's characters inside the chunks."""
    r = P.loc[pid]
    iv = sorted((max(i, r.inicio), min(f, r.fin)) for c in trozos
                if c["doc"] == r.id_doc for i, f in c["tramos"])
    total, hasta = 0, r.inicio
    for a, b in iv:
        a = max(a, hasta)
        if b > a:
            total, hasta = total + b - a, b
    return total / (r.fin - r.inicio)


TR = [c for d in TEXTO for c in trocear(d)]
bm = BM25Okapi([palabras(c["texto"]) for c in TR])


def buscar(pregunta, k=3):
    sc = bm.get_scores(palabras(pregunta))
    orden = np.argsort(-sc, kind="stable")[:k]
    return [TR[j] for j in orden], float(sc[orden[0]])


print(len(TR), "chunks;", len(preg), "questions,",
      int((~preg.respondible).sum()), "unanswerable")
OUT [01]Structure-aware chunks and BM25 (from lesson 6.3.2)
376 chunks; 263 questions, 17 unanswerable

Accuracy, faithfulness and citation, in code

All the measures rest on normalizing figures. The documents use the Spanish number format (“13.668,9”, “2,2 %”), the model sometimes the English one (“40.3%”), and years (“2023”) are not answer figures but references from the question. The rule: with a comma, the period is a thousands separator; without a comma, a period followed by exactly three digits is one too; numbers in square brackets are citations and are removed first.

IN [02]python
NUM = re.compile(r"\d[\d.,]*\d|\d")
CITA = re.compile(r"\[(\d+)\]")


def cifras(s):
    """Set of figures in a text, without the [n] citations."""
    out = set()
    for t in NUM.findall(CITA.sub(" ", s)):
        if "," in t:
            t = t.replace(".", "").replace(",", ".")
        elif re.fullmatch(r"\d{1,3}(\.\d{3})+", t):
            t = t.replace(".", "")
        out.add(round(float(t.rstrip(".")), 4))
    return out


def calla(resp):
    return "no consta" in resp.lower()


def exacta(resp, q):
    """Gold figures present and no foreign ones (except those of the
    question); with no figures in the gold, its words."""
    if not q.respondible:
        return calla(resp)
    if calla(resp):
        return False
    oro, dichas = cifras(q.respuesta_oro), cifras(resp)
    if oro:
        return oro <= dichas and dichas <= oro | cifras(q.pregunta)
    return set(palabras(q.respuesta_oro)) <= set(palabras(resp))


def contiene(resp, q):
    """Lenient version: the gold figures (or words) are inside."""
    oro = cifras(q.respuesta_oro)
    if oro:
        return oro <= cifras(resp)
    return set(palabras(q.respuesta_oro)) <= set(palabras(resp))


def fiel(resp, contexto, q):
    """Every figure in the answer is in the context or the question."""
    return cifras(resp) <= cifras(contexto) | cifras(q.pregunta)


def citas_correctas(citados, q):
    """Fraction of cited chunks that cover a gold passage."""
    if not citados or not q.respondible:
        return np.nan
    oro = q.pasajes_oro.split(";")
    return np.mean([any(cubierta([c], p) >= 0.5 for p in oro)
                    for c in citados])


print(sorted(cifras("Ventas de 13.668,9 millones, un 2,2 % más [2]")))
print(sorted(cifras("40.3% de 5.505 en 2023")))
OUT [02]Normalized figures and the five measures
[2.2, 13668.9]
[40.3, 2023.0, 5505.0]

The extractive baseline, with all 263 questions

Before bringing in an LLM it is worth knowing how far you get without one. The extractive baseline generates nothing: it returns the best retrieved chunk (and cites fragment 1), or, shorter, the line of that chunk that shares the most words with the question. It is what a search engine with highlighting would do. Since it does not generate, it cannot invent figures (its faithfulness is total by construction), and it can be evaluated on every question in a second.

IN [03]python
def mejor_linea(trozo, pregunta):
    cuerpo = trozo["texto"].split("\n\n", 1)[1]
    lineas = [x for x in re.split(r"\n|(?<=\.) ", cuerpo) if x.strip()]
    qp = set(palabras(pregunta))
    return max(lineas, key=lambda x: len(qp & set(palabras(x))))


base = []
for q in preg.itertuples():
    top, s1 = buscar(q.pregunta)
    oro = q.pasajes_oro.split(";") if q.respondible else []
    cob = (np.mean([cubierta(top, p) >= 0.5 for p in oro])
           if oro else np.nan)
    trozo = top[0]["texto"].split("\n\n", 1)[1]
    linea = mejor_linea(top[0], q.pregunta)
    base.append(dict(
        id=q.id_pregunta, tipo=q.tipo_pregunta, resp=q.respondible,
        score=s1, cobertura=cob,
        trozo=contiene(trozo, q) if q.respondible else np.nan,
        linea=contiene(linea, q) if q.respondible else np.nan,
        linea_exacta=exacta(linea, q) if q.respondible else np.nan,
        cita=citas_correctas(top[:1], q)))
base = pd.DataFrame(base)
r = base[base.resp]
print(r[["cobertura", "trozo", "linea", "linea_exacta", "cita"]]
      .astype(float).mean().round(3).to_dict())
cols = ["cobertura", "trozo", "linea", "cita"]
r.groupby("tipo")[cols].mean().round(2)
OUT [03]Extractive baseline on the 263 questions
{'cobertura': 0.873, 'trozo': 0.772, 'linea': 0.545, 'linea_exacta': 0.382, 'cita': 0.707}
              cobertura     trozo     linea  cita
tipo
calculo            0.00       0.0       0.0  0.00
comparacion        0.11       0.0       0.0  0.14
nota_pie           0.75       1.0       0.5  0.50
tabla              0.88  0.671875  0.203125  0.67
tabla_y_nota       1.00  0.923077  0.384615  0.54
texto              0.99  0.844262  0.754098  0.81
texto_y_nota       0.75       1.0       1.0  1.00

The retriever brings the gold passages in 87.3% of cases (the coverage with k = 3 from lesson 6.3.2). The best chunk (trozo) contains the gold figures or words in 77.2% of the 246 answerable questions; the best line (linea), only in 54.5%, and it is exactly the answer, with no extra figures, in 38.2%. The citation of fragment 1 (cita) is correct in 70.7%. By type, text and footnotes do well with the whole chunk. Tables suffer with the line (20.3%): the row that shares the most words with the question is not always the one with the figure, and a lone row loses the column header. Calculation and comparison stay at zero: the answer is not written in any chunk, it has to be computed or documents have to be cross-checked, and an extraction does neither.

What the baseline cannot do is abstain. Its only signal is the BM25 score of the best chunk: if it is low, maybe the question has no answer. The next figure shows why it is a poor signal.

IN [04]python
from sklearn.metrics import roc_auc_score

print(base.groupby("resp").score.describe()[["mean", "50%", "min",
                                             "max"]].round(2))
print("AUC:", round(roc_auc_score(~base.resp, -base.score), 3))
u = base[~base.resp].score.median()        # abstains on half
calla_bien = (~base.resp & (base.score < u)).sum()
calla_mal = (base.resp & (base.score < u)).sum()
print(f"threshold {u:.2f}: abstains on {calla_bien} of 17 unanswerable"
      f" and on {calla_mal} of 246 answerable")
OUT [04]Does the BM25 score separate the unanswerable questions?
        mean    50%   min    max
resp
False  10.95   9.39  8.39  16.16
True   14.72  13.86  7.08  27.67
AUC: 0.742
threshold 9.39: abstains on 8 of 17 unanswerable and on 16 of 246 answerable
Fig. — BM25 score of the top chunk (histograms as a share of each group) for the 246 answerable and 17 unanswerable questions, and the threshold at the unanswerable median that the extractive baseline uses to abstain

Unanswerable questions score lower on average (10.95 versus 14.72), but the distributions overlap: the AUC of the score as a detector is 0.742 (0.5 would be chance). With the threshold at the median of the unanswerable questions (9.39), the baseline abstains on 8 of the 17 and, in exchange, on 16 of the 246 answerable ones. It makes sense: the unanswerable questions in the set name the company, the fund and the year just like the others (“¿cuál es el rating crediticio de Busmar Hogar según su informe anual de 2024?”, what is Busmar Hogar's credit rating according to its 2024 annual report?), so BM25 finds very similar chunks. Knowing that the rating is not there requires reading the context, which is exactly what an LLM is asked to do.

The local LLM: generation with citations on a sample

The generator is Qwen2.5-0.5B-Instruct (Apache-2.0), the small model of the ia environment (lesson 6.2.1): 0.49 billion parameters, on CPU, with greedy (deterministic) decoding. It generates a few tokens per second, so it is not evaluated on all 263 questions: a stratified sample of 12 is taken (three text, three table, one footnote, one text and note, one table and note and three unanswerable), with a fixed seed. With 12 questions, any percentage has a confidence interval of about ±25 points: the sample serves to see how the model fails, not to estimate precisely how much.

The prompt follows what worked best with this model in earlier tests: short instructions in the system message, an example answer with a citation and the explicit instruction to answer “No consta” (not stated) if the context does not say. Like the corpus, the prompt is in Spanish and asks for the answer in Spanish. The context is the three numbered chunks [1], [2], [3].

IN [05]python
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
CUOTA = {"texto": 3, "tabla": 3, "nota_pie": 1, "texto_y_nota": 1,
         "tabla_y_nota": 1, "no_respondible": 3}
muestra = pd.concat([preg[preg.tipo_pregunta == t]
                     .sample(n, random_state=0)
                     for t, n in CUOTA.items()])

SISTEMA = (
    "Eres un analista financiero. Responde en español con una frase "
    "corta, usando solo los fragmentos. Termina la frase con el número "
    "del fragmento que la apoya entre corchetes. Si los fragmentos no "
    "contienen la respuesta, responde exactamente: No consta.\n\n"
    "Ejemplo:\n[1] La plantilla media fue de 1.200 personas.\n"
    "[2] El consejo tiene 9 miembros.\n"
    "Pregunta: ¿Cuántos miembros tiene el consejo?\n"
    "Respuesta: El consejo tiene 9 miembros [2].")


def mensajes(pregunta, top):
    ctx = "\n\n".join(f"[{i + 1}] {c['texto']}"
                      for i, c in enumerate(top))
    return [{"role": "system", "content": SISTEMA},
            {"role": "user",
             "content": f"{ctx}\n\nPregunta: {pregunta}\nRespuesta:"}]


modelo = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(modelo)
llm = AutoModelForCausalLM.from_pretrained(modelo, dtype=torch.float32)
print(muestra.tipo_pregunta.value_counts().to_dict())
OUT [05]Stratified sample and prompt with numbered context
{'texto': 3, 'tabla': 3, 'no_respondible': 3, 'nota_pie': 1, 'texto_y_nota': 1, 'tabla_y_nota': 1}
IN [06]python
filas = []
for q in muestra.itertuples():
    top, _ = buscar(q.pregunta)
    e = tok.apply_chat_template(mensajes(q.pregunta, top),
                                add_generation_prompt=True,
                                return_tensors="pt", return_dict=True)
    with torch.inference_mode():
        out = llm.generate(**e, max_new_tokens=40, do_sample=False,
                           pad_token_id=tok.eos_token_id)
    n = e["input_ids"].shape[1]
    resp = tok.decode(out[0, n:], skip_special_tokens=True).strip()
    citados = [top[int(i) - 1] for i in CITA.findall(resp)
               if 1 <= int(i) <= len(top)]
    contexto = "\n".join(c["texto"] for c in top)
    oro = q.pasajes_oro.split(";") if q.respondible else []
    filas.append(dict(
        id=q.id_pregunta, tipo=q.tipo_pregunta, resp=q.respondible,
        tokens=n, respuesta=resp.replace("\n", " ")[:60],
        exacta=exacta(resp, q), contiene=contiene(resp, q),
        fiel=fiel(resp, contexto, q) if not calla(resp) else np.nan,
        cita=citas_correctas(citados, q), cita_alguna=bool(citados),
        cobertura=(np.mean([cubierta(top, p) >= 0.5 for p in oro])
                   if oro else np.nan)))
llmr = pd.DataFrame(filas)
llmr[["id", "tipo", "respuesta", "exacta", "fiel", "cita"]]
OUT [06]RAG with the local LLM on the 12 questions
       id            tipo                                          respuesta  exacta   fiel  cita
0   QA196           texto                                            [2] Yes   False   True   0.0
1   QA217           texto                                        704 valores    True   True   NaN
2   QA026           texto                                         [1]. 879,1    True   True   1.0
3   QA165           tabla        -869,8 -181,2 = -1,047 millones de USD [3].   False  False   0.0
4   QA105           tabla                                              20,5%    True   True   NaN
5   QA160           tabla                                      1.143,821 USD   False  False   NaN
6   QA015        nota_pie                                         No consta.   False    NaN   NaN
7   QA007    texto_y_nota  1,01 millones de retribución variable⁵, ligada...    True   True   0.0
8   QA151    tabla_y_nota  [1]. Zaptec Capital, S.A. — Informe anual 2023...   False   True   0.5
9   QA044  no_respondible                                         No consta.    True    NaN   NaN
10  QA154  no_respondible                                         No consta.    True    NaN   NaN
11  QA191  no_respondible                                       0,20 % anual   False   True   NaN

The retriever covers all the gold passages in the sample (100% coverage), so every failure belongs to the generator. Six answers are exact (exacta): 704 securities, 879.1 million, 20.5%, 1.01 million and two “No consta” on unanswerable questions. The failures are a catalog of what a small model does:

  • Language and form. To “¿Reparte dividendos la clase A…?” (does class A pay dividends…?) it answers “[2] Yes”: in English, although the prompt asks for Spanish, without the “semestral” (semiannual) of the gold answer and citing a fragment that is not the gold one.
  • Invented arithmetic. For dividends paid (181.2 million) it adds two figures from the cash flow statement and gives “−1,047”: the correct figure appears, but inside an operation nobody asked for. Faithfulness (fiel) detects it (the result is not in the context).
  • Figures that do not exist. Zaptec's market capitalization at the end of 2024 (1,472.9 million) comes out as “1.143,821 USD”, a figure that is not in any fragment.
  • Confusing “no” with “not stated”. To “¿Incluye la deuda financiera neta los pasivos por arrendamiento?” (does net financial debt include lease liabilities?; gold: no) it answers “No consta” (not stated). A negative answer is not an abstention, and the model mixes them up.
  • Copying the header. On the question about scope 2 emissions it repeats the fragment's header and uses up the 40 tokens without getting to the figure.
  • Answering what was not asked. Asked for the TER of a pound-hedged share class that does not exist, it gives “0,20 % anual” (0.20% a year): the TER of the class that does exist. It is a figure from the context, so numerical faithfulness accepts it. Faithfulness checks that what is said is in the context, not that it answers the question.

Numerical accuracy also has its blind spot: in the variable pay question an answer that gives the figure (1.01 million) counts as exact even though the gold also asks what it includes. Deterministic metrics are cheap and reproducible, but they measure what they measure.

The sample, compared: LLM versus extraction

The fair comparison is on the same 12 questions. The line baseline has to decide when to abstain: it is given the BM25 threshold rule from the previous section, the median of the scores of the unanswerable questions.

IN [07]python
bm_ = base.set_index("id").loc[muestra.id_pregunta].reset_index()
calla_base = bm_.score < u
exacta_base = np.where(bm_.resp, ~calla_base & (bm_.linea_exacta == 1),
                       calla_base)


def ic(x):
    """Proportion with its 95% Wilson interval."""
    x = np.asarray(x, float)
    x = x[~np.isnan(x)]
    n, p, z = len(x), x.mean(), 1.96
    c = (p + z**2 / (2 * n)) / (1 + z**2 / n)
    h = (z * np.sqrt(p * (1 - p) / n + z**2 / (4 * n**2))
         / (1 + z**2 / n))
    return f"{p:.2f} [{c - h:.2f}, {c + h:.2f}] n={n}"


r_llm, r_base = llmr[llmr.resp], bm_[bm_.resp]
tabla = pd.DataFrame({
    "LLM 0.5B": [ic(llmr.exacta), ic(r_llm.contiene), "-",
                 ic(r_llm.fiel), ic(r_llm.cita), ic(r_llm.cita_alguna),
                 ic(llmr[~llmr.resp].exacta)],
    "extraction": [ic(exacta_base), ic(r_base.linea), ic(r_base.trozo),
                   "1.00 (no generation)", ic(r_base.cita),
                   "1.00 (always [1])", ic(calla_base[~bm_.resp])]},
    index=["exact (12)", "contains the gold", "  (whole chunk)",
           "faithful to context", "correct citations", "cites something",
           "abstains if not stated"])
print("retriever coverage on the sample:",
      round(r_llm.cobertura.mean(), 3))
tabla
OUT [07]The five measures on the sample, with their uncertainty
retriever coverage on the sample: 1.0
                                      LLM 0.5B              extraction
exact (12)              0.50 [0.25, 0.75] n=12  0.25 [0.09, 0.53] n=12
contains the gold        0.67 [0.35, 0.88] n=9   0.22 [0.06, 0.55] n=9
  (whole chunk)                              -   0.78 [0.45, 0.94] n=9
faithful to context      0.75 [0.41, 0.93] n=8    1.00 (no generation)
correct citations        0.30 [0.07, 0.70] n=5   0.78 [0.45, 0.94] n=9
cites something          0.56 [0.27, 0.81] n=9       1.00 (always [1])
abstains if not stated   0.67 [0.21, 0.94] n=3   0.67 [0.21, 0.94] n=3

Read with its intervals, the table says little with certainty and quite a lot qualitatively:

  • Accuracy. The LLM gives the exact answer in 6 of 12 questions and line extraction (with the threshold for abstaining) in 3 of 12. The 95% Wilson intervals ([0.25, 0.75] and [0.09, 0.53]) overlap: with 12 questions it cannot be claimed that the LLM is better.
  • Content versus form. The whole chunk contains the gold in 7 of the 9 answerable questions, more than the LLM's answer (6 of 9). For an analyst who is going to read the evidence, the highlighted chunk is as useful as the generated sentence and invents nothing.
  • Faithfulness and citations. The LLM invents figures in 2 of 8 answers with content, cites something in only 5 of 9 and, when it does cite, the mean precision of its citations is 0.30. Extraction always cites fragment 1, and it is correct in 7 of 9.
  • Abstention. A tie at 2 of 3 on the unanswerable questions, but for different reasons: the BM25 threshold abstains by score and the LLM by reading (and it gets one answerable question wrong).

The honest conclusion: with a 0.5B model, generative RAG adds no measurable accuracy over an extraction with highlighting, and it adds errors of its own (invented figures, missing citations, language). A larger model changes the numbers, and that is what this evaluation makes it possible to measure: the same pipeline, with another generator, against the same gold. The lesson's header figure summarizes the table with its intervals.

Citations verifiable by offsets

Asking the model to “end with the fragment number in square brackets” is fragile: the model can omit the citation, cite a fragment it did not use or return only the number. Commercial APIs offer something better. In Anthropic's, a document sent with "citations": {"enabled": true} makes the answer arrive split into text blocks, with the cited blocks carrying a citations list; for a plain-text document, each citation is a char_location with the cited text, the document index and the offsets start_char_index (from 0) and end_char_index (exclusive) within the text sent1Note 1 · Anthropic, “Citations”, Messages API documentation, accessed on October 23, 2026: plain-text documents chunked into sentences, char_location citations with 0-based character indexes and an exclusive end, and an answer in several text blocks with their citations list.. That is exactly what is needed to verify in code: if the document text is sent as is, the citation offsets are those of rag6_pasajes.

The real code, with the Anthropic API (not run: it needs a key and network access):

IN [08]python
import anthropic

cliente = anthropic.Anthropic()          # ANTHROPIC_API_KEY from the environment
doc = "R10"
respuesta = cliente.messages.create(
    model="claude-opus-5-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": [
        {"type": "document",
         "source": {"type": "text", "media_type": "text/plain",
                    "data": TEXTO[doc]},
         "title": TITULO[doc],
         "citations": {"enabled": True}},
        {"type": "text",
         "text": "¿Cuál es el indicador resumido de riesgo?"}]}])
for bloque in respuesta.content:
    for c in getattr(bloque, "citations", None) or []:
        print(c.start_char_index, c.end_char_index, c.cited_text)

To run the same code without network access, the official client is pointed at a local simulator (httpx2.MockTransport, the test transport of the HTTP client used by Anthropic's 2.x SDK) that answers with the documented shape. The simulator's “model” is a trivial rule: it uses BM25 to find the sentence in the document most similar to the question and returns it cited with its offsets. It does not evaluate any model: it checks that the pipeline (send the document, read the citations, map them to passages) works.

IN [09]python
import json

import anthropic
import httpx2


def simulador(request):
    """Answers like the Messages API with char_location citations."""
    cuerpo = json.loads(request.content)
    partes = cuerpo["messages"][0]["content"]
    doc, pregunta = partes[0], partes[1]["text"]
    t = doc["source"]["data"]
    frases = [(m.start(), m.end())
              for m in re.finditer(r"[^\n.]+(?:\.(?=\s|$))?", t)
              if len(m.group().strip()) > 20]
    bm_f = BM25Okapi([palabras(t[a:b]) for a, b in frases])
    a, b = frases[int(np.argmax(bm_f.get_scores(palabras(pregunta))))]
    cita = {"type": "char_location", "cited_text": t[a:b],
            "document_index": 0, "document_title": doc["title"],
            "start_char_index": a, "end_char_index": b}
    return httpx2.Response(200, json={
        "id": "msg_simulado", "type": "message", "role": "assistant",
        "model": cuerpo["model"], "stop_reason": "end_turn",
        "stop_sequence": None,
        "usage": {"input_tokens": 0, "output_tokens": 0},
        "content": [
            {"type": "text", "text": "Según el documento, "},
            {"type": "text", "text": t[a:b].strip(),
             "citations": [cita]}]})


cliente = anthropic.Anthropic(
    api_key="sk-simulado", base_url="http://simulado",
    http_client=httpx2.Client(
        transport=httpx2.MockTransport(simulador)))


def pregunta_con_citas(q):
    doc = q.id_doc
    r = cliente.messages.create(
        model="simulado", max_tokens=200,
        messages=[{"role": "user", "content": [
            {"type": "document",
             "source": {"type": "text", "media_type": "text/plain",
                        "data": TEXTO[doc]},
             "title": TITULO[doc], "citations": {"enabled": True}},
            {"type": "text", "text": q.pregunta}]}])
    citas = [c for b in r.content for c in (b.citations or [])]
    tramo = [dict(doc=doc, tramos=[(c.start_char_index,
                                    c.end_char_index)])
             for c in citas]
    pas_doc = pas[pas.id_doc == doc].id_pasaje
    tocados = [p for p in pas_doc if cubierta(tramo, p) > 0]
    return citas, tocados


Q = preg.set_index("id_pregunta")
for qid in ["QA196", "QA105", "QA230"]:
    q = Q.loc[qid]
    citas, tocados = pregunta_con_citas(q)
    c = citas[0]
    print(qid, q.pregunta[:58])
    print("   citation", c.start_char_index, c.end_char_index,
          repr(c.cited_text[:50]))
    ok = bool(set(tocados) & set(q.pasajes_oro.split(";")))
    print("   cited passages:", tocados, "| gold:", q.pasajes_oro,
          "| correct:", ok)
OUT [09]char_location citations against a local API simulator
QA196 ¿Reparte dividendos la clase A del Sintético Bonos Gobiern
   citation 0 55 '# Folleto — Sintético Bonos Gobierno Euro 3-7 UCIT'
   cited passages: ['R11-p001'] | gold: R11-p030 | correct: False
QA105 ¿Qué margen EBITDA tuvo el segmento Internacional de Povdo
   citation 2870 2931 '| Segmento | Ventas 2023 (millones de USD) | Marge'
   cited passages: ['R05-p025'] | gold: R05-p025 | correct: True
QA230 ¿Cuáles son los costes de operación anuales del Sintético
   citation 0 73 '# Documento de datos fundamentales — Sintético Div'
   cited passages: ['R16-p001'] | gold: R16-p016 | correct: False

The pipeline works end to end: the official client sends the document with citations enabled, the simulator answers with the documented shape and the code turns each char_location into an offset span that is cross-checked with the canonical passages. The simulator's rule, on the other hand, is bad, and the verification catches it: for the EBITDA margin it cites the segment table (the gold passage), but in the other two it cites the document title, which repeats the fund's name and wins in BM25. With a real model the verification is the same: a citation that does not touch the gold passage is wrong, with no room for interpretation.

Common mistakes

  • Evaluating with an overall score. “70% is right” mixes retriever, generator, citations and abstention. Each link is measured separately; if coverage is low, changing the model fixes nothing.
  • Accepting “contains the figure” as accuracy. An answer can contain the correct figure inside an invented calculation. Accuracy also requires that there be no foreign figures; faithfulness, that all of them be in the context.
  • Having no unanswerable questions. An evaluation set without questions the documents do not answer does not measure the most expensive hallucination: inventing a data point that does not exist (a rating, a figure for a year that is not there).
  • Drawing conclusions from samples of ten questions. With 12 questions, there is no statistical difference between 33% and 50%. Report the intervals, evaluate more questions, or use a faster model for the evaluation.
  • Not comparing with a baseline. Without the simple extraction you do not know what the LLM adds. In documents with literal answers, the best highlighted chunk can be more useful than a generated sentence.
  • Trusting citations by fragment number. They get omitted or invented. Offset citations over the original document are verified in code.
  • An uncalibrated LLM judge. A model that scores another one (Ragas, LLM-as-a-judge) also makes mistakes: before using it, measure it against a gold set like this one, just like the system.

Exercises

  1. Context is paid for in tokens (and, with the local model, in seconds). For k = 1, 2, 3 and 5 chunks, measure the retriever's mean coverage on the 246 answerable questions and the tokens of the user message with the Qwen tokenizer. Where is the break-even point?
Solution
IN [10]python
resp_ = preg[preg.respondible]
for k in (1, 2, 3, 5):
    cob, ntok = [], []
    for q in resp_.itertuples():
        top, _ = buscar(q.pregunta, k)
        oro = q.pasajes_oro.split(";")
        cob.append(np.mean([cubierta(top, p) >= 0.5 for p in oro]))
        texto = mensajes(q.pregunta, top)[1]["content"]
        ntok.append(len(tok(texto)["input_ids"]))
    print(f"k={k}: coverage {np.mean(cob):.3f},"
          f" {np.mean(ntok):4.0f} tokens on average")
OUT [10]Coverage versus context tokens
k=1: coverage 0.688,  168 tokens on average
k=2: coverage 0.787,  294 tokens on average
k=3: coverage 0.873,  431 tokens on average
k=5: coverage 0.892,  687 tokens on average

With one chunk, coverage is 68.8% with 168 tokens; with three, 87.3% with 431; with five, 89.2% with 687. Going from 3 to 5 chunks adds 60% more tokens to gain two points of coverage: the elbow is at k = 3. With the local model, every hundred input tokens means seconds of computation; with an API, money; and in both cases, more text that can distract the model.

  1. The line baseline returns a single line from the best chunk. Write a variant that, if the question asks for a figure (it starts with “¿Cuánto” (how much), “¿Cuál” (what/which) or “¿Qué porcentaje” (what percentage)), returns only the first figure of that line. Measure its strict accuracy on the 246 answerable questions.
Solution
IN [11]python
def primera_cifra(q):
    top, _ = buscar(q.pregunta)
    linea = mejor_linea(top[0], q.pregunta)
    linea = linea.split(":", 1)[-1] if "|" not in linea else linea
    pide = re.match(r"¿(Cuánto|Cuál|Qué porcentaje)", q.pregunta)
    m = NUM.findall(CITA.sub(" ", linea))
    if pide and m:
        no_anios = [x for x in m if not re.fullmatch(r"20\d\d", x)]
        return (no_anios or m)[0]
    return linea


ok = [exacta(primera_cifra(q), q) for q in resp_.itertuples()]
print("exact, first figure:", round(np.mean(ok), 3),
      "| whole line:", round(base[base.resp].linea_exacta.mean(), 3))
OUT [11]Extracting the first figure of the best line
exact, first figure: 0.337 | whole line: 0.382

The first-figure rule makes accuracy worse (33.7% versus 38.2% with the whole line): the first figure of a table row is usually the one for the first year or for another column, not the one the question asks for. Shortcuts of this kind can be measured in seconds with the evaluation set, and they almost always turn out worse than they seem.

  1. How would you evaluate a RAG system over the level's conference call transcripts (6.2.3), where the answers are mostly text (“what did management say about margins?”) and there are no gold passages?
Solution

Without gold there is no objective measure, so the first step is to build an evaluation set: a few dozen questions with their answers and the transcript turns that support them, written by someone who knows the business (or generated by a large model and reviewed by hand). The turns have identifiers, so the gold passages are turns and coverage and citation are measured just as here. For the faithfulness of free text, looking up figures is not enough: the answer is broken into claims and each one is checked against the cited turns, with an LLM judge (Ragas faithfulness does it this way) whose accuracy is measured first against a hand-annotated sample. Include unanswerable questions (topics nobody mentioned on the call) and questions with a temporal trap (what was said on a call after the date of the question), and always compare with a baseline: returning the turns most similar to the question.

Footnotes

  1. Anthropic, “Citations”, Messages API documentation, accessed on October 23, 2026: plain-text documents chunked into sentences, char_location citations with 0-based character indexes and an exclusive end, and an answer in several text blocks with their citations list. ↩

Each answer is folded under its question

Self-assessment

  1. The LLM answers “-869,8 -181,2 = -1,047 millones de USD [3]” (USD million) to the question about dividends paid (gold: 181.2 million). How does a check that only looks at whether the gold figures appear in the answer score it?

    • AAs a miss, because the sign is negative
    • BAs a miss, because it cites fragment 3
    • CAs a hit, even though the final answer is an invented sum: that is why accuracy also requires that there be no figures foreign to the question and the gold
    • DIt cannot be scored without a human
    Show answer

    Answer C. As a hit, even though the final answer is an invented sum: that is why accuracy also requires that there be no figures foreign to the question and the gold

    181,2 is in the answer, so a “contains” criterion accepts it. The lesson's strict accuracy requires the answer to have the gold figures and no other figure that does not come from the question; faithfulness also checks that every figure is in the context (the result of the sum is not).

  2. What does citation precision measure?

    • AThe fraction of cited fragments that contain a gold passage of the question
    • BWhether the answer is correct
    • CHow many citations the model includes per answer
    • DWhether the retriever retrieved all the gold passages
    Show answer

    Answer A. The fraction of cited fragments that contain a gold passage of the question

    A citation is correct if the cited fragment covers a gold passage (by offsets, as in 6.3.2). Whether the retriever retrieved the passage is coverage; whether the answer is correct is accuracy. They are three different things and can fail separately.

  3. The BM25 score of the best chunk is lower, on average, for unanswerable questions (10.95) than for answerable ones (14.72). Does a threshold on it work to make the extractive baseline abstain?

    • AYes, it separates the two classes perfectly
    • BYes, because the difference in means is large
    • CThere is no need: the baseline is never wrong
    • DBarely: the distributions overlap (AUC of 0.742), and abstaining on half of the 17 unanswerable questions forces it to abstain on some answerable ones too
    Show answer

    Answer D. Barely: the distributions overlap (AUC of 0.742), and abstaining on half of the 17 unanswerable questions forces it to abstain on some answerable ones too

    A difference in means is not a separation. With the threshold at the median of the unanswerable questions (9.39), the baseline abstains on 8 of the 17 and on 16 of the 246 answerable ones. The unanswerable questions name the company and the year just like the others; knowing that something is not stated requires reading the context.

  4. In the sample of 12 questions the LLM gives the exact answer in 6 and line extraction in 3. What can be concluded?

    • AThat the baseline is better than any LLM
    • BLittle with 12 questions: the confidence intervals overlap almost completely; what can be said is that a 0.5B model adds no accuracy over a simple extraction and adds errors of its own (invented figures, missing citations)
    • CThat the LLM is better because it writes better
    • DThat RAG does not work
    Show answer

    Answer B. Little with 12 questions: the confidence intervals overlap almost completely; what can be said is that a 0.5B model adds no accuracy over a simple extraction and adds errors of its own (invented figures, missing citations)

    With 12 questions, 33% and 50% have intervals of about ±27 points. The honest conclusion is qualitative: the small model gets the easy ones right, invents in calculations and does not cite well. To claim more, evaluate more questions or use a larger model.

  5. What advantage do character-offset citations (like the char_location citations of the Anthropic API) have over asking the model to “cite the fragment number”?

    • ANone, they are the same
    • BThat the model never makes mistakes when citing
    • CThat the citation points to an exact span of the original document, verifiable in code against the reference passages, instead of a number the model can omit or invent
    • DThat they are cheaper in tokens
    Show answer

    Answer C. That the citation points to an exact span of the original document, verifiable in code against the reference passages, instead of a number the model can omit or invent

    With start_char_index and end_char_index the citation is intersected with the canonical passages just as in 6.3.2: either the cited span contains the gold passage or it does not. The citation can still be irrelevant, but it is no longer a loose label: it is a specific, checkable piece of the document.

Key points

  • A RAG system is evaluated part by part: retriever coverage, answer accuracy, faithfulness to the context, citation precision and abstention when the document does not say.
  • Metrics can be deterministic on financial documents: normalized figures looked up in the gold and in the context, and citations cross-checked with the reference passages by offsets.
  • The extractive baseline on the 263 questions: the best chunk contains the gold figures in 77.2% of the answerable ones, but it does not give the answer and its only way of abstaining (a BM25 threshold, AUC of 0.742) is poor.
  • The local 0.5B LLM, on a sample of 12 questions, gives 6 exact answers versus 3 for line extraction, with overlapping intervals; it invents figures in 2 of 8, cites in 5 of 9 and with a mean precision of 0.30. No evidence that it beats extraction.
  • Citations verifiable by offsets (Anthropic's char_location, tested here against a simulator) turn “cite the fragment” into a check in code.

Resources