Módulo 4: ChromaDB Setup y Configuración

Cápsula 10: Chunking de documentos — el problema del documento entero

Descripción de la cápsula

Tienes un manual técnico de 50 páginas. Tu primera intuición: "lo embebo entero, ChromaDB lo busca, el LLM lo lee". Suena lógico — y casi siempre falla.

El problema no es de capacidad técnica. OpenAI text-embedding-3-small acepta hasta 8191 tokens por input (más de 30 páginas). El problema es de resolución de búsqueda: un vector de 1536 dimensiones que representa 50 páginas es un promedio difuso de todo. Cuando el usuario pregunta "¿cómo configuro HNSW?", ese vector promedio no apunta a la sección específica que responde — apunta al "tema general del manual".

La solución se llama chunking: dividir cada documento largo en piezas más pequeñas, embebir cada pieza por separado, y dejar que el retrieval encuentre la pieza exacta. Suena simple. Las decisiones (qué tamaño, qué overlap, dividir por caracteres o por estructura) son las que separan un RAG mediocre de uno que devuelve respuestas precisas.

Esta cápsula te da el modelo mental, las decisiones por defecto que funcionan en 80% de los casos, y los criterios para ajustarlas cuando tu dominio lo exige.

Al finalizar esta cápsula serás capaz de:

  • ✅ Explicar por qué embebir un documento largo entero degrada el retrieval
  • ✅ Aplicar chunking con RecursiveCharacterTextSplitter justificando los parámetros
  • ✅ Decidir chunk_size y chunk_overlap para un dominio específico
  • ✅ Comparar tres estrategias de chunking (fijo, recursivo, semántico) y elegir la apropiada
  • ✅ Configurar metadata de chunks para preservar trazabilidad al documento original
  • ✅ Anticipar el error más sutil: chunks que parten oraciones a la mitad y rompen el sentido

Tiempo estimado: 35-45 minutos


Por qué un documento entero falla

Imagina dos documentos en tu collection:

Documento A (1500 palabras): manual técnico que cubre instalación de ChromaDB en la sección 1, configuración de HNSW en la sección 2, queries básicas en la sección 3, y troubleshooting en la sección 4.

Documento B (1500 palabras): otro manual sobre Pinecone que cubre los mismos cuatro temas en sus respectivas secciones.

Ambos se embeben como un solo vector cada uno. ¿Qué representa ese vector?

Geométricamente, el embedding de un texto largo es algo así como el "centro de gravedad" semántico de todo el contenido. Si el documento toca cuatro temas distintos, el vector resultante queda en algún punto central que no representa con precisión a ninguno de los cuatro.

El experimento que lo hace evidente

Carga estos dos documentos en una collection y haz una query muy específica:

# experimento_documento_completo.py
import chromadb
from chromadb.utils import embedding_functions
import os

openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.getenv("OPENAI_API_KEY"),
    model_name="text-embedding-3-small"
)

client = chromadb.PersistentClient(path="./chroma_chunking_test")
collection = client.get_or_create_collection(
    name="docs_completos",
    embedding_function=openai_ef
)

doc_a = """
Capítulo 1: Instalación de ChromaDB.
ChromaDB se instala con pip install chromadb. Requiere Python 3.8+.

Capítulo 2: Configuración de HNSW.
HNSW tiene dos parámetros principales: M (16 default, controla conexiones del grafo)
y construction_ef (100 default, controla calidad del build). Para production con
alta accuracy, usar M=32 y construction_ef=200.

Capítulo 3: Queries básicas.
collection.query(query_texts=["..."], n_results=10) devuelve los k vectores más similares.

Capítulo 4: Troubleshooting.
Si query_embeddings no matchean documents, verifica que el embedding_function sea consistente.
"""

doc_b = """
Capítulo 1: Instalación de Pinecone.
Pinecone requiere registro en pinecone.io y obtener API key. pip install pinecone-client.

Capítulo 2: Configuración de pods.
Los pods de Pinecone tienen tipos s1 (storage), p1 (performance), p2 (high performance).

Capítulo 3: Queries con Pinecone.
index.query(vector=[...], top_k=10) devuelve resultados con metadata.

Capítulo 4: Troubleshooting.
Si la latencia es alta, verifica el tipo de pod y la región del cluster.
"""

