Lección 6.3.2 · 65 min
Chunking de documentos financieros: tablas, notas al pie y XBRL
Un RAG no recupera documentos: recupera trozos. Si el troceado parte una tabla o separa una cifra de la nota al pie que la matiza, ningún modelo podrá responder bien. Esta lección compara tres estrategias de troceado sobre 21 informes, folletos y KID sintéticos, mide cuántos pasajes de referencia recupera cada una y separa lo que debe ir por texto de lo que debe ir por datos estructurados (XBRL).
- Módulo
- 6.3 · RAG financiero
- Duración
- 65 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
Por qué el troceado decide lo que se recupera
Un sistema RAG (retrieval-augmented generation) tiene dos mitades: un buscador que elige unos pocos trozos de los documentos y un modelo que redacta la respuesta con ellos. La lección 6.3.1 trata la mitad del buscador (embeddings, índices vectoriales, BM25); esta trata lo que va antes: cómo se parte cada documento en trozos (chunks). Es la decisión menos vistosa del sistema y una de las que más pesan, porque el modelo solo ve lo que el buscador le entrega.
Fuente Mermaid
flowchart TB D[Documentos] --> T[Troceado] T --> I[Índice] P[Pregunta] --> B[Búsqueda top-k] I --> B B --> C[Contexto] C --> L[LLM] L --> R[Respuesta]
Los documentos financieros castigan especialmente un mal troceado:
- Tablas. Las cifras clave (ventas, deuda, gastos corrientes de un ETF) viven en tablas. Partida por la mitad, una tabla pierde la fila de cabecera que dice qué es cada columna, o la fila que pedía la pregunta queda en el trozo de al lado.
- Notas al pie. «EBITDA ajustado¹» no significa nada sin «¹ excluye costes de reestructuración de 74,3 millones». En un informe la nota puede estar justo debajo de la tabla o a varias páginas.
- Plantillas. Los informes de dos años de la misma empresa, o los folletos de cinco ETF de la misma gestora, son casi idénticos. Un trozo «Ventas | 13.668,9 | 13.378,6» no dice de qué empresa ni de qué año es.
- Datos que ya son estructurados. Las cifras de los estados financieros de las empresas de EE. UU. existen como hechos XBRL etiquetados, con su periodo y su fecha de presentación. Buscarlas en el texto es dar un rodeo.
La lección compara tres troceados con una medida objetiva (recall@k contra pasajes de referencia) y termina con la alternativa estructurada: HTML parseado y hechos XBRL.
El material: documentos, pasajes canónicos y preguntas con oro
Los datos del nivel 6 traen 21 documentos sintéticos en español y en Markdown: informes anuales de 2023 y 2024 de cuatro emisores españoles ficticios, folleto y KID de cinco ETF UCITS ficticios y tres notas de research. Lo que los hace útiles para medir es la segmentación canónica: 974 pasajes con su tipo (título, texto, tabla, nota, lista) y su posición exacta en caracteres, y 263 preguntas con los pasajes que hay que citar para responderlas.
import re
import unicodedata
import numpy as np
import pandas as pd
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")
print(docs.tipo.value_counts().to_dict())
print(pas.tipo.value_counts().to_dict())
print(preg.tipo_pregunta.value_counts().to_dict())
r = P.loc["R01-p013"]
print(r.id_doc, r.seccion, r.tipo, r.inicio, r.fin)
print(TEXTO["R01"][r.inicio:r.fin][:150]){'informe_anual': 8, 'folleto_etf': 5, 'kid': 5, 'research': 3}
{'texto': 431, 'titulo': 252, 'lista': 119, 'tabla': 105, 'nota': 67}
{'texto': 122, 'tabla': 64, 'no_respondible': 17, 'texto_y_nota': 16, 'nota_pie': 16, 'tabla_y_nota': 13, 'calculo': 8, 'comparacion': 7}
R01 3. Magnitudes principales tabla 1577 1914
| (millones de USD) | 2023 | 2022 |
|---|---|---|
| Ventas | 13.668,9 | 13.378,6 |
| EBITDA ajustado¹ | 3.769,6 | 3.222,8 |
| Resultado neto | 2.442,8texto[inicio:fin] es el pasaje: con esos offsets se puede comprobar, para cualquier trozo propio, qué pasajes de
referencia contiene y en qué proporción. Las preguntas de tipo texto_y_nota y tabla_y_nota necesitan dos pasajes,
y alguna de comparación, cinco (uno por ETF). La pregunta QA002 lo muestra: la cifra está en la nota 1 de los
estados financieros y el matiz, en la nota ² de la tabla de magnitudes, muchas líneas antes.
q = preg.set_index("id_pregunta").loc["QA002"]
print(q.pregunta, "|", q.respuesta_oro)
for pid in q.pasajes_oro.split(";"):
r = P.loc[pid]
print(pid, r.inicio, r.seccion[:28], "|",
TEXTO[r.id_doc][r.inicio:r.fin][:70])¿Cuántos pasivos por arrendamiento tiene Busmar Hogar al cierre de 2023 y se incluyen en la deuda neta? | 369,2 millones de dólares; no se incluyen
R01-p044 5658 Notas seleccionadas a los es | Nota 1. Arrendamientos. El grupo reconoce pasivos por arrendamiento de
R01-p015 2010 3. Magnitudes principales | ² Deuda financiera neta = deuda financiera bruta − caja y equivalentesTres formas de trocear
Las tres estrategias producen lo mismo: una lista de trozos, cada uno con su documento y sus tramos de offsets
(inicio, fin). Guardar los offsets, y no solo el texto, es lo que permite medir y, más adelante, citar.
Tamaño fijo. Ventanas de 500 caracteres con un solape de 50. Es lo más sencillo y no sabe nada del documento: corta donde caiga, también en mitad de una fila o de una cifra.
def fijo(doc, n=500, solape=50):
"""Ventanas de n caracteres que se solapan `solape`."""
t, trozos, i = TEXTO[doc], [], 0
while True:
f = min(i + n, len(t))
trozos.append(dict(doc=doc, tramos=[(i, f)]))
if f == len(t):
return trozos
i = f - solape
t = TEXTO["R01"]
r = P.loc["R01-p013"] # la tabla de magnitudes
for c in fijo("R01"):
(i, f), = c["tramos"]
if i < r.fin and f > r.inicio:
print((i, f), repr(t[max(i, r.inicio):max(i, r.inicio) + 40]))(1350, 1850) '| (millones de USD) | 2023 | 2022 |\n|---'
(1800, 2300) 'neto | 13.069,0 | 11.890,0 |\n| Dividendo'La tabla de magnitudes principales queda repartida entre dos ventanas, y la segunda empieza a mitad de una fila: ese trozo tiene cifras sin cabecera de columnas.
Recursivo. La idea de los troceadores «recursivos» de LangChain o LlamaIndex: intenta partir por el separador más grueso (línea en blanco); si un trozo sigue siendo mayor que el máximo, lo parte por el siguiente (salto de línea, punto, espacio), y después vuelve a juntar trozos vecinos mientras quepan. Respeta párrafos y filas cuando puede, pero no sabe qué es una tabla ni a qué sección pertenece cada trozo.
SEPARADORES = ["\n\n", "\n", ". ", " "]
def cortes(t, i, f, seps, n):
"""Tramos de como mucho n caracteres, por el separador más
grueso que sirva."""
if f - i <= n:
return [(i, f)]
if not seps:
return [(k, min(k + n, f)) for k in range(i, f, n)]
s, bordes = seps[0], [i]
k = t.find(s, i, f)
while k != -1:
bordes.append(k + len(s))
k = t.find(s, k + len(s), f)
bordes.append(f)
piezas = []
for a, b in zip(bordes[:-1], bordes[1:]):
if b > a:
piezas += cortes(t, a, b, seps[1:], n)
unidos = [] # junta vecinos mientras quepan
for a, b in piezas:
if unidos and b - unidos[-1][0] <= n:
unidos[-1] = (unidos[-1][0], b)
else:
unidos.append((a, b))
return unidos
def recursivo(doc, n=500):
t, trozos = TEXTO[doc], []
for a, b in cortes(t, 0, len(t), SEPARADORES, n):
a += len(t[a:b]) - len(t[a:b].lstrip()) # sin blancos
b -= len(t[a:b]) - len(t[a:b].rstrip())
if b > a:
trozos.append(dict(doc=doc, tramos=[(a, b)]))
return trozos
print(len(fijo("R01")), len(recursivo("R01")))23 25Por estructura. Usa lo que el documento dice de sí mismo: los bloques separados por líneas en blanco, los títulos
##, las tablas (líneas que empiezan por |) y las marcas de nota (¹ ² ³…). Reglas:
- Los títulos no son trozos: dan la sección de lo que sigue.
- Una tabla es siempre un trozo entero, aunque supere el máximo.
- Un bloque que cita una nota (¹, ²…) se une a la nota: el trozo tiene dos tramos de offsets.
- Los párrafos consecutivos de la misma sección se juntan hasta 800 caracteres.
Fuente Mermaid
flowchart TB
B[Bloque] --> H{¿Título?}
H -->|sí| S[Fija la sección]
H -->|no| N{¿Es una nota?}
N -->|sí| X[Espera su cita]
N -->|no| T{¿Tabla o cita?}
T -->|sí| U[Trozo con su nota]
T -->|no| J[Se une al anterior]NOTA = "¹²³⁴⁵⁶⁷⁸⁹"
def bloques(t):
"""Párrafos, tablas, títulos y notas: lo separado por líneas
en blanco."""
return [(m.start(), m.end())
for m in re.finditer(r"[^\n](?:.|\n(?!\n))*", t)]
def seccion(doc, i):
"""Último título ## anterior a la posición i."""
hs = re.findall(r"^##+ (.*)$", TEXTO[doc][:i + 1], re.M)
return hs[-1] if hs else ""
def estructura(doc, n=800):
t = TEXTO[doc]
bl = bloques(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))
return trozos
tabla = next(c for c in estructura("R01")
if c["tramos"][0][0] == P.loc["R01-p013"].inicio)
print(len(estructura("R01")), tabla["tramos"])29 [(1577, 1914), (1916, 2008), (2010, 2119)]El trozo de la tabla de magnitudes lleva tres tramos: la tabla y sus dos notas. La figura siguiente dibuja, con los offsets reales de esa zona del informe de Busmar Hogar de 2023, los pasajes canónicos y dónde corta cada estrategia.
La última pieza es el texto que se indexa. Un trozo puede llevar delante una cabecera de contexto con el título del documento y la sección («Busmar Hogar, S.A. — Informe anual 2023 > 3. Magnitudes principales»). La cabecera se puede añadir a cualquier estrategia: para los trozos fijos y recursivos se usa el último título anterior a su inicio. Así se separa el efecto de los cortes del efecto de la cabecera.
def con_texto(trozos, cabecera):
"""Añade a cada trozo el texto que se indexará."""
for c in trozos:
t = TEXTO[c["doc"]]
cuerpo = "\n\n".join(t[i:f] for i, f in c["tramos"])
sec = seccion(c["doc"], c["tramos"][0][0])
cab = f"{TITULO[c['doc']]} > {sec}\n\n" if cabecera else ""
c["texto"] = cab + cuerpo
return trozos
ESTRATEGIAS = {"fijo": fijo, "recursivo": recursivo,
"estructura": estructura}
TROZOS = {}
for nombre, f in ESTRATEGIAS.items():
for cab in (False, True):
trozos = [c for d in TEXTO for c in f(d)]
TROZOS[(nombre, cab)] = con_texto(trozos, cab)
for (nombre, cab), tr in TROZOS.items():
largo = np.mean([len(c["texto"]) for c in tr])
print(f"{nombre:10s} cabecera={cab!s:5s} {len(tr):4d} trozos,"
f" {largo:5.0f} caracteres de media")
print(TROZOS[("estructura", True)][12]["texto"][:200])fijo cabecera=False 270 trozos, 487 caracteres de media
fijo cabecera=True 270 trozos, 561 caracteres de media
recursivo cabecera=False 293 trozos, 404 caracteres de media
recursivo cabecera=True 293 trozos, 480 caracteres de media
estructura cabecera=False 376 trozos, 293 caracteres de media
estructura cabecera=True 376 trozos, 372 caracteres de media
Busmar Hogar, S.A. — Informe anual 2023 > Estados financieros resumidos
| Balance (millones de USD) | 31-12-2023 |
|---|---|
| Activo no corriente | 19.543,5 |
| Activo corriente (sin caja) | 3.417,2De un trozo a los pasajes: offsets y recall@k
Para saber si un trozo «contiene» un pasaje de oro no hace falta comparar textos: basta con intersectar intervalos.
La función cubierta calcula qué fracción de los caracteres de un pasaje cae dentro de un conjunto de trozos (la
unión de sus tramos, para no contar dos veces el solape de las ventanas fijas). Un pasaje cuenta como recuperado
si los primeros trozos cubren al menos la mitad de sus caracteres, y el recall@k de una pregunta es la fracción de
sus pasajes de oro recuperados:
con los pasajes de oro y los tramos de los trozos mejor puntuados. También se mide si una pregunta tiene todos sus pasajes (cobertura completa), que es lo que exigen las preguntas con tabla y nota.
def cubierta(trozos, pid):
"""Fracción de los caracteres del pasaje pid dentro de la unión
de los tramos de `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)
for nombre in ESTRATEGIAS:
tr = [c for c in TROZOS[(nombre, True)] if c["doc"] == "R01"]
cob = [float(round(cubierta([c], "R01-p013"), 2)) for c in tr]
print(f"{nombre:10s} trozos que tocan la tabla:",
[x for x in cob if x > 0],
"| nota ²:", max(cubierta([c], "R01-p015") for c in tr))fijo trozos que tocan la tabla: [0.81, 0.34] | nota ²: 1.0
recursivo trozos que tocan la tabla: [1.0] | nota ²: 1.0
estructura trozos que tocan la tabla: [1.0] | nota ²: 1.0En el troceado fijo la tabla de magnitudes queda repartida: una ventana cubre el 81 % de sus caracteres y la siguiente, el 34 % (las dos se solapan 50 caracteres). En los otros dos, un único trozo la cubre entera, y la nota ² está completa en algún trozo de las tres estrategias, aunque en el recursivo no en el de la tabla (la figura anterior). Con la cabecera o sin ella, los offsets son los mismos: la cabecera se añade al texto indexado, no al documento.
Falta el buscador. Se usa BM25 (lección 6.3.1), que puntúa por coincidencia de palabras ponderada por su rareza y normalizada por la longitud del trozo. Es determinista, rápido y, como se verá, muy competitivo en estos textos llenos de nombres propios y cifras. La normalización quita tildes y unas pocas palabras vacías, igual para trozos y preguntas. Las 17 preguntas que los documentos no responden no tienen pasajes de oro y no entran en el recall.
from rank_bm25 import BM25Okapi
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]
resp = preg[preg.respondible].reset_index(drop=True)
def ranking(trozos, kmax=10):
"""Los kmax trozos mejor puntuados para cada pregunta."""
bm = BM25Okapi([palabras(c["texto"]) for c in trozos])
return [np.argsort(-bm.get_scores(palabras(q)),
kind="stable")[:kmax] for q in resp.pregunta]
def medir(trozos, rk, k):
filas = []
for q, orden in zip(resp.itertuples(), rk):
top = [trozos[j] for j in orden[:k]]
ok = [cubierta(top, p) >= 0.5
for p in q.pasajes_oro.split(";")]
car = sum(len(c["texto"]) for c in top)
filas.append((q.tipo_pregunta, np.mean(ok), all(ok), car))
return pd.DataFrame(filas,
columns=["tipo", "recall", "completa", "car"])
RK = {clave: ranking(tr) for clave, tr in TROZOS.items()}
filas = []
for (nombre, cab), tr in TROZOS.items():
for k in (3, 5):
m = medir(tr, RK[(nombre, cab)], k)
filas.append((nombre, cab, k, m.recall.mean(),
m.completa.mean(), m.car.mean()))
tabla = pd.DataFrame(filas, columns=[
"troceado", "cabecera", "k", "recall", "completa", "caracteres"])
print(len(resp), "preguntas respondibles")
tabla.pivot_table(index=["troceado", "cabecera"], columns="k",
values=["recall", "caracteres"]).round(3)246 preguntas respondibles
caracteres recall
k 3 5 3 5
troceado cabecera
estructura False 846.776 1561.736 0.254 0.476
True 1189.667 1959.715 0.873 0.892
fijo False 1495.577 2495.577 0.228 0.408
True 1699.585 2815.459 0.798 0.862
recursivo False 1172.317 1975.874 0.217 0.391
True 1452.394 2407.220 0.829 0.871Dos lecturas. La primera, y la más grande: sin cabecera de contexto ninguna estrategia funciona (entre el 21,7 % y el 25,4 % de recall con k = 3), y con ella las tres pasan del 79 %. No es una propiedad de BM25: los ocho informes anuales comparten plantilla y las preguntas dicen siempre de qué empresa y de qué año hablan. Un trozo con «Ventas | 13.668,9» sin cabecera es igual de bueno para Busmar Hogar 2023 que para Celra 2024. En la práctica, la alternativa o el complemento es filtrar por metadatos (emisor, ejercicio, tipo de documento) antes de buscar; el ejercicio 2 lo mide.
La segunda: con cabecera, el troceado por estructura recupera más con menos texto (87,3 % con k = 3 y unos 1.190 caracteres, frente al 79,8 % del fijo con 1.700). Pero comparar a igual no es justo para nadie: cada estrategia entrega trozos de tamaño distinto.
A igual contexto, y pregunta por pregunta
El contexto de un LLM cuesta: tokens que se pagan, segundos de cálculo (en el modelo local de 6.3.3, varios por cada centenar de tokens) y ruido que distrae al modelo. La comparación honrada pone en el eje horizontal los caracteres entregados y en el vertical el recall, variando de 1 a 10. Es la figura de la cabecera.
curvas = []
for nombre in ESTRATEGIAS:
tr, rk = TROZOS[(nombre, True)], RK[(nombre, True)]
for k in range(1, 11):
m = medir(tr, rk, k)
curvas.append((nombre, k, round(m.car.mean()),
round(m.recall.mean(), 3)))
curvas = pd.DataFrame(curvas,
columns=["troceado", "k", "car", "recall"])
curvas.pivot(index="k", columns="troceado", values=["car", "recall"]) car recall
troceado estructura fijo recursivo estructura fijo recursivo
k
1 408.0 569.0 490.0 0.688 0.569 0.668
2 791.0 1135.0 975.0 0.787 0.687 0.778
3 1190.0 1700.0 1452.0 0.873 0.798 0.829
4 1558.0 2261.0 1928.0 0.890 0.841 0.837
5 1960.0 2815.0 2407.0 0.892 0.862 0.871
6 2360.0 3372.0 2881.0 0.893 0.885 0.875
7 2755.0 3927.0 3359.0 0.907 0.909 0.878
8 3133.0 4488.0 3820.0 0.909 0.937 0.883
9 3492.0 5045.0 4294.0 0.917 0.952 0.904
10 3851.0 5604.0 4772.0 0.919 0.957 0.915Hasta unos 2.000 caracteres, el troceado por estructura está por encima: con k = 3 (1.190 caracteres) recupera el 87,3 %, más que el fijo con k = 4 (2.261 caracteres, 84,1 %). Con mucho contexto la ventaja desaparece y el fijo termina por delante (95,7 % con k = 10), porque sus ventanas grandes y solapadas acaban cubriendo casi todo lo que rodea a la respuesta. El de estructura se estanca hacia el 92 %: lo que no recupera no es cuestión de cuántos trozos, sino de qué preguntas, como muestra el desglose por tipo.
por_tipo = pd.DataFrame({
nombre: medir(TROZOS[(nombre, True)], RK[(nombre, True)], 5)
.groupby("tipo").recall.mean()
for nombre in ESTRATEGIAS}).round(3)
por_tipo.assign(preguntas=resp.tipo_pregunta.value_counts()) fijo recursivo estructura preguntas
tipo
calculo 0.500 0.000 0.000 8
comparacion 0.571 0.814 0.200 7
nota_pie 1.000 1.000 1.000 16
tabla 0.812 0.875 0.875 64
tabla_y_nota 1.000 0.885 1.000 13
texto 0.918 0.926 0.992 122
texto_y_nota 0.688 0.750 0.750 16- Notas al pie y tabla con nota. El de estructura acierta el 100 %: la nota viaja con la tabla. El fijo también, por arrastre: sus ventanas de 500 caracteres suelen coger la tabla corta y la nota de debajo. El recursivo pierde las tablas con nota que su corte separa (88,5 %).
- Texto y nota. Ninguno pasa del 75 %: la cifra está en un párrafo (una nota de los estados financieros) y el matiz, en la nota de una tabla de otra sección. Unir la nota a la tabla no basta cuando la pregunta llega por el párrafo; haría falta que el buscador traiga los dos, o indexar la nota también sola.
- Cálculo. Las preguntas de crecimiento («¿cuánto crecieron las ventas en 2023?») tienen como oro la tabla de magnitudes, pero la carta del presidente dice literalmente «un 2,2 % más que en 2022». El buscador encuentra la respuesta en otro pasaje y la métrica de pasajes lo cuenta como fallo (el fijo acierta la mitad porque alguna de sus ventanas mezcla la carta con el principio de la tabla). Un recordatorio de que el oro también es una convención.
- Comparación. «¿Qué ETF tiene los gastos corrientes más bajos?» necesita cinco folletos a la vez, y el resultado salta sin lógica de una estrategia a otra (del 20 % al 81,4 %): con k = 5 y cinco documentos, basta con que dos trozos del mismo fondo ocupen el sitio de otro. Son solo 7 preguntas y no conviene leer nada en ese ranking. Es una pregunta para una tabla estructurada (los cinco TER) o para descomponerla en cinco búsquedas, una por fondo.
Tablas: Markdown, linealizadas y embeddings
Una recomendación habitual es linealizar las tablas antes de indexarlas: convertir cada fila en una frase que repite la cabecera de columna («Ventas: 2023 = 13.668,9; 2022 = 13.378,6.»). La idea es que un modelo de embeddings entienda mejor una frase que una cuadrícula con barras. Se puede medir: misma estrategia, mismas preguntas, tablas en Markdown o linealizadas, con BM25 y con el modelo de embeddings pequeño del entorno (all-MiniLM-L6-v2, 384 dimensiones, entrenado sobre todo con texto en inglés; lección 6.3.1).
import copy
from sentence_transformers import SentenceTransformer
def linealiza(tabla):
"""Una frase por fila, con la cabecera de cada columna."""
filas = [[x.strip() for x in linea.strip("|").split("|")]
for linea in tabla.strip().split("\n")]
cab = filas[0]
frases = [f"{f[0]}: " + "; ".join(f"{h} = {v}"
for h, v in zip(cab[1:], f[1:]))
+ "." for f in filas[2:]]
return f"{cab[0]}. " + " ".join(frases)
md = TROZOS[("estructura", True)]
lin = copy.deepcopy(md)
for c in lin:
t = TEXTO[c["doc"]]
(i, f), *notas = c["tramos"]
if t[i] == "|":
cuerpo = [linealiza(t[i:f])] + [t[a:b] for a, b in notas]
cab = f"{TITULO[c['doc']]} > {seccion(c['doc'], i)}\n\n"
c["texto"] = cab + "\n\n".join(cuerpo)
cambia = [j for j, (a, b) in enumerate(zip(md, lin))
if a["texto"] != b["texto"]]
print(len(cambia), "trozos con tabla:")
print(lin[cambia[0]]["texto"].split("\n\n")[1][:150])
st = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2",
device="cpu", local_files_only=True)
cod = dict(normalize_embeddings=True, show_progress_bar=False,
batch_size=64)
Q = st.encode(resp.pregunta.tolist(), **cod)
X_md = st.encode([c["texto"] for c in md], **cod)
X_lin = X_md.copy()
X_lin[cambia] = st.encode([lin[j]["texto"] for j in cambia], **cod)
filas = []
for nombre, tr, X in [("markdown", md, X_md),
("linealizada", lin, X_lin)]:
densa = [np.argsort(-(X @ q), kind="stable")[:10] for q in Q]
for buscador, rk in [("BM25", ranking(tr)), ("MiniLM", densa)]:
m = medir(tr, rk, 5)
filas.append((buscador, nombre, m.recall.mean(),
m[m.tipo == "tabla"].recall.mean()))
pd.DataFrame(filas, columns=["buscador", "tablas", "recall@5",
"recall@5 tabla"]).round(3)105 trozos con tabla:
(millones de USD). Ventas: 2023 = 13.668,9; 2022 = 13.378,6. EBITDA ajustado¹: 2023 = 3.769,6; 2022 = 3.222,8. Resultado neto: 2023 = 2.442,8; 2022 =
buscador tablas recall@5 recall@5 tabla
0 BM25 markdown 0.892 0.875
1 MiniLM markdown 0.442 0.406
2 BM25 linealizada 0.890 0.844
3 MiniLM linealizada 0.422 0.344En estos documentos linealizar no ayuda: con BM25 el recall de las preguntas de tabla baja del 87,5 % al 84,4 %, y con MiniLM, del 40,6 % al 34,4 %. Repetir «2023 =» y «2022 =» en cada fila diluye las palabras que distinguen una fila de otra. Y el modelo denso pequeño queda muy por detrás de BM25 en todo (44 % frente a 89 %): es inglés, y aquí las preguntas y los textos son en español y se distinguen por nombres propios, años y cifras, justo lo que mejor casa una búsqueda léxica. No es una conclusión general sobre embeddings (un modelo multilingüe mayor, o una búsqueda híbrida, cambian el resultado; lección 6.3.1), pero sí un aviso: una recomendación de troceado se mide sobre los documentos y las preguntas propias.
HTML de la SEC y XBRL: cuando el dato ya está estructurado
Los documentos tipo SEC de los datos del nivel 6 están en HTML, como los reales: títulos <h2> por Item, tablas
<table> y notas <sup>. Con BeautifulSoup el troceado por estructura es directo: un trozo por <h2>, con las tablas
linealizadas (aquí no hay Markdown que conservar) y la cabecera de contexto con el emisor, el formulario y el periodo.
from bs4 import BeautifulSoup
fil = pd.read_parquet("filings6_documentos.parquet")
d = fil.set_index("id_doc").loc["D000162"]
def tabla_html(tb):
filas = [[x.get_text(" ", strip=True)
for x in tr.find_all(["th", "td"])]
for tr in tb.find_all("tr")]
cab = filas[0]
return " ".join(f"{f[0]}: " + "; ".join(
f"{h} = {v or '-'}" for h, v in zip(cab[1:], f[1:])) + "."
for f in filas[1:])
sopa = BeautifulSoup(d.texto, "html.parser")
for h2 in sopa.find_all("h2"):
partes = []
for el in h2.find_next_siblings():
if el.name == "h2":
break
partes.append(tabla_html(el) if el.name == "table"
else el.get_text(" ", strip=True))
print(f"{d.emisor} > {d.tipo} {d.periodo_fin:%Y-%m-%d} >",
h2.get_text(strip=True))
print(" ", " ".join(partes)[:110])Rapsa Insurance Inc. > 10-Q 2019-03-31 > Part I, Item 1. Financial Statements
Revenue: Three months = 8,983.5; 3 months year to date = 8,983.5. Net income (loss) (1): Three months = 21.9;
Rapsa Insurance Inc. > 10-Q 2019-03-31 > Part I, Item 2. Management's Discussion and Analysis
Revenue for the three months ended March 31, 2019 was $8,983.5 million. Assets under management at quarter end
Rapsa Insurance Inc. > 10-Q 2019-03-31 > Part II, Item 1A. Risk Factors
There have been no material changes to the risk factors disclosed in our most recent Annual Report on Form 10-Pero para una pregunta como «¿cuáles fueron los ingresos de Rapsa en el cuarto trimestre de 2019?» el texto es el
camino equivocado. Las empresas de EE. UU. presentan sus estados financieros con etiquetas XBRL: cada cifra es un
hecho con su concepto (us-gaap:Revenues), su unidad, su periodo y, en los datos del curso, la fecha y hora de la
presentación. La SEC los sirve por API (companyconcept, companyfacts y frames)1Nota 1 · SEC, «EDGAR Application Programming Interfaces», consultado el 2026-10-23. Según esa página, frames agrega un
hecho por entidad usando la presentación más reciente que mejor encaja con el periodo pedido: es cómodo para una
sección cruzada, pero no es point-in-time.. Con hechos, una pregunta
numérica es un filtro, no una búsqueda.
Dos detalles que el texto no resuelve y los hechos sí:
- El cuarto trimestre no se publica. El 10-K da el año y el 10-Q de septiembre, el acumulado de nueve meses: el trimestre es la resta.
- Las enmiendas reescriben cifras. Un 10-Q/A cambia el beneficio de un trimestre ya publicado. Un backtest tiene que usar el valor conocido en cada fecha (point-in-time), filtrando por la fecha de presentación.
xb = pd.read_parquet("filings6_xbrl.parquet").merge(
fil[["id_doc", "ticker"]], on="id_doc")
def hecho(ticker, concepto, inicio, fin, a_fecha):
"""Último valor de un hecho presentado hasta a_fecha (UTC)."""
m = ((xb.ticker == ticker) & (xb.concepto == concepto)
& (xb.periodo_inicio == inicio) & (xb.periodo_fin == fin)
& (xb.ts_presentacion <= pd.Timestamp(a_fecha, tz="UTC")))
s = xb[m].sort_values("ts_presentacion")
return None if s.empty else s.valor.iloc[-1] / 1e6
ing = "us-gaap:Revenues"
anual = hecho("RAPA", ing, "2019-01-01", "2019-12-31", "2020-06-30")
nueve = hecho("RAPA", ing, "2019-01-01", "2019-09-30", "2020-06-30")
ventas = pd.read_csv("acciones6_ventas.csv")
oficial = ventas.query("ticker == 'RAPA' and "
"fin_trimestre == '2019-12-31'").ventas.item()
print(f"RAPA 4T 2019: {anual:.1f} - {nueve:.1f} = {anual - nueve:.1f}"
f" (ventas del trimestre: {oficial})")
bn = "us-gaap:NetIncomeLoss"
for fecha in ["2020-09-30", "2020-12-31"]:
print("BEBA, beneficio 2T 2020 conocido el", fecha, ":",
hecho("BEBA", bn, "2020-04-01", "2020-06-30", fecha))RAPA 4T 2019: 36104.6 - 26958.9 = 9145.7 (ventas del trimestre: 9145.7)
BEBA, beneficio 2T 2020 conocido el 2020-09-30 : 224.5
BEBA, beneficio 2T 2020 conocido el 2020-12-31 : 187.1La resta da exactamente las ventas del trimestre. Y el beneficio del segundo trimestre de 2020 de BEBA era de 224,5 millones hasta que una enmienda de noviembre lo dejó en 187,1. Un RAG sobre el texto de los documentos de hoy respondería 187,1 a una pregunta hecha con fecha de septiembre de 2020: sesgo de anticipación (lección 6.2.5) por la vía del índice. La regla práctica es repartir el trabajo:
| Pregunta | Mejor vía | Por qué |
|---|---|---|
| Cifra de un estado financiero de EE. UU. | Hechos XBRL | Exacta, con periodo y fecha de presentación |
| Cifra derivada (4.º trimestre, márgenes) | Hechos XBRL y cálculo en código | La aritmética no se delega en el modelo |
| Matices, políticas, riesgos, previsiones | Texto troceado por estructura | No hay etiqueta XBRL, o la hay pero sin el matiz |
| Comparar muchos documentos | Tabla estructurada o una búsqueda por documento | El top-k de una sola búsqueda no da cinco documentos |
| Emisores sin XBRL (folletos, KID, informes en PDF) | Texto troceado, tablas enteras | Única vía |
Errores típicos
- Medir a ojo. Probar cinco preguntas y elegir la estrategia «que se ve mejor». Sin pasajes de referencia con offsets no hay forma de saber si un cambio mejora o empeora; con ellos, cada cambio se mide en segundos.
- Comparar a igual k. Chunks más grandes ganan recall por tamaño. La comparación es a igual presupuesto de contexto.
- Trocear sin cabecera. En documentos de plantilla (informes de varios años, folletos de una misma gestora) el trozo debe decir de qué documento y sección es, o filtrarse por metadatos.
- Partir tablas y separar notas. La fila sin cabecera de columnas y la cifra sin su nota son las dos formas más comunes de respuestas «correctas» que no lo son.
- Perder los offsets. Si el índice solo guarda el texto del trozo, no se puede citar el documento original ni mapear a pasajes; con los offsets, cada respuesta apunta a un tramo verificable (lección 6.3.3).
- Pedir al texto lo que está en XBRL. Y, al revés, olvidar la fecha de presentación: un índice construido hoy con documentos enmendados mezcla el futuro en el pasado.
- Generalizar una receta. «Linealiza las tablas», «usa chunks de 512 tokens», «usa embeddings densos»: en estos documentos, las tres empeoran o no mejoran. Se mide con el corpus y las preguntas propios.
Ejercicios
- Cambia el tamaño del troceado fijo a 300 y a 1.000 caracteres (solape del 10 %) y compara su recall con el de estructura a unos 1.200 caracteres entregados. ¿Algún tamaño fijo alcanza al de estructura?
Solución
for n in (300, 500, 1000):
tr = con_texto([c for d in TEXTO for c in fijo(d, n, n // 10)],
True)
rk = ranking(tr)
mejor = max(((k, medir(tr, rk, k)) for k in range(1, 11)),
key=lambda km: (km[1].car.mean() <= 1300,
km[1].car.mean()))
k, m = mejor
print(f"fijo {n:4d}: k={k:2d} {m.car.mean():6.0f} caracteres,"
f" recall {m.recall.mean():.3f}")fijo 300: k= 3 1114 caracteres, recall 0.772
fijo 500: k= 2 1135 caracteres, recall 0.687
fijo 1000: k= 1 1019 caracteres, recall 0.559Con como mucho 1.300 caracteres entregados, ninguna ventana fija llega al 87,3 % del troceado por estructura con k = 3. Las ventanas pequeñas cortan más tablas y filas; las grandes caben pocas en el presupuesto. El tamaño óptimo de un troceado fijo depende del documento, y por eso conviene medir antes de fijarlo.
- Filtra por metadatos antes de buscar: para cada pregunta, quédate solo con los trozos de su documento (la columna
id_docde la pregunta; con varios, todos ellos) y mide el recall@3 de las tres estrategias sin cabecera. ¿Cuánto del efecto de la cabecera recupera el filtro?
Solución
def ranking_filtrado(trozos, k=3):
rk = []
for q in resp.itertuples():
ids = set(q.id_doc.split(";"))
sub = [j for j, c in enumerate(trozos) if c["doc"] in ids]
bm = BM25Okapi([palabras(trozos[j]["texto"]) for j in sub])
orden = np.argsort(-bm.get_scores(palabras(q.pregunta)),
kind="stable")[:k]
rk.append([sub[j] for j in orden])
return rk
for nombre in ESTRATEGIAS:
tr = TROZOS[(nombre, False)]
m = medir(tr, ranking_filtrado(tr), 3)
print(f"{nombre:10s} sin cabecera, filtrado: recall@3"
f" {m.recall.mean():.3f}")fijo sin cabecera, filtrado: recall@3 0.763
recursivo sin cabecera, filtrado: recall@3 0.822
estructura sin cabecera, filtrado: recall@3 0.837Filtrar por documento recupera casi todo el efecto de la cabecera, y el troceado por estructura sigue por delante. En un sistema real el filtro necesita saber de qué documento habla la pregunta (emisor y ejercicio), que se extrae de la propia pregunta con reglas o con un LLM (lección 6.2.2). La cabecera es más robusta cuando la pregunta no lo dice.
- Con los hechos XBRL, calcula los ingresos del cuarto trimestre de 2022 de todas las empresas con 10-K de ese año,
conocidos el 2023-06-30, y compáralos con
acciones6_ventas.csv. ¿Cuántos coinciden al décimo de millón?
Solución
tick = sorted(xb[(xb.forma == "10-K") & (xb.concepto == ing)
& (xb.periodo_fin == "2022-12-31")].ticker.unique())
dif = []
for tk in tick:
a = hecho(tk, ing, "2022-01-01", "2022-12-31", "2023-06-30")
n9 = hecho(tk, ing, "2022-01-01", "2022-09-30", "2023-06-30")
v = ventas.query("ticker == @tk and fin_trimestre == '2022-12-31'")
dif.append(round(a - n9, 1) - v.ventas.item())
dif = np.round(dif, 1)
print(len(tick), "empresas;", int((dif == 0).sum()), "coinciden;",
"máxima diferencia", np.abs(dif).max())60 empresas; 60 coinciden; máxima diferencia 0.0Coinciden todas: en los datos del curso los hechos XBRL salen de las mismas ventas que el fichero de ventas. Con datos reales, la resta también es la vía estándar, pero conviene comprobar que el 10-K y el 10-Q usan el mismo periodo fiscal (no todas las empresas cierran el 31 de diciembre) y que ninguna enmienda posterior a la fecha de consulta se cuela en el cálculo.
Footnotes
-
SEC, «EDGAR Application Programming Interfaces», consultado el 2026-10-23. Según esa página,
framesagrega un hecho por entidad usando la presentación más reciente que mejor encaja con el periodo pedido: es cómodo para una sección cruzada, pero no es point-in-time. ↩
Respuesta plegada en cada pregunta
Autoevaluación
Con BM25 y k = 3, el troceado por estructura sin cabecera recupera el 25,4 % de los pasajes de oro y con la cabecera «documento > sección», el 87,3 %. ¿Por qué tanta diferencia?
- APorque la cabecera alarga el chunk y BM25 premia los textos largos
- BPorque sin cabecera el chunk de una tabla no dice de qué empresa ni de qué año es, y las preguntas sí lo dicen: hay ocho informes casi iguales
- CPorque la cabecera traduce el texto al inglés
- DPor azar: con 246 preguntas la diferencia no es significativa
Ver respuesta
Respuesta B. Porque sin cabecera el chunk de una tabla no dice de qué empresa ni de qué año es, y las preguntas sí lo dicen: hay ocho informes casi iguales
Los informes de 2023 y 2024 de las cuatro empresas comparten plantilla. Una fila «Ventas | 13.668,9» es indistinguible entre ocho documentos si el chunk no lleva el nombre de la empresa y el año. BM25 normaliza por longitud, así que la cabecera no ayuda por alargar, sino por aportar las palabras que la pregunta usa para decir de qué documento habla.
¿Por qué hay que comparar las estrategias a igual número de caracteres recuperados y no solo a igual k?
- APorque con chunks más grandes cada uno cubre más pasajes: a igual k, el troceado de chunks grandes gana recall a costa de meter más texto en el contexto del modelo
- BPorque BM25 solo funciona con chunks del mismo tamaño
- CPorque el recall no depende de k
- DNo hace falta: a igual k la comparación es justa
Ver respuesta
Respuesta A. Porque con chunks más grandes cada uno cubre más pasajes: a igual k, el troceado de chunks grandes gana recall a costa de meter más texto en el contexto del modelo
Con k = 10 el troceado fijo llega al 95,7 % de recall, más que el de estructura (91,9 %), pero entrega 5.604 caracteres de media frente a 3.851. A unos 1.200 caracteres, el de estructura está en el 87,3 % y el fijo entre el 68,7 % y el 79,8 %. El contexto cuesta tokens, tiempo y ruido para el modelo.
Un chunk de tamaño fijo cubre el 60 % de los caracteres de la tabla de magnitudes principales. ¿Qué dice eso de si contiene la respuesta a «¿cuál fue la deuda financiera neta?»?
- AQue la contiene con un 60 % de probabilidad
- BQue la contiene seguro, porque el umbral es del 50 %
- CQue no la contiene
- DNada seguro: el recall por solapamiento dice qué parte del pasaje de oro está en el contexto, no si está la fila que importa; hay que mirarlo también con la respuesta
Ver respuesta
Respuesta D. Nada seguro: el recall por solapamiento dice qué parte del pasaje de oro está en el contexto, no si está la fila que importa; hay que mirarlo también con la respuesta
El umbral del 50 % es una convención para contar un pasaje como recuperado. La fila de la deuda puede estar en el 40 % que se quedó en el chunk siguiente. Por eso conviene que el troceado no parta tablas y que la evaluación de extremo a extremo (6.3.3) mire la respuesta, no solo los pasajes.
Para responder «¿cuáles fueron las ventas del cuarto trimestre de 2019?» de una empresa de EE. UU., ¿qué es más fiable?
- ABuscar con BM25 el párrafo del 10-K que habla del cuarto trimestre
- BPedírselo al LLM sin contexto
- CRestar a los ingresos anuales del 10-K (XBRL) los de los nueve meses del 10-Q del tercer trimestre, con los hechos conocidos en la fecha de la consulta
- DBuscar el 10-Q del cuarto trimestre
Ver respuesta
Respuesta C. Restar a los ingresos anuales del 10-K (XBRL) los de los nueve meses del 10-Q del tercer trimestre, con los hechos conocidos en la fecha de la consulta
El cuarto trimestre no se publica: el 10-K da el año y el 10-Q de septiembre, el acumulado de nueve meses. Con los hechos XBRL la resta es exacta (9.145,7 millones para RAPA, igual que la cifra de ventas del trimestre) y se puede hacer point-in-time, filtrando por la fecha de presentación.
¿Qué hace el troceado por estructura de esta lección con la nota «² Deuda financiera neta = …»?
- ALa descarta, porque las notas no tienen información
- BLa une al chunk de cada bloque que cita la marca ², además de mantener su posición original en los offsets
- CLa convierte en un chunk propio sin cabecera
- DLa traduce a XBRL
Ver respuesta
Respuesta B. La une al chunk de cada bloque que cita la marca ², además de mantener su posición original en los offsets
La nota matiza una cifra de la tabla (aquí, que la deuda neta no incluye arrendamientos). Separada de la tabla, una pregunta por la deuda neta recupera la cifra sin el matiz. El chunk guarda dos tramos de offsets, la tabla y la nota, para poder mapearlo a los dos pasajes canónicos.
Ideas clave
- El retriever devuelve chunks, no documentos: una tabla partida o una cifra sin su nota al pie no se arreglan después con un modelo mejor.
- Medir exige una referencia: los pasajes canónicos con offsets permiten mapear cualquier chunk propio a los pasajes de oro de cada pregunta y calcular el recall@k sin juicios a ojo.
- En estos documentos lo que más pesa es la cabecera de contexto (documento y sección): con BM25 y k = 3, del 22–25 % al 80–87 % de recall en las tres estrategias.
- A igual presupuesto de contexto gana el troceado por estructura (87,3 % con unos 1.200 caracteres); con mucho contexto el fijo le alcanza, pero a costa de entregar un 45 % más de texto.
- Linealizar las tablas no mejora la recuperación aquí, ni con BM25 ni con un modelo de embeddings inglés pequeño; las preguntas que cruzan varios documentos (comparaciones) dan resultados inestables con cualquier troceado.
- Las cifras que existen como hechos XBRL se piden a los datos estructurados, con su periodo y su fecha de presentación (point-in-time), no al texto.