English · Español
02 — Estrategias de chunking e índices vectoriales¶
Dos decisiones de implementación que la mayoría de RAGs malos pasan por alto: cómo se parten los documentos (chunking) y cómo se busca el vecino más cercano (index). Un mal chunker hace que la respuesta correcta aparezca dividida entre dos chunks. Un mal índice escala mal (O(N) cuando podría ser O(log N)).
Por qué importa el chunking¶
Un bi-encoder produce un vector de dimensión fija por texto. Si embebes un documento entero de 10 páginas, ese único vector comprime 10 páginas de contenido semántico. El resultado: las consultas sobre partes específicas del documento recuperan débilmente — el vector está "promediado sobre demasiado".
Así que partimos (chunking) los documentos en piezas más pequeñas antes de embeberlos. El tamaño del chunk es un tradeoff:
- Demasiado pequeño (1 frase): pierde contexto. "El past participle es
eaten" — ¿past participle de qué? - Demasiado grande (documento entero): pierde precisión. El embedding promedia demasiados conceptos.
- Justo el tamaño correcto: una unidad lógica por chunk. Un enunciado completo de regla, un único ejemplo, una definición.
Para la KB de la Fase 29, "justo el tamaño correcto" es ~1-3 frases por chunk, a menudo correspondiente a una regla gramatical.
Estrategias de chunking¶
Tamaño fijo, sin overlap¶
Tomar el documento, partir cada N tokens. N = 256 es común.
- Pro: simple, determinista.
- Contra: parte por límites de frase; rompe unidades lógicas.
Tamaño fijo, con overlap¶
Partir cada N tokens, pero cada chunk se solapa con el anterior por K tokens (típico K = 32-64).
- Pro: los límites solapados reducen el problema de "unidad lógica rota".
- Contra: redundancia en la KB; el mismo contenido aparece en múltiples chunks.
Límite de frase¶
Partir por límites de frase (usando nltk o regex). Cada chunk es una frase (o 2-3 frases).
- Pro: nunca rompe una frase a mitad de cláusula.
- Contra: muy corto para contenido escueto; muy largo para contenido verboso. Tamaño variable.
Chunking semántico¶
Usar un modelo separado (o heurística) para detectar "cambios de tema" entre frases; los chunks son rachas de frases temáticamente relacionadas.
- Pro: mejor alineado con cómo los humanos harían chunking.
- Contra: más caro de calcular; depende de la calidad de detección de temas.
Jerárquico / padre-hijo¶
Mantener tanto un chunk fino (para embedding/recuperación) como un chunk padre (contexto más amplio, para el prompt del lector). Recuperar vía hijo; pasar el padre al lector.
- Pro: precisión de recuperación + contexto de lectura simultáneamente.
- Contra: más almacenamiento; pipeline más complejo.
Para la Fase 29 usamos chunking por límite de frase con alineación manual de reglas — la KB es lo bastante pequeña como para que curemos los chunks a mano para alinearlos con unidades de reglas gramaticales. El Lab 00 cubre la construcción de la KB.
Cómo es un buen chunk (para nuestra KB)¶
Ejemplo: un chunk para el past participle de eat:
Regla: El past participle del verbo irregular "eat" es "eaten" (en español: comido). Se usa tras los verbos auxiliares
have/has/had. Ejemplo:I have eaten dinner.(en español:He comido la cena.)
Este chunk: - Es autocontenido (puedes entender la regla sin contexto). - Tiene el token léxico "eat" + el token léxico "eaten" — BM25-friendly. - Tiene un ejemplo trabajado — pedagógicamente útil para que el lector lo incorpore en su respuesta. - Es bilingüe — saca a la luz el par en español según §A2.
Un mal chunk:
El past participle es "eaten". En español: comido.
¿De qué es "eaten" el past participle? La consulta "past participle of eat" embebida cerca de este chunk está débilmente cercana — el verbo literal "eat" no aparece.
La lección: el anclaje léxico dentro de los chunks importa, especialmente para RAG de corpus pequeño donde la comprensión semántica general del bi-encoder está limitada por la capacidad del modelo de embedding.
Índices vectoriales: las estructuras¶
Tras chunking y embedding, tienes una matriz D ∈ ℝ^{|KB| × d} de embeddings de docs. Una consulta q ∈ ℝ^d necesita los top-k por similitud. Opciones:
Flat (NN exacto)¶
Calcula D @ q directamente. Tiempo O(|KB| × d). Para |KB| = 50, d = 384: ~20K multiplicaciones, instantáneo.
Para |KB| = 1M: 384M multiplicaciones por consulta. Aún tratable pero no ágil.
Para |KB| = 1B: no factible por consulta.
FAISS-flat es la implementación canónica. BLAS optimizado hace los productos-punto.
IVF (Inverted File Index)¶
Agrupar los vectores de docs en K clusters vía k-means (offline). En tiempo de consulta:
- Encontrar los
pclusters más cercanos aq(O(K × d)). - Buscar exhaustivamente dentro de esos
pclusters (O(|KB| × p/K × d)).
Para K = sqrt(|KB|), p = 10: speedup de ~10× sobre flat con pérdida de recall de ~1-2%.
Intercambia exactitud por velocidad. Ajustable.
HNSW (Hierarchical Navigable Small World)¶
Un grafo multi-capa donde cada nodo es un embedding de doc. Las aristas conectan nodos "cercanos". Consulta: empieza en la capa superior (dispersa), greedy-walk al nodo más cercano, baja a la siguiente capa, repite. Visita ~O(log |KB|) nodos por consulta.
- Recall: típicamente 95-99% con un tuning adecuado.
- Tiempo de build: más largo que flat; comparable a IVF.
- Memoria: ~1.5-2× el tamaño del índice flat por las aristas del grafo.
- Tiempo de consulta: sub-lineal en
|KB|.
FAISS-HNSW es la implementación estándar. Los sistemas de producción la usan para corpora de >1M docs.
LSH (Locality-Sensitive Hashing)¶
Basado en hashing; técnica más vieja. Menos competitiva que HNSW para embeddings de alta dimensión. Se menciona por completitud; no se usa en sistemas modernos.
Para la Fase 29: ¿qué índice?¶
|KB| ≈ 50 docs, d = 384. La matriz completa de producto-punto es 50 × 384 = 19200 entradas (~76 KiB). FAISS-flat es drásticamente sobredimensionado — incluso NumPy puro D @ q corre en microsegundos.
Así que nuestra ruta de producción es FAISS-flat. Implementamos HNSW en src/minirag/index.py con fines pedagógicos — para sentir la diferencia a escala de juguete y tener una ruta hacia KBs más grandes.
El Lab 02 mide: el recall@5 de HNSW en nuestra KB de 50 docs debería ser 100% (es lo bastante pequeña como para que el índice aproximado encuentre todo exacto). A escala de juguete, el único "coste" de HNSW es el tiempo de build.
Un walkthrough de FAISS-flat¶
import faiss
import numpy as np
# Build (offline)
embeddings = encoder.encode(docs) # shape (N, 384), float32
embeddings = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)
index = faiss.IndexFlatIP(384) # inner-product index
index.add(embeddings)
# Save: faiss.write_index(index, "kb.faiss")
# Query (online)
q_emb = encoder.encode([query]) # shape (1, 384)
q_emb = q_emb / np.linalg.norm(q_emb)
scores, ids = index.search(q_emb, k=10)
# ids[0] is array of top-10 doc indices
IndexFlatIP es "producto interior" — para vectores normalizados a la unidad, equivalente al coseno. Las llamadas add y search son ambas batch-friendly.
Un walkthrough de FAISS-HNSW¶
# Build
index = faiss.IndexHNSWFlat(384, M=32) # M = number of edges per node
index.hnsw.efConstruction = 200 # build-time accuracy parameter
index.add(embeddings)
# Query
index.hnsw.efSearch = 50 # query-time accuracy parameter
scores, ids = index.search(q_emb, k=10)
Ajustable: M (densidad del grafo), efConstruction (precisión de build), efSearch (precisión de consulta). Más alto = más lento pero más recall.
Persistencia¶
faiss.write_index(index, path) + faiss.read_index(path). Más un JSON-o-pickle aparte de los mapeos (doc_id → doc_text), ya que el índice solo guarda vectores e IDs enteros.
Convención en src/minirag/index.py: index.faiss (binario) + index_metadata.json (id → chunk_text + source + offsets).
¿Y las actualizaciones de embedding e índice?¶
Si añades un nuevo doc a la KB:
- FAISS-flat:
index.add(new_embedding). Listo. - FAISS-HNSW:
index.add(new_embedding). El grafo se ajusta. (La eliminación es más difícil; necesita un rebuild.)
Para la Fase 29, la KB es estática. Reconstruimos en cada cambio.
Metadatos por doc¶
Un chunk en la KB carga más que texto y embedding:
chunk_id: identificador estable (grammar-rules/irregular-verbs/eat-past-participle-v1).source: el documento o categoría fuente.text: el texto real del chunk (para el prompt del lector).embedding: el vector (en el índice FAISS).- Opcional:
bm25_tokens(texto tokenizado para BM25).
src/minirag/index.py guarda todos estos en un dict[chunk_id, ChunkRecord] junto al índice FAISS.
Efectos del tamaño de chunk sobre la calidad de recuperación¶
Empíricamente, para nuestra KB:
- Chunks de 1 frase: cada chunk es pequeño, pero el embedding captura poco contexto. Hit-rate de rango medio.
- Chunks de 1 regla (2-3 frases, alineados a mano): cada chunk es una unidad lógica. Hit-rate más alto.
- Chunks de 10 reglas: los chunks contienen múltiples reglas; la consulta para una regla específica puede recuperar un chunk que la contiene pero el slice relevante está enterrado. Hit-rate más bajo.
La comprobación de sensibilidad del Lab 01 varía el tamaño de chunk; esperamos el codo en "una regla por chunk".
Compresión¶
Para KBs muy grandes, podrías cuantizar los embeddings para ahorrar memoria (8-bit o 4-bit). FAISS soporta esto vía IndexIVFPQ (product quantization). Para 50 docs a 384-dim fp32: 76 KiB — la cuantización no aporta nada. Mencionado pero no usado.
Problemas de drill¶
Soluciones al abrir la fase en solutions/02-chunking-and-indexes-ref.md.
- Una KB de 50 docs con embeddings fp32 de 384 dims. ¿Huella de memoria? Si pasamos a embeddings de 8 bits, ¿cuál es la nueva huella? ¿Cuál es el coste de precisión (en error coseno aproximado)?
- HNSW tiene parámetros
MyefSearch. Argumenta: doblarefSearchdobla el tiempo de consulta y aumenta el recall hacia el 100%. ¿Cuál es el régimen de rendimientos decrecientes? - Considera un chunk que contiene tanto "el past participle de eat es eaten" como "el past participle de write es written". Una consulta para "past participle of eat" se embebe cerca de este chunk. ¿Cuál es el modo de fallo en la etapa del lector? (Pista: ¿en qué frase se centra el modelo?)
- Esboza un pipeline de chunking jerárquico (padre-hijo) para la KB de reglas gramaticales. ¿Cuál es el chunk hijo? ¿Cuál el padre? ¿Cuándo se usa cada uno?
- Para nuestra KB de 50 docs, ¿usarías FAISS-flat o HNSW? Defiende con estimaciones de tiempos.
Resumen en un párrafo¶
El chunking decide qué unidad de texto se convierte en un único embedding; para nuestra pequeña KB de reglas gramaticales, usamos chunks de 1 regla (~2-3 frases) alineados a mano. Los índices vectoriales deciden cómo encontrar los top-k vecinos más cercanos: flat (exacto, barato para KBs pequeñas), IVF (basado en clusters, sub-lineal), HNSW (basado en grafo, sub-lineal con recall alto). Para nuestra KB de 50 docs, FAISS-flat es suficiente e instantáneo; HNSW se implementa por completitud pedagógica. Ingeniería crítica: el anclaje léxico dentro de los chunks mejora tanto la recuperación BM25 como la densa, especialmente con el modelo de embedding pequeño que usamos.
Siguiente: theory/03-hybrid-search-and-reranking.md.