collection.add(
    documents=[doc_a, doc_b],
    ids=["doc_a", "doc_b"],
    metadatas=[{"product": "chromadb"}, {"product": "pinecone"}]
)

# Query muy específica sobre HNSW (solo está en doc_a, sección 2)
query = "What are the recommended HNSW parameters for production with high accuracy?"
results = collection.query(query_texts=[query], n_results=2)

for doc, dist in zip(results['documents'][0], results['distances'][0]):
    first_line = doc.strip().split('\n')[0]
    print(f"Distance: {dist:.3f} | Empieza con: {first_line}")

Output esperado:

Distance: 0.413 | Empieza con: Capítulo 1: Instalación de ChromaDB.
Distance: 0.587 | Empieza con: Capítulo 1: Instalación de Pinecone.

¿Qué pasó? La query era 100% específica de la sección 2 de doc_a, pero ChromaDB devolvió ambos documentos completos. El score del relevante (0.413) no es malo, pero la respuesta que el LLM va a leer son los 4 capítulos de ChromaDB enteros, no solo la sección sobre HNSW. El context window se llena de información irrelevante (instalación, queries, troubleshooting) que diluye la sección que realmente importa.

Y peor: la diferencia entre el doc relevante (0.413) y el irrelevante (0.587) es estrecha. Un threshold mal calibrado deja entrar a Pinecone también — y el LLM termina leyendo dos manuales en su context window cuando debería leer dos párrafos.

El insight pedagógico

El embedding promedia el contenido. Si tu documento cubre cuatro temas, el vector apunta al "centroide de los cuatro temas", no a ninguno. Para que el retrieval funcione bien, cada vector debe representar una idea coherente y compacta — idealmente, una sola pregunta que ese chunk responde.

Por eso chunking no es un detalle de implementación. Es la decisión arquitectónica que define la resolución de tu retrieval.


Las tres estrategias de chunking

1. Fixed-size chunking (chunking fijo por caracteres)

La estrategia más simple: divide el texto cada N caracteres. Sin entender estructura, sin respetar palabras.

def fixed_chunk(text: str, size: int = 500) -> list[str]:
    return [text[i:i+size] for i in range(0, len(text), size)]

texto = "ChromaDB usa HNSW por default. Los parámetros son M=16 y construction_ef=100..."
chunks = fixed_chunk(texto, size=30)
# ['ChromaDB usa HNSW por default.',
#  ' Los parámetros son M=16 y con',  ← parte una palabra
#  'struction_ef=100...']

Ventaja: trivial de implementar, predecible.

Problema fatal: parte palabras, oraciones y conceptos a la mitad. El chunk del medio del ejemplo (Los parámetros son M=16 y con) embebido aisladamente es ruido semántico — no significa lo que dice.

Cuándo usarlo: prácticamente nunca. Existe solo como contraste.

2. Recursive character chunking (lo que vas a usar el 80% del tiempo)

RecursiveCharacterTextSplitter (de la librería LangChain) intenta dividir respetando la estructura natural del texto: primero por dobles saltos de línea (separadores de párrafo), luego por saltos de línea simples (separadores de oración), luego por espacios, y solo en último recurso parte palabras.

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,        # Objetivo de tamaño en caracteres
    chunk_overlap=50,      # Overlap entre chunks consecutivos
    separators=["\n\n", "\n", ". ", " ", ""],  # Orden de preferencia
    length_function=len
)

chunks = splitter.split_text(documento_largo)

Cómo opera el algoritmo (mental model):

  1. Intenta partir el texto en \n\n (párrafos). Si los pedazos resultan ≤500 chars, listo.
  2. Si algún pedazo sigue siendo >500, vuelve a partirlo con \n (líneas).
  3. Si sigue siendo >500, prueba con ". " (oraciones).
  4. Si sigue siendo >500, parte por espacios (palabras).
  5. Si todavía >500 (palabra rarísima), parte por carácter.

