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).

Fig. — Recall de los pasajes de oro (246 preguntas, BM25, trozos con cabecera de contexto) frente a los caracteres entregados, con k de 1 a 10 trozos por estrategia de troceado
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

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.

DocumentosTroceadoÍndicePreguntaBúsqueda top-kContextoLLMRespuesta
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.

IN [01]python
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])
OUT [01]Documentos, pasajes canónicos y preguntas
{'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,8

texto[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.

IN [02]python
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])
OUT [02]Una respuesta repartida en dos sitios
¿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 equivalentes

Tres 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.

IN [03]python
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]))
OUT [03]Troceado de tamaño fijo
(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.

IN [04]python
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")))
OUT [04]Troceado recursivo por separadores
23 25

Por 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:

  1. Los títulos no son trozos: dan la sección de lo que sigue.
  2. Una tabla es siempre un trozo entero, aunque supere el máximo.
  3. Un bloque que cita una nota (¹, ²…) se une a la nota: el trozo tiene dos tramos de offsets.
  4. Los párrafos consecutivos de la misma sección se juntan hasta 800 caracteres.
sínosínosínoBloque¿Título?Fija la sección¿Es una nota?Espera su cita¿Tabla o cita?Trozo con su notaSe une al anterior
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]
IN [05]python
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"])
OUT [05]Troceado por estructura: secciones, tablas y notas
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.

Fig. — Informe anual 2023 de Busmar Hogar, caracteres 1.300 a 2.400: pasajes canónicos (texto, títulos, la tabla de magnitudes y sus dos notas) y los trozos de cada estrategia; tonos alternos = trozos distintos

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.

IN [06]python
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])
OUT [06]Texto indexado, con o sin cabecera de contexto
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,2

De 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 kk 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:

recall@k=1∣O∣∑p∈O1[∣p∩(c1∪⋯∪ck)∣∣p∣≥0,5]\text{recall@}k = \frac{1}{|O|}\sum_{p \in O} \mathbf{1}\left[\frac{|p \cap (c_1 \cup \dots \cup c_k)|}{|p|} \ge 0{,}5\right]

con OO los pasajes de oro y c1,…,ckc_1, \dots, c_k los tramos de los kk 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.

IN [07]python
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))
OUT [07]Mapeo por offsets: qué parte de cada pasaje cubre un trozo
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.0

En 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.

IN [08]python
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)
OUT [08]BM25 y recall@k de las seis variantes
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.871

Dos 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 kk 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 kk de 1 a 10. Es la figura de la cabecera.

IN [09]python
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"])
OUT [09]Recall frente a los caracteres entregados (k de 1 a 10)
                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.915

Hasta 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.

IN [10]python
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())
OUT [10]Recall@5 por tipo de pregunta
               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
Fig. — Recall@5 por tipo de pregunta (entre paréntesis, cuántas) de los tres troceados con cabecera de contexto: cuadrado, fijo; triángulo, recursivo; círculo destacado, estructura
  • 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).

IN [11]python
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)
OUT [11]Tablas en Markdown o linealizadas, con BM25 y con MiniLM
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.344

En 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.

IN [12]python
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])
OUT [12]Troceado de un 10-Q en HTML con BeautifulSoup
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.
IN [13]python
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))
OUT [13]Hechos XBRL point-in-time: el cuarto trimestre y una enmienda
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.1

La 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:

PreguntaMejor víaPor qué
Cifra de un estado financiero de EE. UU.Hechos XBRLExacta, con periodo y fecha de presentación
Cifra derivada (4.º trimestre, márgenes)Hechos XBRL y cálculo en códigoLa aritmética no se delega en el modelo
Matices, políticas, riesgos, previsionesTexto troceado por estructuraNo hay etiqueta XBRL, o la hay pero sin el matiz
Comparar muchos documentosTabla estructurada o una búsqueda por documentoEl 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

  1. 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
IN [14]python
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}")
OUT [14]Otros tamaños del troceado fijo
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.559

Con 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.

  1. Filtra por metadatos antes de buscar: para cada pregunta, quédate solo con los trozos de su documento (la columna id_doc de 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
IN [15]python
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}")
OUT [15]Filtrar por documento antes de buscar
fijo       sin cabecera, filtrado: recall@3 0.763
recursivo  sin cabecera, filtrado: recall@3 0.822
estructura sin cabecera, filtrado: recall@3 0.837

Filtrar 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.

  1. 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
IN [16]python
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())
OUT [16]Cuarto trimestre de 2022 para todas las empresas
60 empresas; 60 coinciden; máxima diferencia 0.0

Coinciden 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

  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. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. 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.

  2. ¿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.

  3. 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.

  4. 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.

  5. ¿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.

Recursos