Módulo 2: Chunking Strategies
Cápsula 04: Semantic chunking — chunks que respetan el cambio de tema
Descripción de la cápsula
Recursive chunking (cápsula 03) divide el documento por separadores estructurales — saltos de línea, puntos, espacios. Funciona bien cuando el documento tiene formato consistente, pero tiene un problema sutil: respeta estructura, no semántica. Si tu documento de 2000 caracteres habla de FastAPI en los primeros 1200 chars y de Python en general en los últimos 800, recursive te puede dar un chunk de 500 chars que mezcla las últimas oraciones de FastAPI con las primeras de Python.
Semantic chunking resuelve eso: divide cuando el contenido cambia de tema, sin importar la posición del separador estructural. Usa embeddings para detectar dónde la coherencia semántica se rompe — donde una oración tiene un embedding muy distinto de la oración anterior. Esa transición es el punto de corte natural.
Esta cápsula te enseña cómo funciona el algoritmo conceptualmente, cómo implementarlo con SemanticChunker de LangChain, cómo tunear el threshold de breakpoint, y cuándo el costo extra (embeddings durante indexing + latencia 10x) se justifica vs recursive.
Al finalizar esta cápsula serás capaz de:
- ✅ Explicar el algoritmo de semantic chunking en términos de cosine similarity entre oraciones
- ✅ Implementar semantic chunking con
SemanticChunkery embeddings de OpenAI - ✅ Tunear el
breakpoint_thresholdpara tu dataset (percentile, standard deviation, interquartile) - ✅ Calcular el costo extra de semantic chunking vs recursive para un volumen dado
- ✅ Decidir cuándo semantic chunking gana sobre recursive con datos
- ✅ Anticipar las trampas: chunks demasiado grandes o demasiado pequeños según threshold
Tiempo estimado: 25-30 minutos
El insight: detectar transiciones temáticas con embeddings
Piensa en un documento técnico real:
"FastAPI es un framework web moderno para Python. Fue creado en 2018 por Sebastián Ramírez.
Se basa en Pydantic para validación y Starlette para el ASGI underlying.
Las características principales incluyen rendimiento comparable a Node.js, validación
automática de tipos con type hints, documentación interactiva con Swagger UI, y
soporte nativo para async/await.
Python es un lenguaje de programación de alto nivel. Fue creado por Guido van Rossum
en 1991. Es conocido por su sintaxis legible y su filosofía 'batteries included'.
La popularidad de Python creció enormemente en los 2010s gracias a su uso en data
science y machine learning."
Hay tres tópicos: introducción a FastAPI, características de FastAPI, y Python en general. Recursive chunking con chunk_size=500 te puede partir el documento entre "características de FastAPI" y "Python como lenguaje" en mitad de oración — pierdes contexto en cada chunk.
Semantic chunking ve la transición:
Oración 1: "FastAPI es un framework web..." embedding ──┐
Oración 2: "Fue creado en 2018..." embedding ──┼─ similarity 0.85 (mismo tema)
Oración 3: "Se basa en Pydantic..." embedding ──┘
Oración 4: "Las características principales..." embedding ──┐
Oración 5: "...rendimiento comparable a Node.js..." embedding ──┼─ similarity 0.78 (sigue FastAPI)
Oración 6: "...soporte nativo para async..." embedding ──┘
Oración 7: "Python es un lenguaje..." embedding ←─ similarity con oración 6: 0.32 ← TRANSICIÓN
(cambio de tema)
Oración 8: "Fue creado por Guido van Rossum..." embedding ──┐
Oración 9: "...sintaxis legible..." embedding ──┼─ similarity 0.81 (sigue Python)
└
El algoritmo detecta la caída de similaridad de 0.78 a 0.32 entre oraciones 6 y 7, e inserta un breakpoint exactamente ahí. Resultado: dos chunks coherentes, uno sobre FastAPI completo, otro sobre Python completo.
El algoritmo paso a paso
def semantic_chunk_conceptual(document: str, threshold: float = 0.50):
"""
Pseudo-código del algoritmo de semantic chunking.
"""
# 1. Dividir el doc en oraciones
sentences = split_into_sentences(document)
# 2. Generar embedding de cada oración
embeddings = [embed(s) for s in sentences]
# 3. Calcular similaridad coseno entre oraciones consecutivas
similarities = []
for i in range(len(sentences) - 1):
sim = cosine_similarity(embeddings[i], embeddings[i + 1])
similarities.append(sim)
# 4. Encontrar breakpoints (similarity < threshold)
breakpoints = [i for i, sim in enumerate(similarities) if sim < threshold]
# 5. Construir chunks usando breakpoints
chunks = []
start = 0
for bp in breakpoints:
chunk = " ".join(sentences[start:bp + 1])
chunks.append(chunk)
start = bp + 1
chunks.append(" ".join(sentences[start:]))
return chunks
Costo del algoritmo: generar embeddings de cada oración del documento. Para 1M docs × promedio 30 oraciones × $0.02 por 1M tokens, son ~$1-3 de embeddings extras vs recursive (que es gratis). Más latencia: ~50-100ms por documento durante indexing (vs ~5-10ms para recursive).
Implementación con LangChain
# semantic_chunking.py
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
import os
# Configurar embeddings
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small",
api_key=os.getenv("OPENAI_API_KEY"),
)
# Configurar splitter
splitter = SemanticChunker(
embeddings=embeddings,
breakpoint_threshold_type="percentile", # ver opciones abajo
breakpoint_threshold_amount=75, # split donde similarity < P75
)
document = """
FastAPI es un framework web moderno para Python. Fue creado en 2018 por Sebastián Ramírez.
Se basa en Pydantic para validación y Starlette para el ASGI underlying.
Las características principales de FastAPI incluyen rendimiento comparable a Node.js,
validación automática de tipos con type hints, documentación interactiva con Swagger UI,
y soporte nativo para async/await.
Python es un lenguaje de programación de alto nivel. Fue creado por Guido van Rossum
en 1991. Es conocido por su sintaxis legible y su filosofía 'batteries included'.
La popularidad de Python creció enormemente en los 2010s gracias a su uso en data
science y machine learning.
"""
chunks = splitter.split_text(document)
print(f"Total chunks: {len(chunks)}")
for i, chunk in enumerate(chunks, 1):
print(f"\n--- Chunk {i} ({len(chunk)} chars) ---")
print(chunk)
Output esperado:
Total chunks: 2
--- Chunk 1 (385 chars) ---
FastAPI es un framework web moderno para Python. Fue creado en 2018 por Sebastián Ramírez.
Se basa en Pydantic para validación y Starlette para el ASGI underlying.
Las características principales de FastAPI incluyen rendimiento comparable a Node.js,
validación automática de tipos con type hints, documentación interactiva con Swagger UI,
y soporte nativo para async/await.
--- Chunk 2 (272 chars) ---
Python es un lenguaje de programación de alto nivel. Fue creado por Guido van Rossum
en 1991. Es conocido por su sintaxis legible y su filosofía 'batteries included'.
La popularidad de Python creció enormemente en los 2010s gracias a su uso en data
science y machine learning.
Nota que los chunks tienen tamaños distintos (385 y 272 chars). Eso es esperado y deseable — cada chunk se ajusta al "alcance natural" del tema, no a un tamaño fijo arbitrario.
Tuning del breakpoint threshold
SemanticChunker ofrece tres tipos de threshold:
Tipo 1: percentile (recomendado)
splitter = SemanticChunker(
embeddings=embeddings,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=75,
)
Cómo funciona: calcula todas las similaridades del documento, encuentra el percentil 75. Las transiciones donde la similaridad es menor a ese percentil se vuelven breakpoints.
Ventaja: se adapta al documento. Documentos con muchas transiciones temáticas tienen muchos breakpoints; documentos coherentes tienen pocos.
Tuning:
amount=75(default): chunks medianamente grandes, ~20% de oraciones marcadas como transiciónamount=85: chunks más grandes, menos transiciones detectadas (más conservador)amount=65: chunks más pequeños, más transiciones (más agresivo)
Tipo 2: standard_deviation
splitter = SemanticChunker(
embeddings=embeddings,
breakpoint_threshold_type="standard_deviation",
breakpoint_threshold_amount=2, # split donde similarity < mean - 2*std
)
Cómo funciona: marca como breakpoint las oraciones donde la similaridad cae más de N desviaciones estándar bajo la media.
Cuándo usarlo: cuando el dataset es heterogéneo (algunos docs con muchas transiciones, otros pocos) y quieres un threshold robusto a la distribución.
Tipo 3: interquartile
splitter = SemanticChunker(
embeddings=embeddings,
breakpoint_threshold_type="interquartile",
)
Cómo funciona: usa el rango intercuartil (IQR) de las similaridades. Transiciones outliers (más bajas que Q1 - 1.5×IQR) son breakpoints.
Cuándo usarlo: datasets con outliers que quieres capturar — transiciones temáticas muy fuertes pero raras.
Comparación con recursive: cuándo gana cada uno
# benchmark_semantic_vs_recursive.py
from langchain.text_splitter import RecursiveCharacterTextSplitter
import time
# Mismo documento, dos splitters
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
)
semantic_splitter = SemanticChunker(
embeddings=embeddings,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=75,
)
def benchmark(splitter, name, document):
start = time.perf_counter()
chunks = splitter.split_text(document)
elapsed = (time.perf_counter() - start) * 1000
avg_size = sum(len(c) for c in chunks) / len(chunks)
print(f"\n{name}:")
print(f" Chunks generados: {len(chunks)}")
print(f" Tamaño promedio: {avg_size:.0f} chars")
print(f" Latencia: {elapsed:.0f} ms")
# Probar con un documento de 5000 chars
benchmark(recursive_splitter, "Recursive", document)
benchmark(semantic_splitter, "Semantic", document)
Output típico:
Recursive:
Chunks generados: 11
Tamaño promedio: 500 chars
Latencia: 8 ms
Semantic:
Chunks generados: 6
Tamaño promedio: 833 chars (rango: 280-1240 chars)
Latencia: 420 ms (genera 30 embeddings)
Lecturas:
- Recursive: rápido (~50x más rápido), gratis, chunks de tamaño uniforme.
- Semantic: lento, cuesta dinero (~$0.0001 por documento de 5K chars), pero chunks coherentes.
Cuándo el costo extra se justifica
| Caso | ¿Semantic? | Por qué |
|---|---|---|
| Documentación técnica con secciones bien delimitadas | ❌ Recursive | Las secciones ya están separadas por headers; recursive las respeta |
| Texto narrativo largo (artículos, papers) | ✅ Semantic | Los temas cambian gradualmente, sin separadores claros |
| Transcripciones de audio (sin estructura) | ✅ Semantic | Sin separadores estructurales, semántica es lo único confiable |
| Código fuente | ❌ Structural (M02/05) | Tiene unidades naturales (funciones, clases) |
| Conversaciones de chat | ✅ Semantic | Cambios de tema frecuentes, sin estructura uniforme |
| MVP con presupuesto cero | ❌ Recursive | El costo extra de semantic no se justifica al inicio |
Regla general: semantic chunking es para texto narrativo continuo donde las transiciones temáticas son sutiles. Para texto estructurado, structural o recursive son mejores.
Trampas y errores comunes
Trampa 1: threshold demasiado bajo → chunks demasiado pequeños
El error: breakpoint_threshold_amount=50 (percentil 50, threshold muy bajo).
Síntoma: el splitter detecta transición casi entre cada par de oraciones. Cada chunk tiene 1-2 oraciones. Recall colapsa porque los chunks no tienen contexto suficiente.
Cómo prevenir: empezar con amount=75 (default) y ajustar solo si el resultado claramente no funciona. Si necesitas chunks más pequeños, mejor usar recursive con chunk_size pequeño.
Trampa 2: threshold demasiado alto → chunks demasiado grandes
El error: breakpoint_threshold_amount=95.
Síntoma: el splitter solo detecta transiciones obvias. Documentos con varios sub-temas terminan como un solo chunk gigante. Pierdes la ventaja de semantic chunking.
Cómo prevenir: amount=70-80 es el rango típicamente óptimo. Validar empíricamente sobre tu dataset.
Trampa 3: usar semantic chunking sin necesidad
El error: "semantic es lo más nuevo, lo aplico a todo mi corpus".
Síntoma: indexing toma 10x más tiempo, costos de embeddings se disparan, y la mejora vs recursive es <3%.
Cómo prevenir: medir vs recursive sobre tu eval set. Si la mejora es <5% precision, recursive alcanza.
Trampa 4: ignorar overlap en semantic chunking
El error: asumes que como los chunks son "coherentes", no necesitas overlap.
Síntoma: queries que requieren información que se menciona transicional entre temas (ej: una oración que conecta dos sub-temas) se pierden.
Cómo prevenir: SemanticChunker no tiene overlap nativo, pero puedes agregarlo en post-procesamiento:
def add_overlap_to_semantic_chunks(chunks: list[str], overlap_sentences: int = 1):
"""Agregar overlap manual de N oraciones entre chunks consecutivos."""
if not chunks:
return chunks
overlapped = [chunks[0]]
for i in range(1, len(chunks)):
prev_sentences = chunks[i-1].split('. ')[-overlap_sentences:]
new_chunk = '. '.join(prev_sentences) + '. ' + chunks[i]
overlapped.append(new_chunk)
return overlapped
Trampa 5: cambio de modelo de embeddings invalida los chunks
El error: indexaste con semantic chunking usando text-embedding-3-small. Migraste a text-embedding-3-large. Los chunks viejos quedan con cortes basados en el modelo anterior.
Síntoma: inconsistencia: docs viejos con chunks "según modelo viejo", docs nuevos con chunks "según modelo nuevo". Calidad de retrieval inconsistente.
Cómo prevenir: cuando cambias el modelo de embeddings, re-procesa todo el corpus con semantic chunking del nuevo modelo. No es opcional.
Trampa 6: olvidar el costo de re-indexar
El error: decides cambiar el threshold (de 75 a 80). Re-procesas todo el corpus.
Síntoma: generas todos los embeddings de nuevo. Para 100K docs, eso es ~$30-100 dependiendo del tamaño promedio.
Cómo prevenir: cachear embeddings de oraciones (no solo de chunks finales). Si solo cambias el threshold, las similaridades no cambian — solo cambia dónde cortar.
Ejercicio aplicado
Escenario: eres AI Engineer en una empresa que indexa transcripciones de podcasts técnicos para hacer RAG sobre el contenido.
Datos:
- 5K episodios, ~45 minutos cada uno
- Transcripción promedio: 8K palabras (~50K caracteres)
- Las transcripciones NO tienen separadores estructurales (un solo párrafo gigante)
- Las queries son técnicas: "cómo manejar autenticación según [host del podcast]"
Métricas con recursive chunking actual (chunk_size=500, overlap=50):
- Precision@5: 71%
- Recall@5: 58%
- Quejas: "el bot mezcla diferentes temas en una sola respuesta"
Tu trabajo:
- Decide si semantic chunking aplica para este caso.
- Diseña la implementación con tuning del threshold.
- Calcula el costo de re-indexar todo el corpus con semantic chunking.
Solución
1. Sí aplica semantic chunking
Razones:
- Sin separadores estructurales: transcripciones son texto continuo. Recursive chunking parte oraciones a la mitad o agrupa fragments de distintos temas.
- Cambios de tema frecuentes: los podcasts saltan de tema sin "headers" que marquen la transición. Semantic puede detectarlo.
- El síntoma del usuario ("el bot mezcla temas") confirma el problema: chunks actuales NO son coherentes temáticamente.
Recursive es la causa probable. Semantic es la solución natural.
2. Implementación
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# Threshold 75 default, ajustar empíricamente
splitter = SemanticChunker(
embeddings=embeddings,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=75, # punto de partida
)
def chunk_podcast_transcript(transcript: str) -> list[str]:
"""Chunk semantic con post-procesamiento."""
chunks = splitter.split_text(transcript)
# Validación: descartar chunks demasiado cortos (<150 chars)
# Esos suelen ser ruido (interrupciones, "uh", "right")
chunks = [c for c in chunks if len(c) > 150]
# Agregar overlap manual de 1 oración entre chunks
if len(chunks) > 1:
for i in range(1, len(chunks)):
last_sentence = chunks[i-1].rsplit('. ', 1)[-1]
chunks[i] = last_sentence + '. ' + chunks[i]
return chunks
# Validar sobre algunos episodios y ajustar threshold
sample_episode = transcripts[0]
chunks = chunk_podcast_transcript(sample_episode)
avg_size = sum(len(c) for c in chunks) / len(chunks)
print(f"Chunks: {len(chunks)}, Avg size: {avg_size:.0f} chars")
# Si avg_size > 1500: subir threshold a 80 (chunks más grandes son problema)
# Si avg_size < 300: bajar threshold a 65 (chunks demasiado pequeños)
# Si entre 400-1200: el threshold es razonable
Tuning del threshold:
Estrategia: probar 65, 75, 85 sobre 50 episodios sample, medir:
- Tamaño promedio de chunk
- Distribución del tamaño (¿tienen mucha varianza?)
- Recall sobre eval set de queries
Threshold óptimo típico para transcripciones: 70-75 (un poco más bajo que default porque queremos detectar transiciones más frecuentes).
3. Costo de re-indexar
TOTAL_EPISODES = 5000
AVG_WORDS_PER_EPISODE = 8000
AVG_TOKENS_PER_EPISODE = 8000 * 1.3 # ~10K tokens
# Semantic chunking necesita generar embedding de cada oración
# Asumiendo 50 oraciones promedio por episodio
SENTENCES_PER_EPISODE = 50
TOKENS_PER_SENTENCE = 200
total_tokens_for_chunking = TOTAL_EPISODES * SENTENCES_PER_EPISODE * TOKENS_PER_SENTENCE
# = 50M tokens
# Costo embeddings text-embedding-3-small: $0.02 / 1M tokens
chunking_embedding_cost = (total_tokens_for_chunking / 1_000_000) * 0.02
# = $1.00 — costo de los embeddings DE LAS ORACIONES (para detectar transiciones)
# Después, embeddings de los chunks finales para indexar en ChromaDB
# Asumiendo ~10 chunks finales por episodio, ~1000 tokens cada chunk
total_tokens_for_indexing = TOTAL_EPISODES * 10 * 1000
indexing_cost = (total_tokens_for_indexing / 1_000_000) * 0.02
# = $1.00 — costo del indexing real
total_cost = chunking_embedding_cost + indexing_cost
print(f"Costo total de re-indexing: ${total_cost:.2f}")
Output: ~$2 USD. Despreciable para 5K episodios.
Latencia de re-indexing:
- 50 oraciones × 5K episodios = 250K llamadas a OpenAI embedding
- Con batch_size=100 → 2,500 batches
- ~150ms por batch (latencia API) → ~6 minutos puros de embedding
- En la práctica, con paralelismo (5 workers), termina en ~2-3 minutos
Plan de validación:
- Construir eval set de 100 queries reales con ground truth (chunk relevante para cada query).
- Medir recall@5 con recursive (baseline).
- Implementar semantic chunking con threshold=75. Medir.
- Si recall mejora >10 puntos, deployar.
- Iterar threshold (65, 80) si es necesario.
Métricas a monitorear post-deploy:
- Tasa de quejas "el bot mezcla temas" (debería caer notablemente).
- Tamaño promedio de chunks (alerta si baja a <250 chars o sube a >2000).
- Costo mensual de re-indexing (cuando llegan episodios nuevos).
Plan B si semantic no llega:
- Si recall mejora marginalmente (<5%): el problema puede ser otro (queries, embeddings). Diagnosticar siguiendo M03 (query optimization).
- Si chunks son inconsistentes en tamaño: agregar lógica de merge de chunks pequeños o split de chunks grandes.
Resumen y siguiente paso
Lo que aprendiste:
- Semantic chunking detecta cambios de tema usando cosine similarity entre oraciones consecutivas.
- Mejora típica vs recursive: +3-7% precision en texto narrativo, hasta +10% en transcripciones sin estructura.
- Costo: 10x más latencia de indexing + costo de embeddings extras (~$1-2 USD por 100K docs).
- Threshold tuning: percentile 75 default. Bajar para chunks más pequeños, subir para más grandes.
- Ganador en: texto narrativo continuo, transcripciones, conversaciones, papers académicos.
- Perdedor en: documentación con headers claros, código fuente, MVPs con presupuesto cero.
- Cambio de modelo de embeddings o threshold requiere re-indexar todo el corpus.
Checkpoint: antes de avanzar, deberías poder:
- Explicar el algoritmo de semantic chunking con cosine similarity entre oraciones.
- Implementar
SemanticChunkercon threshold tuning. - Decidir cuándo semantic chunking gana sobre recursive con datos.
Siguiente cápsula: 05 — Structural chunking.
Para texto con estructura formal (código, HTML, markdown), ni recursive ni semantic son óptimas. Structural chunking respeta unidades sintácticas naturales: funciones en código, headings en markdown, secciones en HTML. La cápsula 05 te enseña a implementarlo con parsers especializados.
Recursos
- LangChain — SemanticChunker — Documentación oficial
- Pinecone — Chunking Strategies — Comparación con benchmarks
- Greg Kamradt — 5 Levels of Text Splitting — Tutorial visual
- Anthropic — Contextual Retrieval — Técnica complementaria al chunking
- Lost in the Middle Paper — Por qué chunks coherentes importan
- LlamaIndex — Semantic Splitter — Implementación alternativa
Tiempo estimado: 25-30 minutos Siguiente: 05-structural-chunking.md