El resultado: chunks que respetan estructura natural del texto cuando es posible. Una oración no se parte a la mitad excepto en casos extremos.

Cuándo usarlo: primer default para casi todo. Cubre 80% de los casos sin pensar mucho.

3. Semantic chunking (cuando importa la coherencia conceptual)

En vez de tamaño fijo, usa embeddings para encontrar puntos donde el significado cambia. Cuando dos oraciones consecutivas tienen embeddings muy distintos, parte ahí.

# Pseudo-código del algoritmo
def semantic_chunk(text: str, threshold: float = 0.7):
    sentences = split_into_sentences(text)
    embeddings = [embed(s) for s in sentences]

    chunks = []
    current_chunk = [sentences[0]]
    for i in range(1, len(sentences)):
        similarity = cosine_similarity(embeddings[i-1], embeddings[i])
        if similarity < threshold:
            # Cambio de tema → nuevo chunk
            chunks.append(' '.join(current_chunk))
            current_chunk = [sentences[i]]
        else:
            current_chunk.append(sentences[i])
    chunks.append(' '.join(current_chunk))
    return chunks

Ventaja: cada chunk es semánticamente coherente.

Costo: debes embebir cada oración solo para decidir dónde partir. Triplica el costo de ingestion. Tiempo de procesamiento sube notablemente.

Cuándo usarlo: dominios donde la diferencia de calidad justifica el costo (legal, médico, papers académicos donde un chunk mal cortado lleva a respuestas peligrosamente erróneas).

Cuándo no usarlo: la mayoría de los casos. Empieza con recursivo, mide, y solo cambia si la calidad no alcanza.


Las dos decisiones críticas: chunk_size y chunk_overlap

chunk_size: cuántos caracteres por chunk

El tamaño correcto depende de la densidad de información de tu dominio:

Dominiochunk_size sugeridoRazón
Documentación técnica densa (API docs, código)300-500Cada concepto es compacto; chunks pequeños evitan diluir
Artículos de blog técnicos500-800Balance entre contexto y precisión
Manuales de usuario800-1200Necesitan más contexto para que la respuesta tenga sentido
Papers académicos1000-1500Argumentos largos, partir muy pequeño rompe coherencia
Transcripciones de conversación200-400Cambios de tema frecuentes

Default razonable si no sabes: chunk_size=500. Funciona bien para texto general en español o inglés. Si tus tokens son ~4 caracteres en promedio (regla aproximada para inglés), 500 chars ≈ 125 tokens — bien por debajo del límite del modelo de embeddings.

chunk_overlap: cuántos caracteres comparten chunks consecutivos

El overlap existe por una razón específica: evitar que una respuesta caiga partida entre dos chunks y ninguno la capture completa.

Sin overlap (chunk_size=100):
Chunk 1: "...para configurar HNSW con alta accuracy se recomienda M=32 y"
Chunk 2: "construction_ef=200, valores que mejoran recall en producción..."
                    ↑
        La respuesta a "¿qué M usar?" cae partida.
        Ningún chunk individual contiene "M=32 y construction_ef=200" completo.

Con overlap=50:
Chunk 1: "...para configurar HNSW con alta accuracy se recomienda M=32 y"
Chunk 2: "se recomienda M=32 y construction_ef=200, valores que mejoran..."
              ↑                ↑
        Los últimos 50 chars del chunk 1 reaparecen al inicio del chunk 2.
        Ahora el chunk 2 contiene "M=32 y construction_ef=200" completo.

Regla práctica:

  • chunk_overlap = chunk_size * 0.1 (10% es buen default)
  • Para chunk_size=500, usa chunk_overlap=50
  • Para chunk_size=1000, usa chunk_overlap=100

Trade-off: más overlap = más chunks duplicados parcialmente = más costo de embeddings y storage. 10% es el punto donde la cobertura mejora notablemente sin inflar el costo.

