---
title: "6.3.2 Chunking de documentos financieros"
description: "Troceado fijo, recursivo y por estructura de informes con tablas y notas al pie: chunks mapeados a pasajes por offsets, recall@k medido y XBRL."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/chunking-de-documentos-financieros/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/chunking-de-documentos-financieros.png"
---

# 6.3.2 · 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).

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.3 · RAG financiero
- Duración: 65 min
- Requisitos: [6.3.1 · Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant](https://pythonparatrading.com/curso-python-trading/nivel-6/embeddings-y-bases-vectoriales/), [6.1.2 · Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales](https://pythonparatrading.com/curso-python-trading/nivel-6/fuentes-de-texto-edgar-cnmv-transcripciones-y-redes/)

## Al terminar esta lección

- Implementar un troceado de tamaño fijo, uno recursivo por separadores y uno por estructura que respeta títulos, tablas y notas al pie.
- Mapear cualquier chunk a los pasajes canónicos de un documento por offsets de caracteres y medir el recall@k contra las preguntas con pasajes de oro.
- Comparar estrategias a igual presupuesto de contexto y por tipo de pregunta, y medir el efecto de la cabecera de contexto (documento y sección).
- Convertir tablas HTML y Markdown en texto recuperable y decidir cuándo una cifra se pide a los hechos XBRL point-in-time en lugar de al texto.

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

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

```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])
```
```output
{'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.

```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])
```
```output
¿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.

```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]))
```
```output
(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.

```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")))
```
```output
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.

```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]
```

```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"])
```
```output
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.* Cuatro filas sobre la misma franja de texto. Arriba, los pasajes canónicos: texto de la carta, título y texto del modelo de negocio, título de magnitudes, la tabla de 1.577 a 1.914, la nota 1 y la nota 2 hasta 2.119, y el principio de la tabla de información bursátil. El troceado fijo pone ventanas de 500 caracteres solapadas: una termina en 1.850, en mitad de la tabla (línea destacada), y la siguiente empieza en 1.800. El recursivo corta en los límites de los bloques, pero junta la tabla con la nota 1 y deja la nota 2 con la tabla siguiente. El de estructura hace un trozo con la tabla y sus dos notas.

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.

```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])
```
```output
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 $k$ 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:

$$
\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 $O$ los pasajes de oro y $c_1, \dots, c_k$ los tramos de los $k$ 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.

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

```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)
```
```output
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 $k$ no es justo para nadie: cada estrategia
entrega trozos de tamaño distinto.

<aside class="callout" data-kind="nota">

El 50 % de caracteres es una convención para decidir si un pasaje está «recuperado». No garantiza que la fila que
pide la pregunta esté dentro: en una tabla partida, la mitad cubierta puede ser la otra. La evaluación de extremo a
extremo de la lección 6.3.3 mira la respuesta, no solo los pasajes.

</aside>

## 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 $k$ de 1 a 10. Es la figura de la cabecera.

```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"])
```
```output
                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.

```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())
```
```output
               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.* Siete filas. Texto: fijo 92 %, recursivo 93 %, estructura 99 %. Tabla: 81 %, 88 % y 88 %. Nota al pie: los tres 100 %. Texto y nota: 69 %, 75 % y 75 %. Tabla y nota: 100 %, 89 % y 100 %. Cálculo: 50 %, 0 % y 0 %. Comparación: 57 %, 81 % y 20 %.

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

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

```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])
```
```output
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`)[^1]. 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.

```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))
```
```output
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:

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

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

## 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.**

```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}")
```
```output
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.

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

```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}")
```
```output
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.

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

```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())
```
```output
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.

## 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?

   - A. Porque la cabecera alarga el chunk y BM25 premia los textos largos
   - 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
   - C. Porque la cabecera traduce el texto al inglés
   - D. Por azar: con 246 preguntas la diferencia no es significativa

   *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?

   - 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
   - B. Porque BM25 solo funciona con chunks del mismo tamaño
   - C. Porque el recall no depende de k
   - D. No hace falta: a igual k la comparación es justa

   *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?»?

   - A. Que la contiene con un 60 % de probabilidad
   - B. Que la contiene seguro, porque el umbral es del 50 %
   - C. Que no la contiene
   - 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

   *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?

   - A. Buscar con BM25 el párrafo del 10-K que habla del cuarto trimestre
   - B. Pedírselo al LLM sin contexto
   - 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
   - D. Buscar el 10-Q del cuarto trimestre

   *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 = …»?

   - A. La descarta, porque las notas no tienen información
   - B. La une al chunk de cada bloque que cita la marca ², además de mantener su posición original en los offsets
   - C. La convierte en un chunk propio sin cabecera
   - D. La traduce a XBRL

   *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

### Documentación

- [rank_bm25: implementaciones de BM25 en Python (GitHub)](https://github.com/dorianbrown/rank_bm25) · github.com
- [LlamaIndex: módulos de node parsers (troceadores por frases, Markdown, HTML…)](https://docs.llamaindex.ai/en/stable/module_guides/loading/node_parsers/modules/) · docs.llamaindex.ai
- [SEC: EDGAR application programming interfaces (companyconcept, companyfacts y frames de XBRL)](https://www.sec.gov/search-filings/edgar-application-programming-interfaces) · sec.gov
- [XBRL International: introducción a XBRL](https://www.xbrl.org/the-standard/what/an-introduction-to-xbrl/) · xbrl.org
- [Beautiful Soup 4: documentación](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) · crummy.com

### Datos

- [Datos del nivel 6: documentos para RAG con sus pasajes canónicos (Parquet)](https://pythonparatrading.com/curso/datos/nivel-6/rag6_pasajes.parquet) · Descarga · rag6_pasajes.parquet
- [Datos del nivel 6: preguntas de evaluación con pasajes de oro (CSV)](https://pythonparatrading.com/curso/datos/nivel-6/rag6_preguntas.csv) · Descarga · rag6_preguntas.csv

Lección anterior: [6.3.1 · Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant](https://pythonparatrading.com/curso-python-trading/nivel-6/embeddings-y-bases-vectoriales/)

Lección siguiente: [6.3.3 · RAG sobre informes, folletos y KID, y cómo evaluarlo](https://pythonparatrading.com/curso-python-trading/nivel-6/rag-sobre-informes-y-su-evaluacion/)
