Lección 6.3.3 · 70 min
RAG sobre informes, folletos y KID, y cómo evaluarlo
Un RAG financiero no se juzga por lo convincente que suena, sino por cuatro cosas medibles: si la respuesta sale de los pasajes, si cita los correctos, si recupera todo lo necesario y si se calla cuando el documento no lo dice. Esta lección monta el sistema completo con un LLM local de 0,5B parámetros, lo evalúa contra preguntas con respuesta de oro y lo compara con una línea base extractiva que no genera nada.
- Módulo
- 6.3 · RAG financiero
- Duración
- 70 min
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
Del trozo recuperado a la respuesta citada
La lección 6.3.2 dejó el buscador medido: trozos por estructura con cabecera de contexto y BM25 recuperan el 87,3 % de los pasajes de oro con los tres primeros trozos. Esta lección añade la otra mitad, el modelo que lee esos trozos y redacta, y sobre todo la evaluación del conjunto. Los documentos son los mismos: informes anuales, folletos y KID de ETF y notas de research, todos sintéticos, con 263 preguntas de las que 17 no tienen respuesta en los documentos.
Fuente Mermaid
flowchart TB Q[Pregunta] --> B[BM25 top-3] B --> C[Contexto numerado] C --> L[LLM o extracción] L --> R[Respuesta y citas] R --> E1[Exactitud] R --> E2[Fidelidad] R --> E3[Citación] B --> E4[Cobertura] R --> E5[Abstención]
Un RAG puede fallar en cada eslabón, y una sola nota global («el 70 % de las respuestas están bien») no dice dónde. Por eso se miden cinco cosas por separado:
| Medida | Pregunta que responde | Cómo se calcula aquí |
|---|---|---|
| Cobertura | ¿El buscador trajo los pasajes necesarios? | Pasajes de oro cubiertos (≥ 50 %) por los 3 trozos, por offsets |
| Exactitud | ¿La respuesta es la del oro? | Cifras de oro presentes y ninguna cifra ajena; sin cifras, palabras clave |
| Fidelidad | ¿Lo que dice sale del contexto? | Cada cifra de la respuesta está en el contexto entregado |
| Citación | ¿Cita los fragmentos correctos? | Fragmentos citados que cubren un pasaje de oro, sobre los citados |
| Abstención | ¿Se calla cuando no consta? | «No consta» en las 17 sin respuesta, y no en las respondibles |
Las cinco son deterministas: no hay un modelo juez. En documentos financieros casi todas las respuestas son cifras, y una cifra se puede normalizar («13.668,9», «13668.9») y buscar. Para afirmaciones de texto libre hace falta algo más (un juez LLM o una métrica como la fidelidad de Ragas, que descompone la respuesta en afirmaciones y comprueba cada una contra el contexto); se comenta al final.
El buscador, en versión compacta
El troceado por estructura y BM25 son los de la lección 6.3.2, en una sola celda: bloques separados por líneas en blanco, tablas enteras con sus notas al pie unidas, párrafos de la misma sección juntos hasta 800 caracteres y una cabecera «documento > sección» en el texto indexado.
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):
"""Fracción de los caracteres del pasaje dentro de los trozos."""
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), "trozos;", len(preg), "preguntas,",
int((~preg.respondible).sum()), "sin respuesta")376 trozos; 263 preguntas, 17 sin respuestaExactitud, fidelidad y citación, en código
Todas las medidas se apoyan en una normalización de cifras. Los documentos escriben a la española («13.668,9», «2,2 %»), el modelo a veces a la inglesa («40.3%»), y los años («2023») no son cifras de la respuesta sino referencias de la pregunta. La regla: con coma, el punto es separador de miles; sin coma, un punto seguido de exactamente tres dígitos también lo es; los números entre corchetes son citas y se quitan antes.
NUM = re.compile(r"\d[\d.,]*\d|\d")
CITA = re.compile(r"\[(\d+)\]")
def cifras(s):
"""Conjunto de cifras de un texto, sin las citas [n]."""
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):
"""Cifras de oro presentes y ninguna ajena (salvo las de la
pregunta); sin cifras en el oro, sus palabras."""
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):
"""Versión laxa: las cifras (o palabras) de oro están dentro."""
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):
"""Toda cifra de la respuesta está en el contexto o la pregunta."""
return cifras(resp) <= cifras(contexto) | cifras(q.pregunta)
def citas_correctas(citados, q):
"""Fracción de trozos citados que cubren un pasaje de oro."""
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")))[2.2, 13668.9]
[40.3, 2023.0, 5505.0]La línea base extractiva, con las 263 preguntas
Antes de poner un LLM conviene saber cuánto se consigue sin él. La línea base extractiva no genera nada: devuelve el mejor trozo recuperado (y cita el fragmento 1), o, más corta, la línea de ese trozo que comparte más palabras con la pregunta. Es lo que haría un buscador con resaltado. Al no generar, no puede inventar cifras (su fidelidad es total por construcción) y se puede evaluar con todas las preguntas en un segundo.
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){'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.00El buscador trae los pasajes de oro en el 87,3 % de los casos (la cobertura con k = 3 de la lección 6.3.2). El mejor trozo contiene las cifras o las palabras de oro en el 77,2 % de las 246 preguntas respondibles; la mejor línea, solo en el 54,5 %, y es exactamente la respuesta, sin cifras de más, en el 38,2 %. La cita del fragmento 1 es correcta en el 70,7 %. Por tipos, el texto y las notas al pie van bien con el trozo entero. Las tablas sufren con la línea (20,3 %): la fila que más palabras comparte con la pregunta no siempre es la que tiene la cifra, y una fila suelta pierde la cabecera de columnas. Cálculo y comparación quedan a cero: la respuesta no está escrita en ningún trozo, hay que calcularla o cruzar documentos, y una extracción no hace ni lo uno ni lo otro.
Lo que la línea base no sabe hacer es callar. Su única señal es la puntuación BM25 del mejor trozo: si es baja, quizá la pregunta no tiene respuesta. La figura siguiente muestra por qué es una señal pobre.
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() # calla en la mitad
calla_bien = (~base.resp & (base.score < u)).sum()
calla_mal = (base.resp & (base.score < u)).sum()
print(f"umbral {u:.2f}: calla en {calla_bien} de 17 sin respuesta"
f" y en {calla_mal} de 246 respondibles") 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
umbral 9.39: calla en 8 de 17 sin respuesta y en 16 de 246 respondiblesLas preguntas sin respuesta puntúan menos de media (10,95 frente a 14,72), pero las distribuciones se solapan: el AUC de la puntuación como detector es 0,742 (0,5 sería el azar). Con el umbral en la mediana de las sin respuesta (9,39), la línea base calla en 8 de las 17 y, a cambio, en 16 de las 246 respondibles. Es lógico: las preguntas sin respuesta del conjunto nombran la empresa, el fondo y el año igual que las demás («¿cuál es el rating crediticio de Busmar Hogar según su informe anual de 2024?»), así que BM25 encuentra trozos muy parecidos. Saber que el rating no está exige leer el contexto, que es justo lo que se le pide a un LLM.
El LLM local: generación con citas sobre una muestra
El generador es Qwen2.5-0.5B-Instruct (Apache-2.0), el modelo pequeño del entorno ia (lección 6.2.1): 0,49 miles de
millones de parámetros, en CPU, con decodificación voraz (determinista). Genera unos pocos tokens por segundo, así que
no se evalúa con las 263 preguntas: se toma una muestra estratificada de 12 (tres de texto, tres de tabla, una de
nota al pie, una de texto y nota, una de tabla y nota y tres sin respuesta), con semilla fija. Con 12 preguntas,
cualquier porcentaje tiene un intervalo de confianza de unos ±25 puntos: la muestra sirve para ver cómo falla el
modelo, no para estimar con precisión cuánto.
El prompt sigue lo que mejor ha funcionado con este modelo en pruebas previas: instrucciones cortas en el mensaje de
sistema, un ejemplo de respuesta con cita y la orden explícita de responder «No consta» si el contexto no lo dice. El
contexto son los tres trozos numerados [1], [2], [3].
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()){'texto': 3, 'tabla': 3, 'no_respondible': 3, 'nota_pie': 1, 'texto_y_nota': 1, 'tabla_y_nota': 1}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"]] 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 NaNEl buscador cubre todos los pasajes de oro de la muestra (cobertura del 100 %), así que cada fallo es del generador. Seis respuestas son exactas: 704 valores, 879,1 millones, 20,5 %, 1,01 millones y dos «No consta» en preguntas sin respuesta. Los fallos son un catálogo de lo que hace un modelo pequeño:
- Idioma y forma. A «¿Reparte dividendos la clase A…?» responde «[2] Yes»: en inglés, sin el «semestral» del oro y citando un fragmento que no es el de oro.
- Cuentas inventadas. Para los dividendos pagados (181,2 millones) suma dos cifras del estado de flujos y da «−1,047»: la cifra correcta aparece, pero dentro de una operación que nadie pidió. La fidelidad lo detecta (el resultado no está en el contexto).
- Cifras que no existen. La capitalización de Zaptec al cierre de 2024 (1.472,9 millones) sale como «1.143,821 USD», una cifra que no está en ningún fragmento.
- Confundir «no» con «no consta». A «¿Incluye la deuda financiera neta los pasivos por arrendamiento?» (oro: no) responde «No consta». Una respuesta negativa no es una abstención, y el modelo las mezcla.
- Copiar la cabecera. En la pregunta de las emisiones de alcance 2 repite la cabecera del fragmento y agota los 40 tokens sin llegar a la cifra.
- Responder lo que no se pregunta. Ante el TER de una clase cubierta en libras que no existe, da «0,20 % anual»: el TER de la clase que sí existe. Es una cifra del contexto, así que la fidelidad numérica la da por buena. La fidelidad comprueba que lo dicho esté en el contexto, no que responda a la pregunta.
La exactitud numérica también tiene su punto ciego: en la retribución variable cuenta como exacta una respuesta que da la cifra (1,01 millones) aunque el oro pide además qué incluye. Las métricas deterministas son baratas y reproducibles, pero miden lo que miden.
La muestra, comparada: LLM frente a extracción
La comparación justa es sobre las mismas 12 preguntas. La línea base por líneas tiene que decidir cuándo calla: se le da la regla del umbral BM25 de la sección anterior, la mediana de las puntuaciones de las preguntas sin respuesta.
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):
"""Proporción con su intervalo de Wilson al 95 %."""
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)],
"extracción": [ic(exacta_base), ic(r_base.linea), ic(r_base.trozo),
"1.00 (no genera)", ic(r_base.cita),
"1.00 (siempre [1])", ic(calla_base[~bm_.resp])]},
index=["exacta (12)", "contiene el oro", " (trozo entero)",
"fiel al contexto", "citas correctas", "cita algo",
"calla si no consta"])
print("cobertura del buscador en la muestra:",
round(r_llm.cobertura.mean(), 3))
tablacobertura del buscador en la muestra: 1.0
LLM 0,5B extracción
exacta (12) 0.50 [0.25, 0.75] n=12 0.25 [0.09, 0.53] n=12
contiene el oro 0.67 [0.35, 0.88] n=9 0.22 [0.06, 0.55] n=9
(trozo entero) - 0.78 [0.45, 0.94] n=9
fiel al contexto 0.75 [0.41, 0.93] n=8 1.00 (no genera)
citas correctas 0.30 [0.07, 0.70] n=5 0.78 [0.45, 0.94] n=9
cita algo 0.56 [0.27, 0.81] n=9 1.00 (siempre [1])
calla si no consta 0.67 [0.21, 0.94] n=3 0.67 [0.21, 0.94] n=3Leída con sus intervalos, la tabla dice poco con seguridad y bastante de forma cualitativa:
- Exactitud. El LLM da la respuesta exacta en 6 de 12 preguntas y la extracción por líneas (con el umbral para callar), en 3 de 12. Los intervalos de Wilson al 95 % ([0,25; 0,75] y [0,09; 0,53]) se solapan: con 12 preguntas no se puede afirmar que el LLM sea mejor.
- Contenido frente a forma. El trozo entero contiene el oro en 7 de las 9 respondibles, más que la respuesta del LLM (6 de 9). Para un analista que va a leer la evidencia, el trozo resaltado es tan útil como la frase generada y no inventa nada.
- Fidelidad y citas. El LLM inventa cifras en 2 de 8 respuestas con contenido, cita algo solo en 5 de 9 y, cuando cita, la precisión media de sus citas es de 0,30. La extracción cita el fragmento 1 siempre, y es correcto en 7 de 9.
- Abstención. Empate a 2 de 3 en las sin respuesta, pero por motivos distintos: el umbral BM25 calla por puntuación y el LLM, por leer (y se equivoca en una respondible).
La conclusión honesta: con un modelo de 0,5B el RAG generativo no aporta exactitud medible sobre una extracción con resaltado, y añade errores propios (cifras inventadas, citas omitidas, idioma). Un modelo mayor cambia los números, y eso es lo que esta evaluación permite medir: la misma tubería, con otro generador, contra el mismo oro. La figura de la cabecera resume la tabla con sus intervalos.
Citas verificables por offsets
Pedir al modelo «termina con el número del fragmento entre corchetes» es frágil: el modelo puede omitir la cita,
citar un fragmento que no usó o devolver solo el número. Las APIs comerciales ofrecen algo mejor. En la de Anthropic,
un documento enviado con "citations": {"enabled": true} hace que la respuesta llegue partida en bloques de texto y
que los bloques citados lleven una lista citations; para un documento de texto plano, cada cita es una
char_location con el texto citado, el índice del documento y los offsets start_char_index (desde 0) y
end_char_index (exclusivo) dentro del texto enviado1Nota 1 · Anthropic, «Citations», documentación de la API de Messages, consultada el 2026-10-23: documentos de texto
plano troceados en frases, citas char_location con índices de carácter desde 0 y fin exclusivo, y respuesta en
varios bloques text con su lista citations.. Es justo lo que hace falta para verificar por código: si se
envía el texto del documento tal cual, los offsets de la cita son los de rag6_pasajes.
El código real, con la API de Anthropic (no se ejecuta: necesita una clave y red):
import anthropic
cliente = anthropic.Anthropic() # ANTHROPIC_API_KEY del entorno
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)Para ejecutar el mismo código sin red, el cliente oficial se apunta a un simulador local (httpx2.MockTransport,
el transporte de pruebas del cliente HTTP que usa el SDK 2.x de Anthropic) que responde con la forma documentada. El
«modelo» del simulador es una regla trivial: busca con BM25 la frase del documento que más se parece a la pregunta y
la devuelve citada con sus offsets. No evalúa ningún modelo: comprueba que la tubería (enviar el documento, leer las
citas, mapearlas a pasajes) funciona.
import json
import anthropic
import httpx2
def simulador(request):
"""Responde como la API de Messages con citas char_location."""
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(" cita", c.start_char_index, c.end_char_index,
repr(c.cited_text[:50]))
ok = bool(set(tocados) & set(q.pasajes_oro.split(";")))
print(" pasajes citados:", tocados, "| oro:", q.pasajes_oro,
"| correcta:", ok)QA196 ¿Reparte dividendos la clase A del Sintético Bonos Gobiern
cita 0 55 '# Folleto — Sintético Bonos Gobierno Euro 3-7 UCIT'
pasajes citados: ['R11-p001'] | oro: R11-p030 | correcta: False
QA105 ¿Qué margen EBITDA tuvo el segmento Internacional de Povdo
cita 2870 2931 '| Segmento | Ventas 2023 (millones de USD) | Marge'
pasajes citados: ['R05-p025'] | oro: R05-p025 | correcta: True
QA230 ¿Cuáles son los costes de operación anuales del Sintético
cita 0 73 '# Documento de datos fundamentales — Sintético Div'
pasajes citados: ['R16-p001'] | oro: R16-p016 | correcta: FalseLa tubería funciona de punta a punta: el cliente oficial envía el documento con las citas activadas, el simulador
responde con la forma documentada y el código convierte cada char_location en un tramo de offsets que se cruza con
los pasajes canónicos. La regla del simulador, en cambio, es mala, y la verificación lo detecta: para el margen
EBITDA cita la tabla de segmentos (el pasaje de oro), pero en las otras dos cita el título del documento, que repite
el nombre del fondo y gana en BM25. Con un modelo real la verificación es la misma: una cita que no toca el pasaje de
oro es incorrecta, sin interpretaciones.
Errores típicos
- Evaluar con una nota global. «El 70 % está bien» mezcla buscador, generador, citas y abstención. Cada eslabón se mide por separado; si la cobertura es baja, cambiar de modelo no arregla nada.
- Aceptar «contiene la cifra» como exactitud. Una respuesta puede contener la cifra correcta dentro de una cuenta inventada. La exactitud exige también que no haya cifras ajenas; la fidelidad, que todas estén en el contexto.
- No tener preguntas sin respuesta. Un conjunto de evaluación sin preguntas que los documentos no responden no mide la alucinación más cara: inventar un dato que no existe (un rating, una cifra de un año que no está).
- Concluir con muestras de diez preguntas. Con 12 preguntas, entre un 33 % y un 50 % no hay diferencia estadística. Se dan los intervalos, o se evalúan más preguntas, o se usa un modelo más rápido para la evaluación.
- No comparar con una línea base. Sin la extracción simple no se sabe qué aporta el LLM. En documentos con respuestas literales, el mejor trozo resaltado puede ser más útil que una frase generada.
- Confiar en citas por número de fragmento. Se omiten o se inventan. Las citas por offsets sobre el documento original se verifican por código.
- Juez LLM sin calibrar. Un modelo que puntúa a otro (Ragas, LLM-as-a-judge) también se equivoca: antes de usarlo, se mide contra un conjunto con oro como este, igual que el sistema.
Ejercicios
- El contexto se paga en tokens (y, con el modelo local, en segundos). Mide, para k = 1, 2, 3 y 5 trozos, la cobertura media del buscador en las 246 preguntas respondibles y los tokens del mensaje de usuario con el tokenizador de Qwen. ¿Dónde está el punto de equilibrio?
Solución
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}: cobertura {np.mean(cob):.3f},"
f" {np.mean(ntok):4.0f} tokens de media")k=1: cobertura 0.688, 168 tokens de media
k=2: cobertura 0.787, 294 tokens de media
k=3: cobertura 0.873, 431 tokens de media
k=5: cobertura 0.892, 687 tokens de mediaCon un trozo, la cobertura es del 68,8 % con 168 tokens; con tres, del 87,3 % con 431; con cinco, del 89,2 % con 687. El paso de 3 a 5 trozos añade un 60 % de tokens para ganar dos puntos de cobertura: el codo está en k = 3. Con el modelo local, cada centenar de tokens de entrada son segundos de cálculo; con una API, dinero; y en los dos casos, más texto que puede distraer al modelo.
- La línea base por líneas devuelve una sola línea del mejor trozo. Haz una variante que, si la pregunta pide una cifra (empieza por «¿Cuánto», «¿Cuál» o «¿Qué porcentaje»), devuelva solo la primera cifra de esa línea. Mide su exactitud estricta en las 246 preguntas respondibles.
Solución
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("exacta, primera cifra:", round(np.mean(ok), 3),
"| línea entera:", round(base[base.resp].linea_exacta.mean(), 3))exacta, primera cifra: 0.337 | línea entera: 0.382La regla de la primera cifra empeora la exactitud (33,7 % frente a 38,2 % con la línea entera): la primera cifra de una fila de tabla suele ser la del primer año o la de otra columna, no la que pide la pregunta. Los atajos de este tipo se pueden medir en segundos con el conjunto de evaluación, y casi siempre resultan peores de lo que parecen.
- ¿Cómo evaluarías un RAG sobre las transcripciones de conference calls del nivel (6.2.3), donde las respuestas son sobre todo de texto («¿qué dijo la dirección sobre los márgenes?») y no hay pasajes de oro?
Solución
Sin oro no hay medida objetiva, así que lo primero es construir un conjunto de evaluación: unas decenas de preguntas con su respuesta y los turnos de la transcripción que la sostienen, escritas por alguien que conoce el negocio (o generadas por un modelo grande y revisadas a mano). Los turnos tienen identificador, así que los pasajes de oro son turnos y la cobertura y la citación se miden igual que aquí. Para la fidelidad de un texto libre no basta con buscar cifras: se descompone la respuesta en afirmaciones y se comprueba cada una contra los turnos citados, con un juez LLM (la fidelidad de Ragas lo hace así) cuyo acierto se mide antes contra una muestra anotada a mano. Hay que incluir preguntas sin respuesta (temas que nadie mencionó en la llamada) y preguntas con trampa temporal (lo que se dijo en una llamada posterior a la fecha de la pregunta), y comparar siempre con una línea base: devolver los turnos más parecidos a la pregunta.
Footnotes
-
Anthropic, «Citations», documentación de la API de Messages, consultada el 2026-10-23: documentos de texto plano troceados en frases, citas
char_locationcon índices de carácter desde 0 y fin exclusivo, y respuesta en varios bloquestextcon su listacitations. ↩
Respuesta plegada en cada pregunta
Autoevaluación
El LLM responde «-869,8 -181,2 = -1,047 millones de USD [3]» a la pregunta por los dividendos pagados (oro: 181,2 millones). ¿Cómo lo puntúa una comprobación que solo mira si las cifras de oro aparecen en la respuesta?
- AComo fallo, porque el signo es negativo
- BComo fallo, porque cita el fragmento 3
- CComo acierto, aunque la respuesta final es una suma inventada: por eso la exactitud exige además que no haya cifras ajenas a la pregunta y al oro
- DNo se puede puntuar sin un humano
Ver respuesta
Respuesta C. Como acierto, aunque la respuesta final es una suma inventada: por eso la exactitud exige además que no haya cifras ajenas a la pregunta y al oro
181,2 está en la respuesta, así que un criterio de «contiene» la da por buena. La exactitud estricta de la lección pide que la respuesta tenga las cifras de oro y ninguna otra que no venga de la pregunta; la fidelidad comprueba además que cada cifra esté en el contexto (el resultado de la suma no está).
¿Qué mide la precisión de la citación?
- ALa fracción de fragmentos citados que contienen un pasaje de oro de la pregunta
- BSi la respuesta es correcta
- CCuántas citas pone el modelo por respuesta
- DSi el buscador recuperó todos los pasajes de oro
Ver respuesta
Respuesta A. La fracción de fragmentos citados que contienen un pasaje de oro de la pregunta
Una cita es correcta si el fragmento citado cubre un pasaje de oro (por offsets, como en 6.3.2). Que el buscador haya recuperado el pasaje es la cobertura; que la respuesta sea correcta es la exactitud. Son tres cosas distintas y pueden fallar por separado.
La puntuación BM25 del mejor trozo es menor, de media, en las preguntas sin respuesta (10,95) que en las respondibles (14,72). ¿Sirve un umbral sobre ella para que la línea base extractiva se calle?
- ASí, separa perfectamente las dos clases
- BSí, porque la diferencia de medias es grande
- CNo hace falta: la línea base nunca se equivoca
- DPoco: las distribuciones se solapan (AUC de 0,742) y callar en la mitad de las 17 sin respuesta obliga a callar también en algunas respondibles
Ver respuesta
Respuesta D. Poco: las distribuciones se solapan (AUC de 0,742) y callar en la mitad de las 17 sin respuesta obliga a callar también en algunas respondibles
Una diferencia de medias no es una separación. Con el umbral en la mediana de las sin respuesta (9,39), la línea base calla en 8 de las 17 y en 16 de las 246 respondibles. Las preguntas sin respuesta nombran empresa y año como las demás; saber que algo no consta exige leer el contexto.
En la muestra de 12 preguntas el LLM da la respuesta exacta en 6 y la extracción por líneas, en 3. ¿Qué se puede concluir?
- AQue la línea base es mejor que cualquier LLM
- BPoco con 12 preguntas: los intervalos de confianza se solapan casi por completo; sí que un modelo de 0,5B no aporta exactitud sobre una extracción simple y añade errores propios (cifras inventadas, citas omitidas)
- CQue el LLM es mejor porque redacta mejor
- DQue el RAG no funciona
Ver respuesta
Respuesta B. Poco con 12 preguntas: los intervalos de confianza se solapan casi por completo; sí que un modelo de 0,5B no aporta exactitud sobre una extracción simple y añade errores propios (cifras inventadas, citas omitidas)
Con 12 preguntas, un 33 % y un 50 % tienen intervalos de unos ±27 puntos. La conclusión honesta es cualitativa: el modelo pequeño acierta lo fácil, inventa en los cálculos y no cita bien. Para afirmar más, hay que evaluar más preguntas o usar un modelo mayor.
¿Qué ventaja tienen las citas por offsets de caracteres (como las
char_locationde la API de Anthropic) frente a pedir al modelo «cita el número del fragmento»?- ANinguna, son lo mismo
- BQue el modelo nunca se equivoca al citar
- CQue la cita apunta a un tramo exacto del documento original, verificable por código contra los pasajes de referencia, en lugar de un número que el modelo puede omitir o inventar
- DQue son más baratas en tokens
Ver respuesta
Respuesta C. Que la cita apunta a un tramo exacto del documento original, verificable por código contra los pasajes de referencia, en lugar de un número que el modelo puede omitir o inventar
Con
start_char_indexyend_char_indexse intersecta la cita con los pasajes canónicos igual que en 6.3.2: o el tramo citado contiene el pasaje de oro o no. La cita sigue pudiendo ser irrelevante, pero ya no es una etiqueta suelta: es un trozo concreto del documento, comprobable.
Ideas clave
- Un RAG se evalúa por partes: cobertura del buscador, exactitud de la respuesta, fidelidad al contexto, precisión de las citas y abstención cuando el documento no lo dice.
- Las métricas pueden ser deterministas en documentos financieros: cifras normalizadas que se buscan en el oro y en el contexto, y citas que se cruzan con los pasajes de referencia por offsets.
- La línea base extractiva sobre las 263 preguntas: el mejor trozo contiene las cifras de oro en el 77,2 % de las respondibles, pero no da la respuesta y su única forma de callar (un umbral BM25, AUC de 0,742) es pobre.
- El LLM local de 0,5B, en una muestra de 12 preguntas, da 6 respuestas exactas frente a 3 de la extracción por líneas, con intervalos que se solapan; inventa cifras en 2 de 8, cita en 5 de 9 y con una precisión media de 0,30. Sin evidencia de que mejore a la extracción.
- Las citas verificables por offsets (las
char_locationde Anthropic, probadas aquí contra un simulador) convierten «cita el fragmento» en una comprobación por código.