No hagas esto: chunk_overlap=0. Vas a perder respuestas en las fronteras. Lo verás como "el RAG no encontró la respuesta y juro que está en los documentos".

Tampoco hagas esto: chunk_overlap > chunk_size / 2. El segundo chunk repite más del 50% del primero. Estás duplicando casi todo el dataset y los retrieval results se llenan de chunks parecidos.


Implementación práctica con ChromaDB

Pipeline completo: documento → chunks con metadata → ChromaDB

# pipeline_chunking.py
import os
from langchain.text_splitter import RecursiveCharacterTextSplitter
import chromadb
from chromadb.utils import embedding_functions

# Setup
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.getenv("OPENAI_API_KEY"),
    model_name="text-embedding-3-small"
)

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    length_function=len,
    separators=["\n\n", "\n", ". ", " ", ""]
)

client = chromadb.PersistentClient(path="./chroma_with_chunks")
collection = client.get_or_create_collection(
    name="documents_chunked",
    embedding_function=openai_ef
)


def ingest_document(doc_id: str, content: str, source_path: str, category: str):
    """
    Procesa un documento: chunking + metadata por chunk + insert en ChromaDB.

    Cada chunk preserva trazabilidad al documento original (doc_id, chunk_index)
    para que el sistema RAG pueda mostrar la fuente exacta de cada respuesta.
    """
    chunks = splitter.split_text(content)

    chunk_ids = [f"{doc_id}_chunk_{i:03d}" for i in range(len(chunks))]
    chunk_metadatas = [
        {
            "doc_id": doc_id,
            "chunk_index": i,
            "total_chunks": len(chunks),
            "source": source_path,
            "category": category,
            "chunk_size_chars": len(chunk),
        }
        for i, chunk in enumerate(chunks)
    ]

    collection.add(
        documents=chunks,
        ids=chunk_ids,
        metadatas=chunk_metadatas
    )

    print(f"Ingerido {doc_id}: {len(chunks)} chunks (avg {sum(len(c) for c in chunks)//len(chunks)} chars)")


# Uso
documento_largo = """
Capítulo 1: Instalación de ChromaDB.
ChromaDB se instala con pip install chromadb. Requiere Python 3.8 o superior.
Para uso en producción, recomendamos usar una versión específica fijada en requirements.txt.

Capítulo 2: Configuración de HNSW.
HNSW tiene dos parámetros principales que afectan el balance entre velocidad y accuracy.
M (default 16) controla cuántas conexiones tiene cada nodo en el grafo HNSW.
Para producción con alta accuracy se recomienda M=32 y construction_ef=200.
Aumentar M mejora recall pero también aumenta el uso de memoria proporcionalmente.

Capítulo 3: Queries básicas.
Una query básica usa collection.query(query_texts=["..."], n_results=10).
ChromaDB retorna los n_results más similares según la métrica de distancia configurada.
Para text embeddings de OpenAI se recomienda cosine similarity como métrica.
"""

ingest_document(
    doc_id="manual_chromadb_v1",
    content=documento_largo,
    source_path="docs/chromadb/manual.md",
    category="documentation"
)

Output esperado:

Ingerido manual_chromadb_v1: 4 chunks (avg 320 chars)

Recuperar y reconstruir contexto del documento original

Cuando una query devuelve chunks, el metadata permite ubicar la fuente:

# Buscar y mostrar contexto completo
query = "What are the recommended HNSW parameters for production?"
results = collection.query(query_texts=[query], n_results=3)

print(f"Query: {query}\n")
for doc, meta, dist in zip(
    results['documents'][0],
    results['metadatas'][0],
    results['distances'][0]
):
    print(f"--- Distance: {dist:.3f} ---")
    print(f"Source: {meta['source']}")
    print(f"Doc: {meta['doc_id']} (chunk {meta['chunk_index']+1}/{meta['total_chunks']})")
    print(f"Content: {doc}")
    print()

Output esperado:

Query: What are the recommended HNSW parameters for production?

--- Distance: 0.198 ---
Source: docs/chromadb/manual.md
Doc: manual_chromadb_v1 (chunk 2/4)
Content: Capítulo 2: Configuración de HNSW. HNSW tiene dos parámetros principales que
afectan el balance entre velocidad y accuracy. M (default 16) controla cuántas
conexiones tiene cada nodo en el grafo HNSW. Para producción con alta accuracy
se recomienda M=32 y construction_ef=200. Aumentar M...

--- Distance: 0.412 ---
Source: docs/chromadb/manual.md
Doc: manual_chromadb_v1 (chunk 3/4)
Content: Capítulo 3: Queries básicas. Una query básica usa collection.query...

Compara con el experimento del inicio: ahora el chunk relevante tiene distancia 0.198 (vs 0.413 con documento entero). El retrieval pasó de "encontró el documento correcto entre dos opciones" a "encontró el párrafo exacto que responde la pregunta".


Trampas y errores comunes

Trampa 1: chunk_overlap en cero "para ahorrar"

El error: decides que el overlap es desperdicio porque duplica datos. Lo pones en 0.

splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=0)

Síntoma: queries específicas que deberían matchear chunks específicos no devuelven los chunks correctos. La respuesta del LLM dice "no tengo información sobre eso" cuando claramente el documento la tiene.

Por qué pasa: la respuesta cae partida en la frontera entre dos chunks. Ni el chunk anterior ni el siguiente la contienen completa. Sin overlap, esa información es invisible al retrieval.

Cómo corregir: usa al menos 10% de overlap. El "ahorro" de quitar overlap es trivial (~10% más chunks) comparado con el daño en recall.

Trampa 2: chunk_size demasiado pequeño

El error: alguien te dice "más pequeño es más preciso", entonces usas chunk_size=100.

Síntoma: los chunks devueltos no tienen contexto suficiente para que el LLM genere una respuesta coherente. La respuesta del LLM es fragmentaria, le falta información de contorno.

Por qué pasa: un chunk de 100 caracteres es ~25 tokens. La respuesta a una pregunta técnica raramente cabe en 25 tokens. El LLM lee el chunk y dice "aquí dice X, pero no sé el contexto completo".

Cómo detectarlo: queries que deberían tener respuesta completa devuelven respuestas truncadas o vagas, aún cuando los chunks correctos sí se recuperan.

Cómo corregir: sube chunk_size a 400-800 según tu dominio. Mide recall y calidad de respuesta, no solo recall.

Trampa 3: chunk_size demasiado grande

El error: "si grande está mal, todavía más grande está peor pero al menos cubre más". Usas chunk_size=3000.

Síntoma: retrieval funciona, pero los resultados ranquean mal — chunks irrelevantes aparecen alto y los relevantes a veces no.

Por qué pasa: vuelves al problema del documento entero a menor escala. El embedding de un chunk de 3000 chars promedia múltiples temas. Pierdes resolución.

Cómo corregir: mantén chunks ≤1500 chars excepto en dominios con argumentos muy largos (papers académicos).

Trampa 4: dividir por delimitadores erróneos para tu dominio

El error: usas el default de separadores ["\n\n", "\n", ". ", " ", ""] para procesar código fuente. El código no usa "\n\n" para separar bloques lógicos — usa indentación y llaves.

Síntoma: los chunks parten funciones a la mitad, dejan llaves abiertas, rompen la estructura.

Cómo corregir: ajusta los separadores al dominio. Para código:

splitter_code = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=80,
    separators=["\nclass ", "\ndef ", "\n\n", "\n", " ", ""]
)

Para Markdown:

splitter_md = RecursiveCharacterTextSplitter(
    chunk_size=600,
    chunk_overlap=60,
    separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " ", ""]
)

Trampa 5: olvidar el doc_id en metadata

El error: chunkeas, insertas, todo funciona. Pero solo guardas el contenido del chunk sin metadata que lo conecte al documento original.

# ❌ Insertar chunks sin trazabilidad al doc original
collection.add(
    documents=chunks,
    ids=[f"chunk_{i}" for i in range(len(chunks))]
    # No metadata
)

Síntoma: cuando una query devuelve un chunk útil, no puedes mostrar al usuario "esta respuesta viene de manual.md, sección 2". Los chunks están huérfanos. Si el usuario reporta un error en una respuesta, no puedes encontrar el documento original para corregirlo.

Cómo corregir: siempre incluir doc_id, chunk_index, y source en metadata. Esto es no negociable para RAG production.

Trampa 6: re-chunkear sin re-insertar

El error: decides cambiar de chunk_size=300 a chunk_size=600 para mejorar contexto. Modificas el código que procesa documentos nuevos. Pero los documentos viejos quedan con chunks de 300.

Síntoma: queries que dependen de documentos viejos siguen fallando como antes; queries sobre documentos nuevos mejoran. Resultados inconsistentes según qué documento contesta.

Cómo corregir: cuando cambias parámetros de chunking, debes re-procesar todos los documentos existentes. La migración es:

  1. Crear collection nueva
  2. Re-leer cada documento original
  3. Re-chunkear con parámetros nuevos
  4. Insertar en collection nueva
  5. Verificar que count() sea coherente
  6. Cambiar la app para usar collection nueva
  7. Eliminar collection vieja

No hay shortcut.


Ejercicio aplicado

Escenario: Trabajas con una empresa de servicios legales. Te piden construir un RAG sobre 200 documentos PDF de jurisprudencia. Cada PDF tiene entre 5 y 30 páginas. Características:

  • Idioma: español jurídico (formal, oraciones largas)
  • Estructura típica: encabezado del caso → hechos → fundamentos jurídicos → resolución
  • Las queries de los abogados son específicas: "¿qué dijo el juez sobre prescripción en casos de daño moral?"
  • SLA: las respuestas deben citar literalmente el fragmento del documento
  • Es crítico no perder contexto entre secciones (un fundamento jurídico puede referenciar hechos descritos antes)

Pregunta: Configura RecursiveCharacterTextSplitter para este caso. Justifica cada parámetro y agrega cualquier estrategia adicional que recomiendes.

Solución

Análisis:

  1. Idioma jurídico = oraciones largas. Usar chunk_size=300 partiría argumentos jurídicos a la mitad. Necesitas chunks más grandes que el default.

  2. Estructura conocida (hechos, fundamentos, resolución). Los separadores deben respetar esos cortes. Si el PDF se procesa con headings claros, conviene priorizar separadores que correspondan a esas secciones.

  3. Citas literales requeridas. Cada chunk debe tener identidad fuerte del documento original. Metadata es crítico.

  4. Referencias entre secciones. Necesitas overlap más alto que el default 10% para no perder conexiones entre "fundamento jurídico" y "hechos" descritos antes.

Configuración propuesta:

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter_legal = RecursiveCharacterTextSplitter(
    chunk_size=1200,       # Más grande que default por oraciones jurídicas largas
    chunk_overlap=200,     # ~17% overlap por referencias entre secciones
    length_function=len,
    separators=[
        "\nRESOLUCIÓN",     # Estructura conocida del dominio
        "\nFUNDAMENTOS",
        "\nHECHOS",
        "\n\n",              # Separación de párrafo
        "\n",                # Línea
        ". ",                # Oración (cuidado: puede partir abreviaciones legales)
        " ",
        ""
    ]
)

Justificación parámetros:

  • chunk_size=1200: doble del default. Permite que un argumento jurídico completo (premisa + razonamiento + cita de norma) entre en un chunk sin partirse.
  • chunk_overlap=200: 17%, mayor al 10% default, porque los fundamentos jurídicos referencian hechos descritos varios párrafos antes. Sin overlap suficiente, esas referencias quedan colgadas.
  • separators priorizan cortes naturales del dominio (RESOLUCIÓN, FUNDAMENTOS, HECHOS) antes que los separadores genéricos.

Estrategias adicionales recomendadas:

  1. Metadata enriquecida por chunk:
metadata = {
    "doc_id": "case_2024_001",
    "chunk_index": i,
    "section": detect_section(chunk),  # "hechos" / "fundamentos" / "resolución"
    "court": "Tribunal Supremo",
    "year": 2024,
    "case_topic": "daño_moral_prescripción",
    "source_page": detect_pdf_page(chunk),  # Para citar página exacta
}
  1. Pre-procesamiento del PDF: los PDFs de jurisprudencia tienen ruido (encabezados de página repetidos, números de página, footers). Limpiar antes de chunkear:
def clean_legal_pdf_text(raw: str) -> str:
    raw = remove_page_headers_footers(raw)
    raw = normalize_section_titles(raw)  # "Fundamentos jurídicos" → "FUNDAMENTOS"
    return raw
  1. Eval set específico del dominio: antes de poner en producción, construir 30-50 queries reales con respuestas anotadas por el equipo legal. Medir recall@5 con la configuración propuesta y comparar contra default. Si recall@5 < 90% en queries técnicas, considerar:

    • Subir chunk_size a 1500
    • Subir chunk_overlap a 300
    • Agregar pre-clasificación: identificar tipo de query (¿busca hechos? ¿busca fundamentos?) y filtrar por section en metadata antes del semantic search.
  2. No usar semantic chunking para empezar. El costo en este dominio (200 PDFs × ~15 páginas × oraciones largas) sería notable. Empieza con recursivo + parámetros del dominio. Mide. Si la calidad no alcanza, evalúa semantic chunking solo para los documentos donde más importa.


Resumen y siguiente paso

Lo que aprendiste:

  • Embebir un documento entero degrada el retrieval — el vector resultante es un promedio difuso que no apunta a ninguna sección específica.
  • Chunking divide documentos largos en piezas semánticamente coherentes. Cada chunk se embebe por separado y compite individualmente en el retrieval.
  • Tres estrategias: fixed-size (no usar), recursive character (default para 80% de casos), semantic (cuando el dominio justifica el costo).
  • RecursiveCharacterTextSplitter con chunk_size=500 y chunk_overlap=50 es el default razonable. Ajusta según densidad de información de tu dominio.
  • El overlap (~10% del chunk_size) evita que respuestas caigan partidas entre dos chunks.
  • Metadata por chunk (doc_id, chunk_index, source) es crítica para trazabilidad en producción.

Checkpoint: antes de avanzar, deberías poder:

  • Explicar con tus palabras por qué embebir documentos largos como un solo vector empeora el retrieval.
  • Configurar RecursiveCharacterTextSplitter con parámetros justificados para un dominio dado.
  • Identificar las tres trampas más caras del chunking (overlap=0, chunk_size demasiado pequeño, chunks sin metadata).

Siguiente cápsula: 11 — Pipeline RAG end-to-end básico.

Tienes embeddings de calidad (M4/09) y chunking que preserva resolución (M4/10). Ahora vas a juntar todo: ingestion completa de un dataset → chunking → OpenAI embeddings → ChromaDB con metadata → retrieval → generation con GPT.

Será la primera vez en la guía que ves un sistema RAG funcional end-to-end. Es la versión mínima que prueba que el pipeline funciona. El Módulo 8 más adelante lo escala a 1000+ documentos y le agrega API REST, Docker y testing — pero la lógica core la construyes en M4/11.


Recursos

  1. LangChain RecursiveCharacterTextSplitter — Documentación oficial
  2. Chunking Strategies for LLM Applications (Pinecone) — Comparación de estrategias
  3. The Five Levels of Chunking (Greg Kamradt) — De fixed a agentic chunking
  4. Semantic Chunking with LangChain — Implementación oficial de semantic chunking
  5. Tokenizer Playground (OpenAI) — Verifica cuántos tokens son tus chunks
  6. LlamaIndex Node Parsers — Alternativa a LangChain con sentence splitters más finos

Tiempo estimado: 35-45 minutos Siguiente: 11-pipeline-rag-end-to-end.md