Embeddings y vector stores
M3 · Embeddings y Vector Stores
Módulo 3 del curso RAGorbit — Sem 3 (~32 h: ~12 h guía · ~8 h ejercicios · ~12 h taller)
Nodos RAGorbit cubiertos:
store.chroma,store.pgvector,store.qdrant,store.neo4j,store.multi-index,model.embeddingTemplates ancla: 09 RRHH (store.chroma) · 02 Banca (store.pgvector)
Índice
- ¿Qué es un embedding?
- Dimensiones y espacio vectorial
- Normalización de vectores
- Métricas de similitud: coseno, dot product, L2
- Qué es un índice vectorial
- Tipos de índice: flat, IVF, HNSW
- Persistencia y colecciones
- ChromaDB a fondo: operaciones CRUD
- FAISS: qué es y cuándo usarlo
- Vector store vs base de datos tradicional
- Sistemas de recomendación con embeddings
- Comparativa de vector stores
- Modelos de embedding: OpenAI vs Cohere vs BGE/E5 locales
- Nodos RAGorbit y ancla a templates
- La capa ③ explicada: del dict en memoria a ChromaDB, FAISS y sentence-transformers
- Checkpoint
1. ¿Qué es un embedding?
Un embedding es la traducción de un objeto de alta dimensión semántica (texto, imagen, audio) a un vector de números reales de longitud fija. No es un hash ni un código — es una representación geométrica: objetos semánticamente similares quedan próximos en el espacio vectorial.
Analogía
Imagina una ciudad en la que cada idea tiene una dirección. "política de vacaciones" y "días de descanso anuales" viven en el mismo barrio; "tipo de interés hipotecario" vive en otro distrito. Un embedding coloca cada frase en su coordenada dentro de este mapa conceptual.
Cómo se genera
Un modelo de embedding (BERT, E5, text-embedding-3-large…) recibe un texto, lo procesa con una arquitectura transformer, y extrae el estado oculto de un token especial ([CLS]) o el promedio de todos los tokens. Este vector resume el significado del texto en ese espacio matemático.
Texto: "¿Cuántos días de vacaciones tengo?"
│
▼
Tokenización
│
▼
Transformer (N capas de atención)
│
▼
Pooling (CLS o mean)
│
▼
Vector: [0.12, -0.34, 0.78, ..., 0.05] ← 1536 dimensiones (text-embedding-3-small)
Por qué no usar TF-IDF o BM25
TF-IDF y BM25 son representaciones léxicas: dos frases idénticas en vocabulario pero diferentes en intención tendrán vectores similares; sinónimos tendrán vectores totalmente distintos. Los embeddings densos capturan semántica: "¿Cuántos días de vacaciones tengo?" y "días de permiso remunerado al año" quedan próximos aunque no compartan palabras.
Esto NO significa que embeddings siempre sean superiores. Para búsqueda de términos exactos (IDs, nombres de función, códigos de producto), BM25 suele ganar. La búsqueda híbrida (M4) combina ambos mundos.
2. Dimensiones y espacio vectorial
La dimensión de un embedding es la longitud del vector. Modelos comunes:
| Modelo | Dimensiones | Notas |
|---|---|---|
text-embedding-3-small |
1 536 | OpenAI, económico |
text-embedding-3-large |
3 072 | OpenAI, mayor calidad |
text-embedding-ada-002 |
1 536 | OpenAI, legacy |
embed-english-v3.0 |
1 024 | Cohere |
BAAI/bge-large-en-v1.5 |
1 024 | Open source, local |
intfloat/e5-large-v2 |
1 024 | Open source, local |
nomic-embed-text-v1 |
768 | Open source, contexto largo |
Dimensionalidad y calidad
Más dimensiones no siempre equivalen a más calidad. Lo que importa es la tarea para la que se entrenó el modelo y el dominio del texto. Un modelo de 768 dimensiones bien alineado con tu dominio puede superar a uno de 3 072 dimensiones entrenado en texto genérico.
La "maldición de la dimensionalidad"
En espacios de muy alta dimensión, las distancias entre puntos tienden a homogeneizarse: la diferencia entre el vecino más cercano y el más lejano se vuelve relativa. Por encima de ~2 000–4 000 dimensiones, los índices aproximados (ANN) se vuelven menos precisos. Para embeddings de texto, las dimensiones actuales (768–3 072) están bien en la práctica porque los vectores no son uniformes — contienen estructura semántica.
Proyección y reducción (UMAP/PCA)
Para visualizar embeddings, se reducen a 2 o 3 dimensiones con UMAP o PCA. Esto es solo para exploración — no uses embeddings reducidos en producción (pierdes información).
3. Normalización de vectores
Un vector está normalizado si su norma L2 (longitud geométrica) es 1. La normalización se aplica dividiéndolo por su norma:
v̂ = v / ‖v‖₂ donde ‖v‖₂ = √(v₁² + v₂² + ... + vₙ²)
Ejemplo numérico
v = [3, 4]
‖v‖ = √(9 + 16) = √25 = 5
v̂ = [3/5, 4/5] = [0.6, 0.8]
‖v̂‖ = √(0.36 + 0.64) = √1.0 = 1.0 ✓
Por qué normalizar
- La mayoría de los modelos de embedding modernos ya devuelven vectores normalizados.
- Con vectores normalizados, similitud coseno = producto punto (dot product). Esto permite usar las operaciones más rápidas de los índices vectoriales.
- Sin normalización, el producto punto favorece a los vectores de mayor magnitud, introduciendo un sesgo hacia textos más largos.
Regla práctica: siempre normaliza antes de indexar a menos que el vendor de tu embedding garantice que ya lo hace (OpenAI text-embedding-3-* lo hace).
4. Métricas de similitud: coseno, dot product, L2
4.1 Similitud coseno
Mide el ángulo entre dos vectores, ignorando su magnitud:
cos(θ) = (A · B) / (‖A‖ · ‖B‖)
Rango: [-1, 1]
- 1 → misma dirección (máxima similitud)
- 0 → perpendiculares (sin relación semántica)
- -1 → opuestos
Ejemplo con vectores pequeños:
A = [1, 0, 1] (representa "perro come hueso")
B = [1, 0, 0.8] (representa "can mastica alimento")
C = [0, 1, 0] (representa "política fiscal")
A · B = 1×1 + 0×0 + 1×0.8 = 1.8
‖A‖ = √(1+0+1) = √2 ≈ 1.414
‖B‖ = √(1+0+0.64) = √1.64 ≈ 1.281
cos(A,B) = 1.8 / (1.414 × 1.281) ≈ 1.8 / 1.812 ≈ 0.994 → muy similar ✓
A · C = 0
cos(A,C) = 0 / (1.414 × 1) = 0 → sin relación ✓
Cuándo usar coseno: casi siempre en text retrieval. Es robusto a la longitud del texto.
4.2 Producto punto (Dot Product / IP — Inner Product)
A · B = Σ (Aᵢ × Bᵢ)
Con vectores normalizados, A · B = cos(θ). Sin normalización, el resultado mezcla similitud angular con magnitud.
Ventaja: es la operación más rápida (SIMD/GPU). Si normalizas previamente, obtienes exactamente la similitud coseno sin el costo de la división.
Cuándo usar IP: cuando el modelo garantiza vectores normalizados Y necesitas máxima velocidad. OpenAI recomienda IP para text-embedding-3-* precisamente porque entrega vectores unitarios.
4.3 Distancia L2 (Euclidiana)
d(A,B) = √(Σ (Aᵢ - Bᵢ)²)
Mide la distancia geométrica directa entre dos puntos. Menor distancia = mayor similitud.
Ejemplo:
A = [0.6, 0.8]
B = [0.5, 0.9]
d = √((0.6-0.5)² + (0.8-0.9)²) = √(0.01 + 0.01) = √0.02 ≈ 0.141
Con vectores normalizados: d(A,B)² = 2 - 2×cos(θ). Es decir, L2 y coseno están relacionados monótonamente — dan el mismo orden de ranking cuando los vectores están normalizados.
Cuándo usar L2: cuando los embeddings NO están normalizados y la magnitud importa (p.ej. embeddings de imágenes donde la intensidad tiene significado).
Resumen de métricas
| Métrica | Fórmula | Rango | Cuándo usar |
|---|---|---|---|
| Coseno | (A·B)/(‖A‖‖B‖) |
[-1, 1] | Text retrieval general |
| Dot product | Σ AᵢBᵢ |
(-∞, +∞) | Vectores normalizados, máxima velocidad |
| L2 euclidiana | √Σ(Aᵢ-Bᵢ)² |
[0, +∞) | Cuando la magnitud importa; clustering |
5. Qué es un índice vectorial
Un índice vectorial es una estructura de datos que permite responder eficientemente a la pregunta: "¿cuáles son los K vectores más similares a este query?"
El problema sin índice
Con N vectores almacenados, responder una query requiere calcular la distancia con CADA vector. Esto es búsqueda exhaustiva (brute force):
Complejidad: O(N × D) donde D = dimensiones
N = 1 000 000, D = 1 536 → 1.5 × 10⁹ operaciones por query
A 10 ms por millón de multiplicaciones: 15 segundos por query. Inaceptable.
La solución: Approximate Nearest Neighbor (ANN)
Los índices ANN sacrifican un poco de recall (pueden perder algún vecino real) a cambio de una velocidad drásticamente mayor. El balance velocidad/recall es el parámetro central de diseño.
Recall = |vecinos_reales_encontrados| / K
Ejemplo: buscas top-5; el índice devuelve 5 resultados, 4 son los reales top-5 → recall@5 = 80%
6. Tipos de índice: flat, IVF, HNSW
6.1 Flat (búsqueda exhaustiva)
No es un índice ANN: compara el query con TODOS los vectores.
Query
│
┌──────┴──────┐
▼ ▼
Todos los vectores se comparan
▼ ▼
└──────┬──────┘
│
Top-K
Ventajas:
- Recall = 100% (exacto)
- Muy simple de implementar
- Sin parámetros de tuning
Desventajas:
- Escala lineal: 10× más datos → 10× más lento
- Límite práctico: ~100k–500k vectores con latencia aceptable
Cuándo usar flat:
- Colecciones pequeñas (< 100k documentos)
- Desarrollo y prototipado
- Cuando la exactitud es crítica (auditores financieros, sistemas médicos)
- Benchmarks de baseline
Nodo RAGorbit: store.chroma en modo por defecto usa flat para colecciones pequeñas.
6.2 IVF (Inverted File Index)
Intuición: agrupa los vectores en C clústeres (celdas de Voronoi). Cuando llega un query, solo busca en los nlist_probe clústeres más cercanos en lugar de todos.
Entrenamiento (k-means):
┌────────────────────────┐
│ ● ● │
│ ☆ (centroide 1) │
│ ● ● ○ ○ │
│ ☆ (centroide 2)│
│ ○ ○ │
└────────────────────────┘
Query Q:
1. Calcular distancia Q a los C centroides (barato: C << N)
2. Seleccionar los nprobe centroides más cercanos
3. Búsqueda exhaustiva solo dentro de esas celdas
Parámetros clave:
nlist(C): número de clústeres. Regla:nlist ≈ sqrt(N). Para 1M vectores → 1000 clústeres.nprobe: cuántos clústeres explorar en query time. Mayor nprobe → mayor recall → mayor latencia.
nprobe = 1 → rápido, recall bajo (~60-70%)
nprobe = 10 → equilibrado, recall ~90%
nprobe = C → igual que flat (exhaustivo)
Ventajas:
- Buen balance para colecciones medianas (100k–10M vectores)
- Entrenamiento rápido con k-means
Desventajas:
- Requiere fase de entrenamiento (k-means)
- Sensible a la distribución de los datos
- Recall cae en bordes de clúster (el vecino real puede estar en el clúster vecino)
Variante IVF+PQ (Product Quantization): comprime cada vector usando cuantización de producto, reduciendo memoria 8-32× a cambio de algo de recall. Ideal para 100M+ vectores en RAM limitada.
6.3 HNSW (Hierarchical Navigable Small World)
Intuición: construye un grafo navegable en múltiples capas (como una autopista + calles secundarias + callejones). La búsqueda empieza en la capa superior (pocas conexiones, saltos largos) y desciende hacia la capa inferior (muchas conexiones, búsqueda fina).
Capa 2 (autopista): A ──────────── E
Capa 1 (secundaria): A ─── B ─── D ─ E
Capa 0 (local): A - a - B - C - D - d - E
Query Q: "encuentra el vecino más cercano a Q"
1. Entrar en la capa superior por el entry point
2. Greedy search: saltar al vecino más cercano al query
3. Descender a la capa inferior
4. Repetir hasta capa 0 con búsqueda local exhaustiva
Parámetros clave:
M: número de conexiones por nodo por capa. Mayor M → mayor recall, mayor memoria, construcción más lenta. Valores típicos: 16–64.ef_construction: tamaño de la lista de candidatos durante la construcción. Mayor → mejor calidad del grafo, más lento. Típico: 100–200.ef_search(oef): tamaño de la cola de búsqueda en query time. Mayor → más recall → más lento.
M=16, ef_construction=200 → construcción equilibrada
ef_search=50 → recall ~95%, rápido
ef_search=200 → recall ~99%, más lento
Ventajas:
- Mejor recall/velocidad que IVF para colecciones medianas
- No requiere fase de entrenamiento separada (construye el grafo incrementalmente)
- Soporta inserciones incrementales eficientemente
- Es el índice por defecto de Chroma, Qdrant y otros
Desventajas:
- Mayor uso de memoria que IVF (almacena el grafo)
- Construcción más lenta que IVF para colecciones muy grandes (>10M)
Comparativa visual:
Velocidad de query
◄──── más lento más rápido ────►
Exactitud
▲ Flat ●
│ HNSW ●
│ IVF+HNSW ●
│ IVF ●
│ IVF+PQ ●
▼
Tabla de decisión
| Criterio | Flat | IVF | HNSW |
|---|---|---|---|
| Colección pequeña (<100k) | ✅ ideal | ok | ok |
| Colección mediana (100k–5M) | lento | ✅ | ✅ |
| Colección grande (>5M) | ❌ | ✅ IVF+PQ | puede saturar RAM |
| Inserciones frecuentes | ✅ | necesita re-index | ✅ |
| Recall exacto requerido | ✅ | ❌ | casi |
| Memoria limitada | ✅ | ✅ con PQ | mayor uso |
7. Persistencia y colecciones
7.1 Modos de persistencia
Los vector stores pueden operar en dos modos:
In-memory (efímero):
store = chromadb.Client() # desaparece al cerrar el proceso
Útil para: tests, prototipado rápido, talleres sin dependencias.
Persistente en disco:
store = chromadb.PersistentClient(path="./chroma_db") # escribe en disco
Útil para: desarrollo local, demos, colecciones que se construyen una vez y se consultan muchas.
Persistente en servidor (producción):
store = chromadb.HttpClient(host="localhost", port=8000)
Útil para: producción, múltiples workers, acceso concurrente.
7.2 Colecciones
Una colección es la unidad de organización dentro de un vector store. Análoga a una tabla en SQL o un índice en Elasticsearch.
Cada colección tiene:
- Un nombre único
- Una función de embedding (puede ser diferente por colección)
- Una métrica de distancia
- Sus propios vectores y metadatos
Cuándo separar en colecciones:
- Dominios distintos (políticas de RRHH vs manuales técnicos) — evita contaminación de resultados
- Idiomas distintos si el modelo no es multilingüe
- Modelos de embedding diferentes
- Ciclos de vida distintos (una colección se actualiza mensualmente; otra es de solo lectura)
Template 09 RRHH: usa una sola colección hr_policies en store.chroma. Suficiente porque todos los documentos son del mismo dominio.
Template 02 Banca: usa store.pgvector con índice credit_docs por expediente. En producción se usarían colecciones o esquemas separados por cliente.
8. ChromaDB a fondo: operaciones CRUD
ChromaDB es el vector store más simple de arrancar: no requiere Docker ni servidor externo para modo local. Por eso es el choice por defecto de RAGorbit para demos y store.chroma.
8.1 Instalación y cliente
# pip install chromadb
import chromadb
# In-memory
client = chromadb.Client()
# Persistente en disco
client = chromadb.PersistentClient(path="./datos/chroma")
# Servidor remoto
client = chromadb.HttpClient(host="localhost", port=8000)
8.2 Gestionar colecciones
# Crear colección
collection = client.create_collection(
name="hr_policies",
metadata={"hnsw:space": "cosine"} # métrica de distancia
)
# Obtener existente (falla si no existe)
collection = client.get_collection("hr_policies")
# Obtener o crear (idempotente)
collection = client.get_or_create_collection(
name="hr_policies",
metadata={"hnsw:space": "cosine"}
)
# Listar todas las colecciones
colecciones = client.list_collections()
# Eliminar colección
client.delete_collection("hr_policies")
8.3 ADD — añadir documentos
collection.add(
ids=["doc_001", "doc_002", "doc_003"],
documents=[
"Los empleados tienen 15 días de vacaciones al año.",
"El seguro médico cubre hasta 3 dependientes.",
"La jornada laboral es de 8 horas con 1 hora de almuerzo."
],
metadatas=[
{"categoria": "vacaciones", "version": "2024"},
{"categoria": "beneficios", "version": "2024"},
{"categoria": "horario", "version": "2023"}
],
# Si no proporcionas embeddings, Chroma los genera con su modelo interno
# embeddings=[[0.1, 0.2, ...], ...] # opcional
)
Notas importantes:
- Los
idsdeben ser únicos dentro de la colección. Si el id ya existe, Chroma lanza un error (usaupsertpara update-or-insert). documentses texto plano que Chroma puede embeber automáticamente si no pasasembeddings.metadatasdebe ser una lista de diccionarios con valoresstr,int,floatobool. NO soporta listas ni dicts anidados.
8.4 QUERY — consultar
resultados = collection.query(
query_texts=["¿cuántos días de vacaciones tengo?"],
n_results=3,
where={"categoria": "vacaciones"}, # filtro de metadata (opcional)
include=["documents", "metadatas", "distances", "embeddings"]
)
# Estructura del resultado:
# {
# 'ids': [['doc_001']],
# 'distances': [[0.12]],
# 'metadatas': [[{'categoria': 'vacaciones', 'version': '2024'}]],
# 'documents': [['Los empleados tienen 15 días de vacaciones al año.']]
# }
Filtros de metadata (operadores):
# Igualdad
where={"categoria": "vacaciones"}
# Operadores: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin
where={"version": {"$gte": "2024"}}
where={"categoria": {"$in": ["vacaciones", "beneficios"]}}
# Combinaciones: $and, $or
where={"$and": [
{"categoria": "vacaciones"},
{"version": {"$gte": "2023"}}
]}
Filtro de contenido con where_document:
where_document={"$contains": "15 días"}
8.5 UPDATE — actualizar
collection.update(
ids=["doc_001"],
documents=["Los empleados tienen 20 días de vacaciones al año (nueva política 2025)."],
metadatas=[{"categoria": "vacaciones", "version": "2025"}]
)
Chroma recalcula automáticamente el embedding del nuevo texto.
8.6 UPSERT — crear o actualizar
collection.upsert(
ids=["doc_001", "doc_004"], # doc_001 existe → update; doc_004 no existe → insert
documents=["...", "..."],
metadatas=[{...}, {...}]
)
Upsert es la operación más segura para pipelines de ingesta que se ejecutan repetidamente.
8.7 DELETE — eliminar
# Por id
collection.delete(ids=["doc_001", "doc_002"])
# Por filtro de metadata
collection.delete(where={"version": "2023"})
# Por contenido
collection.delete(where_document={"$contains": "texto obsoleto"})
8.8 GET — recuperar por id (sin similitud)
resultado = collection.get(
ids=["doc_001", "doc_002"],
include=["documents", "metadatas"]
)
Útil para verificar qué hay indexado o para pipelines de auditoría.
8.9 COUNT y PEEK
total = collection.count() # número de documentos en la colección
sample = collection.peek(5) # primeros 5 documentos (para debug)
Diagrama de flujo típico con ChromaDB
PDF/texto
│
▼
Chunker (M2)
│ chunks con metadata
▼
collection.upsert() ← añade/actualiza vectores
│
│ [más tarde, en query time]
│
▼
collection.query(query_texts=[...], where={...})
│
▼
Top-K chunks → LLM → respuesta con citas
9. FAISS: qué es y cuándo usarlo
FAISS (Facebook AI Similarity Search) es una librería de C++ (con bindings Python) para búsqueda de vecinos más cercanos de alta eficiencia, desarrollada por Meta AI.
Diferencias con ChromaDB
| Aspecto | FAISS | ChromaDB |
|---|---|---|
| Qué es | Librería de índices (solo búsqueda) | Base de datos vectorial completa |
| Metadata filtering | No nativo (necesitas implementarlo tú) | Sí, con operadores ricos |
| Persistencia | Manual (faiss.write_index / read_index) |
Automática |
| CRUD | Solo add/search (no update/delete eficiente) | Completo |
| Velocidad | Extrema (C++, BLAS/CUDA) | Buena |
| Uso típico | Investigación, ML pipelines, escala masiva | RAG apps, demos, producción media |
Índices principales de FAISS
import faiss
import numpy as np
dim = 1536 # dimensión de los embeddings
# Flat (exacto)
index_flat = faiss.IndexFlatL2(dim)
# Flat con similitud coseno (vectores normalizados)
index_ip = faiss.IndexFlatIP(dim)
# IVF + Flat
quantizer = faiss.IndexFlatL2(dim)
index_ivf = faiss.IndexIVFFlat(quantizer, dim, nlist=100)
index_ivf.train(train_vectors) # requiere entrenamiento
index_ivf.nprobe = 10
# HNSW
index_hnsw = faiss.IndexHNSWFlat(dim, M=16)
# IVF + PQ (compresión extrema)
index_pq = faiss.IndexIVFPQ(quantizer, dim, nlist=100, M=8, nbits=8)
Operaciones básicas
# Añadir vectores (deben ser float32)
vectors = np.array([[...], [...]], dtype=np.float32)
index.add(vectors)
# Buscar top-K
query = np.array([[...]], dtype=np.float32)
distances, indices = index.search(query, k=5)
# distances: (1, 5) array con distancias
# indices: (1, 5) array con posiciones en el índice
# Persistencia manual
faiss.write_index(index, "mis_vectores.faiss")
index = faiss.read_index("mis_vectores.faiss")
FAISS con IDs personalizados
Por defecto, FAISS asigna índices enteros (0, 1, 2...). Para mapear a tus IDs de documento, mantén un diccionario externo:
id_map = {} # indice_faiss → id_documento
for i, doc_id in enumerate(tus_ids):
id_map[i] = doc_id
# O usa IndexIDMap para gestión automática
index_with_ids = faiss.IndexIDMap(index_flat)
ids_array = np.array([101, 205, 307], dtype=np.int64)
index_with_ids.add_with_ids(vectors, ids_array)
GPU con FAISS
FAISS tiene soporte nativo para GPU (CUDA):
res = faiss.StandardGpuResources()
index_gpu = faiss.index_cpu_to_gpu(res, 0, index_flat)
# Búsqueda hasta 100× más rápida en GPU
Cuándo elegir FAISS sobre ChromaDB
- Tienes millones de vectores y necesitas máxima velocidad
- Integras en un pipeline de ML (no una app RAG estándar)
- Necesitas control fino del algoritmo de índice (IVF+PQ para memoria limitada, HNSW para recall alto)
- Tu equipo tiene experiencia con numpy/C++
- No necesitas filtros de metadata complejos
10. Vector store vs base de datos tradicional
Por qué no usar PostgreSQL "normal"
Una tabla SQL puede almacenar embeddings como arrays:
CREATE TABLE documentos (
id TEXT PRIMARY KEY,
texto TEXT,
embedding FLOAT8[],
categoria TEXT
);
Pero buscar los K más cercanos requiere:
SELECT id, texto,
embedding <-> query_embedding AS distancia
FROM documentos
ORDER BY distancia
LIMIT 5;
Esto es una búsqueda exhaustiva — O(N). Con 1M documentos, es lentísimo.
pgvector al rescate
pgvector es una extensión de PostgreSQL que añade:
- Tipo de dato
vector(1536) - Operadores de distancia:
<->(L2),<#>(IP negativo),<=>(coseno) - Índices HNSW e IVF dentro de Postgres
CREATE EXTENSION vector;
CREATE TABLE documentos (
id TEXT PRIMARY KEY,
texto TEXT,
embedding vector(1536),
categoria TEXT
);
CREATE INDEX ON documentos USING hnsw (embedding vector_cosine_ops);
SELECT id, texto
FROM documentos
WHERE categoria = 'vacaciones'
ORDER BY embedding <=> query_embedding
LIMIT 5;
Esto combina filtros SQL con búsqueda vectorial eficiente. Por eso store.pgvector es el choice en el template 02 Banca: necesitas filtros duros por doc_type y period usando SQL estándar.
Comparativa conceptual
| Aspecto | BD relacional | BD vectorial | BD relacional + pgvector |
|---|---|---|---|
| Búsqueda semántica | ❌ | ✅ | ✅ |
| Filtros complejos | ✅ | limitado | ✅ |
| Joins, agregaciones | ✅ | ❌ | ✅ |
| Transacciones ACID | ✅ | depende | ✅ |
| Escala >100M vectores | ❌ | ✅ dedicadas | ❌ |
| Infraestructura ya existente | ✅ | no | ✅ si tienes Postgres |
Regla práctica: si ya tienes Postgres en producción y tu escala es < 5M vectores, pgvector es la opción más sencilla. Para escala masiva o funcionalidades avanzadas (filtros numéricos complejos, streaming de actualizaciones), usa Qdrant o Weaviate.
11. Sistemas de recomendación con embeddings
El motor de búsqueda semántica de un vector store es fundamentalmente un motor de recomendación. La misma consulta top-K por similitud que usas para RAG se aplica a recomendación de productos, contenido, canciones, etc.
Patrón item-to-item
"Dado un ítem que el usuario está viendo, recomienda ítems similares":
Ítem actual: embedding(descripción_producto_A)
│
▼
query al vector store con ese embedding
│
▼
Top-5 productos más similares → mostrar como recomendaciones
Patrón user-to-item (collaborative filtering denso)
"Dado el historial de un usuario, recomienda nuevos ítems":
- Generar el embedding del usuario: promedio o transformación de los embeddings de los ítems que consumió.
- Buscar top-K en el espacio de ítems.
# Perfil del usuario como promedio de embeddings de artículos leídos
perfil_usuario = np.mean([embedding(articulo_1), embedding(articulo_2), ...], axis=0)
top_k = vector_store.query(perfil_usuario, k=5)
Patrón de detección de duplicados/near-duplicates
Para cada nuevo documento:
embedding(doc_nuevo) → query top-1 en el store
Si similitud > 0.95 → probable duplicado, no indexar
Ancla con RAGorbit
En el template 09 RRHH, el mismo store.chroma con retrieval.vector actúa como motor de recomendación de políticas: dada la pregunta del empleado, recomienda los fragmentos más relevantes. La búsqueda vectorial es la misma operación matemática que un sistema de recomendación.
12. Comparativa de vector stores
Tabla principal
| Store | Tipo | Filtros | Índices | Escala | On-premise | Cloud managed | Fortaleza |
|---|---|---|---|---|---|---|---|
| ChromaDB | Open source | Ricos (operadores) | HNSW, flat | Hasta ~10M | ✅ | ❌ nativo | Simplicidad, zero-config, ideal RAG apps |
| FAISS | Librería | Manual (externo) | Flat, IVF, HNSW, PQ | 100M+ | ✅ | ❌ | Velocidad extrema, investigación, ML pipelines |
| pgvector | Extensión Postgres | Full SQL | HNSW, IVF | ~5M práctico | ✅ | ✅ (RDS, AlloyDB, Supabase) | Si ya tienes Postgres; joins complejos |
| Qdrant | BD vectorial dedicada | Muy ricos (payload) | HNSW, cuantización | 100M+ | ✅ Docker | ✅ Qdrant Cloud | Filtros avanzados, rendimiento, Rust |
| Pinecone | BD vectorial SaaS | Metadata filters | Propietario (ANN) | Ilimitada | ❌ | ✅ | Zero-ops, escala automática |
| Weaviate | BD vectorial + grafo | GraphQL + BM25 híbrido | HNSW | 100M+ | ✅ Docker | ✅ WCS | Búsqueda híbrida nativa, multimodal |
| Milvus | BD vectorial open | Rich | HNSW, IVF, DiskANN | 1B+ | ✅ | ✅ Zilliz | Escala enterprise, ecosistema Attu |
Cuándo elegir cada uno
ChromaDB: primer prototipo, demos, equipos sin DevOps. store.chroma en RAGorbit.
FAISS: necesitas lo más rápido posible y controlas tú la infraestructura (pipelines ML internos, investigación). Sin gestión de colecciones ni servidor.
pgvector: ya tienes Postgres y tu escala es < 5M vectores. Evitas añadir otro sistema. Template 02 Banca usa store.pgvector porque los filtros duros SQL son parte del requisito regulatorio.
Qdrant: production-grade, necesitas filtros de payload complejos, quieres on-premise sin lock-in a cloud. Muy buen equilibrio velocidad/features.
Pinecone: equipo de producto que no quiere gestionar infraestructura y puede pagar el SaaS. La opción "serverless" de vector stores.
Weaviate: necesitas búsqueda híbrida (semántica + BM25) nativa sin código extra, o el dominio combina texto con imágenes.
Milvus: escala de 100M–1B+ vectores, empresa grande con equipo de plataforma dedicado.
Anti-patrones frecuentes
- Usar ChromaDB en producción con 50M+ documentos (se vuelve lento).
- Usar FAISS cuando necesitas filtros de metadata (tienes que implementar la lógica tú y re-filtrar post-búsqueda, lo que degrada el recall).
- Usar pgvector para colecciones > 5M sin análisis de rendimiento previo.
- Elegir Pinecone por defecto por comodidad sin evaluar el lock-in.
13. Modelos de embedding: OpenAI vs Cohere vs BGE/E5 locales
Dimensión comparativa
| Modelo | Dim | Max tokens | Multilingüe | Costo | Privacidad | Velocidad |
|---|---|---|---|---|---|---|
text-embedding-3-small |
1 536 | 8 191 | Sí | $0.02/1M tokens | ❌ API externa | API latency |
text-embedding-3-large |
3 072 | 8 191 | Sí | $0.13/1M tokens | ❌ API externa | API latency |
text-embedding-ada-002 |
1 536 | 8 191 | Sí | $0.10/1M tokens | ❌ API externa | API latency, legacy |
embed-english-v3.0 |
1 024 | 512 | No (english) | $0.10/1M tokens | ❌ API externa | API latency |
embed-multilingual-v3.0 |
1 024 | 512 | Sí (100 idiomas) | $0.10/1M tokens | ❌ API externa | API latency |
BAAI/bge-large-en-v1.5 |
1 024 | 512 | No (english) | Gratis | ✅ local | GPU requerida para velocidad |
BAAI/bge-m3 |
1 024 | 8 192 | Sí (100 idiomas) | Gratis | ✅ local | GPU recomendada |
intfloat/e5-large-v2 |
1 024 | 512 | No | Gratis | ✅ local | GPU requerida |
intfloat/multilingual-e5-large |
1 024 | 512 | Sí | Gratis | ✅ local | GPU recomendada |
nomic-embed-text-v1 |
768 | 8 192 | No | Gratis | ✅ local | GPU opcional |
Cuándo elegir cada familia
OpenAI (text-embedding-3-*):
- Ya usas OpenAI para LLM (API key lista)
- Contenido en múltiples idiomas sin complejidad adicional
- No tienes GPU local
- Quieres el menor tiempo de desarrollo posible
Cohere (embed-*-v3):
- Documentos en inglés puro con límite de 512 tokens (ya troceas bien)
- La API de Cohere ya está en tu stack (p.ej. usas su reranker)
BGE (BAAI):
- Privacidad de datos: los documentos no pueden salir de tu infraestructura
- Presupuesto limitado (cero costo de API)
- Tienes GPU disponible (A10/T4/RTX son suficientes)
- Dominio específico: puedes hacer fine-tune de BGE con datos propios
E5:
- Similar a BGE. La familia E5 tiene variantes de "instruction-tuned" que aceptan un prefijo de tarea (
query: .../passage: ...) para mejorar precisión en retrieval asimétrico.
Nodo RAGorbit model.embedding:
{
"type": "model.embedding",
"config": {
"model": "text-embedding-3-large",
"local": false,
"apiKeyRef": "OPENAI_API_KEY"
}
}
Para usar un modelo local:
{
"type": "model.embedding",
"config": {
"model": "BAAI/bge-large-en-v1.5",
"local": true
}
}
Embeddings asimétricos vs simétricos
Simétrico: query y documento son del mismo tipo (ambos son preguntas o ambos son respuestas). Los modelos estándar funcionan bien.
Asimétrico: la query es corta ("¿días de vacaciones?") y el documento es largo (párrafo completo de la política). Los modelos tipo E5 y BGE tienen variantes específicas para retrieval asimétrico:
# E5: prefijo de tarea
query_text = "query: ¿cuántos días de vacaciones tengo?"
doc_text = "passage: Los empleados tienen derecho a 15 días..."
En RAG, el retrieval es casi siempre asimétrico. Para producción con alta calidad, usa E5 o BGE con los prefijos correspondientes.
14. Nodos RAGorbit y ancla a templates
model.embedding
Nodo independiente que proporciona la función de embedding al store. No produce chunks ni texto — produce Embeddings que el store consume para indexar.
model.embedding (Embeddings →) ──────────▶ store.chroma/pgvector/qdrant (→ Embeddings)
Configuración típica:
{
"model": "text-embedding-3-large",
"local": false,
"apiKeyRef": "OPENAI_API_KEY"
}
store.chroma
Chroma local, sin infraestructura. Ideal para demos y desarrollo. En el template 09 RRHH (hr-policy-assistant), el grafo es:
loader.pdf → ingest.chunker → store.chroma ← model.embedding
│ Retriever
▼
retrieval.vector (topK: 4)
Sin filtros de metadata porque todas las políticas son del mismo dominio.
store.pgvector
Postgres con extensión vectorial. En el template 02 Banca (banking-credit-scoring):
loader.pdf + loader.tabular → ingest.chunker → ingest.metadata → store.pgvector ← model.embedding
│ Retriever
▼
retrieval.vector (topK: 6, hardFilters: [doc_type, period])
Los filtros doc_type y period aseguran que para evaluar el expediente del año 2023 solo se recuperen documentos de ese período — guardrail semántico implementado como filtro de metadata.
store.qdrant, store.neo4j, store.multi-index
store.qdrant: producción con filtros de payload avanzados y escalabilidad. Los templates de salud (M4) y telecom (M4) lo usarían en producción.store.neo4j: GraphRAG. Los documentos se almacenan como nodos con relaciones tipadas. Permite recuperar por vecindario en el grafo, no solo por similitud vectorial (M4).store.multi-index: agrupa múltiples índices para routing. El retriever puede elegir el índice correcto según la query (M4).
15. La capa ③ explicada: del dict en memoria a ChromaDB, FAISS y sentence-transformers
Para quién es esta sección: acabas de completar el taller en capa ② (
lab/solucion_scratch.py): undicten memoria, embedding bag-of-words de 20 dimensiones, coseno manual y filtro a mano. Aquí aprendes las tres librerías que reemplazan cada pieza — para que puedas escribirlab/solucion_framework.pytú mismo, no solo leerlo.Prerrequisitos: haber leído §8 (ChromaDB) y §9 (FAISS). Esta sección no las duplica: las conecta con lo que ya hiciste a mano.
15.1 El mapa mental: tu scratch vs las librerías reales
En el taller scratch construiste un pipeline completo con solo Python estándar. Cada pieza tiene un equivalente en producción:
CAPA ② (scratch) CAPA ③ (framework)
───────────────── ──────────────────────────────
embeder(texto) SentenceTransformer.encode()
bag-of-words 20 dim BGE-base 768 dim (transformer)
store = {id: {vector, texto, chromadb.Client() +
metadata}} collection.upsert(...)
coseno(a, b) manual Chroma: distances en query()
FAISS: IndexFlatIP.search()
for doc in store: top-k manual collection.query(n_results=k)
index.search(query_vec, k)
if metadata["cat"] == "vac": Chroma: where={"categoria":...}
filtro antes del ranking FAISS: post-filtering en Python
dict en RAM (se pierde al cerrar) Chroma: PersistentClient
FAISS: write_index / read_index
Tabla puente detallada:
| Lo que hiciste a mano (scratch) | Pieza real | Librería / API |
|---|---|---|
embeder(texto) — conteo de 20 palabras del vocabulario |
Modelo neuronal que convierte texto → vector denso de 768 dims | sentence-transformers: SentenceTransformer("BAAI/bge-base-en-v1.5").encode(textos, normalize_embeddings=True) |
store[id] = {"vector", "texto", "metadata"} — dict Python |
Colección con vectores + texto + metadata indexados | chromadb: client.get_or_create_collection(...) + collection.upsert(ids, documents, embeddings, metadatas) |
coseno(query_vec, doc_vec) — producto punto de vectores normalizados |
Índice que calcula IP (= coseno si normalizas) sobre millones de vectores en C++ | faiss: IndexFlatIP(dim) + search(query_vec, k) |
buscar(query, k, filtro) — recorre todos los docs, filtra, ordena |
Query con filtro integrado en el índice (pre-filtering) | chromadb: collection.query(..., where={"categoria": "vacaciones"}) — ver §8.4 |
| Mismo filtro en FAISS | Pedir K_extra resultados y filtrar en Python después | Post-filtering manual — ver §9 y §15.5 |
| Sin persistencia (RAM) | Guardar en disco y recuperar | Chroma: PersistentClient(path=...) · FAISS: faiss.write_index / read_index |
| Búsqueda O(N) exhaustiva sobre 12 docs | Índice ANN (HNSW) para millones | Chroma activa HNSW internamente · FAISS: IndexHNSWFlat(dim, M) |
Diagrama del flujo completo (capa ③):
doc_01.json … doc_12.json
│
▼
┌─────────────────────────────────────┐
│ SentenceTransformer.encode() │ ← reemplaza embeder()
│ textos → array (12, 768) float32 │
│ normalize_embeddings=True │
└──────────────┬──────────────────────┘
│
┌─────────┴─────────┐
▼ ▼
ChromaDB FAISS
collection.upsert() IndexIDMap.add_with_ids()
+ where en query + id_a_doc mapa externo
│ │
▼ ▼
query + filtro query + post-filter
nativo (pre-filter) manual en Python
15.2 sentence-transformers: tu embeder() pero de verdad
¿Qué es?
sentence-transformers es una librería Python que envuelve modelos transformer (BERT, BGE, E5…) entrenados para producir vectores de oraciones completas. No necesitas saber cómo funciona un transformer por dentro — solo necesitas saber que convierte texto en un array de números donde textos parecidos en significado quedan cerca.
pip install sentence-transformers
# La primera vez descarga el modelo (~440 MB para BGE-base)
Instalación mínima y primer uso
from sentence_transformers import SentenceTransformer
# Cargar modelo (descarga automática la primera vez)
modelo = SentenceTransformer("BAAI/bge-base-en-v1.5")
# Un solo texto → vector 1D de 768 floats
vec = modelo.encode("dias de permiso y descanso", normalize_embeddings=True)
print(len(vec)) # 768
print(vec[:3]) # [-0.02, 0.15, -0.08, ...] (valores reales, no conteos)
# Varios textos → matriz (n, 768)
textos = [
"Los empleados tienen 15 dias de vacaciones al ano.",
"El seguro medico cubre dependientes.",
]
matriz = modelo.encode(textos, normalize_embeddings=True)
print(matriz.shape) # (2, 768)
Cómo reemplaza tu embeder() del scratch
| Aspecto | embeder() scratch |
modelo.encode() real |
|---|---|---|
| Dimensiones | 20 (fijas, vocabulario manual) | 768 (aprendidas por el modelo) |
| Semántica | Solo palabras exactas del vocab | Sinónimos y paráfrasis cercanos |
| Determinismo | Sí (mismo texto → mismo vector) | Sí (mismo modelo + mismo texto → mismo vector) |
| Red / pip | No requiere | Requiere pip + descarga del modelo |
| Normalización | Tú llamas normalizar() |
normalize_embeddings=True lo hace el modelo |
Mini-ejemplo comparativo:
# SCRATCH (lo que hiciste en el taller):
def embeder(texto):
tokens = texto.lower().split()
return [float(tokens.count(p)) for p in VOCAB] # 20 dims, bag-of-words
# FRAMEWORK (lo que usarás en capa ③):
modelo = SentenceTransformer("BAAI/bge-base-en-v1.5")
vec = modelo.encode(texto, normalize_embeddings=True) # 768 dims, semántica real
Con el embedding real, "dias de permiso" y "vacaciones anuales" tendrán similitud alta aunque no compartan palabras — algo imposible con bag-of-words.
¿Por qué normalize_embeddings=True?
Igual que en el scratch: si normalizas antes de indexar, el producto punto es la similitud coseno. FAISS con IndexFlatIP y Chroma con metadata={"hnsw:space": "cosine"} asumen vectores unitarios. Si no normalizas:
- FAISS IP favorece vectores largos (textos largos ganan sin ser más relevantes).
- Las distancias de Chroma pierden calibración.
Regla: siempre normalize_embeddings=True al llamar .encode() para retrieval.
Bi-encoder vs cross-encoder (intuición, sin profundizar)
- Bi-encoder (lo que usa
sentence-transformers): embede query y documento por separado → comparas vectores con coseno. Rápido: puedes pre-calcular todos los documentos y buscar en O(log N) con un índice. - Cross-encoder (rerankers, M4): mete query + documento juntos en un solo modelo → score de relevancia más preciso pero lento (no puedes pre-indexar). Se usa en segunda pasada para reordenar top-100.
Para indexar y buscar (este módulo), siempre bi-encoder.
Tamaño del modelo BGE-base
BAAI/bge-base-en-v1.5 pesa ~440 MB en disco. La primera ejecución lo descarga de Hugging Face. En CPU tarda ~50–200 ms por batch pequeño; con GPU es mucho más rápido. Para datos privados de empleados (template 09 RRHH), es la elección correcta: cero costo de API, datos no salen de tu máquina.
15.3 Puente hacia ChromaDB (§8) y FAISS (§9)
Ya leíste las APIs completas en §8 y §9. Aquí solo el puente conceptual desde tu scratch:
ChromaDB = tu dict store + índice + filtros, empaquetado:
| Tu función scratch | Equivalente ChromaDB | Sección |
|---|---|---|
store[id] = {...} al cargar JSONs |
collection.upsert(ids, documents, embeddings, metadatas) |
§8.6 |
buscar(query, k, filtro=None) |
collection.query(query_texts=[query], n_results=k, where=filtro) |
§8.4 |
actualizar(id, ...) en demo CRUD |
collection.upsert(ids=[id], ...) |
§8.6 |
eliminar(id) |
collection.delete(ids=[id]) |
§8.7 |
len(store) |
collection.count() |
§8.9 |
FAISS = solo el motor de búsqueda vectorial rápido; tú gestionas el resto:
| Tu función scratch | Equivalente FAISS | Sección |
|---|---|---|
store dict con vectores |
IndexFlatIP(dim) o IndexHNSWFlat(dim, M) |
§9 |
IDs string ("doc_01") |
IndexIDMap + add_with_ids(vectors, ids_numericos) |
§9 — FAISS con IDs personalizados |
metadata en cada entrada del dict |
No existe en FAISS → mapa externo id_a_doc = {i: doc} |
§9 — diferencias con ChromaDB |
buscar() con filtro |
search(k_extra) + filtrar en Python (post-filtering) |
§15.5 |
| Guardar store en disco | faiss.write_index(index, "archivo.faiss") |
§9 — operaciones básicas |
15.4 Antes de escribir código: qué instalar
pip install chromadb faiss-cpu sentence-transformers
# faiss-cpu en Mac/Linux sin GPU; usa faiss-gpu si tienes CUDA
La primera ejecución descarga BAAI/bge-base-en-v1.5 (~440 MB). Necesitas red. En el entorno del curso (sin pip/red) solo corre la capa ②; la capa ③ la ejecutas en tu máquina cuando tengas los paquetes.
15.5 Recorrido bloque por bloque de lab/solucion_framework.py
Abre lab/solucion_framework.py mientras lees. El archivo tiene dos secciones (A: ChromaDB, B: FAISS) más una comparativa.
Sección A — ChromaDB (demo_chromadb)
Bloque 1: Cliente y colección (líneas ~31–38)
client = chromadb.Client() # in-memory; en producción: PersistentClient(path="./datos")
collection = client.get_or_create_collection(
name="hr_policies",
metadata={"hnsw:space": "cosine"} # métrica coseno en el índice interno
)
Client()= equivalente a tustore = {}vacío en RAM. Desaparece al cerrar el proceso.get_or_create_collection= crear la "tabla" donde vivirán vectores + texto + metadata. Elmetadata={"hnsw:space": "cosine"}le dice a Chroma que use distancia coseno (como tucoseno()manual).- Detalle de persistencia: §7.1 y §8.1.
Bloque 2: Modelo de embedding (líneas ~40–44)
modelo = SentenceTransformer("BAAI/bge-base-en-v1.5")
Reemplaza tu embeder(). Chroma podría embeber solo con documents= y su modelo interno (all-MiniLM), pero aquí queremos controlar el modelo — igual que en producción con model.embedding en RAGorbit.
Bloque 3: Cargar JSONs (líneas ~46–54)
for archivo in sorted(datos_dir.glob("doc_*.json")):
doc = json.load(f)
ids.append(doc["id"])
textos.append(doc["texto"])
metadatas.append(doc["metadata"])
Idéntico a tu cargar_documentos() del scratch: separas id, texto y metadata en listas paralelas (Chroma las quiere así).
Bloque 4: Indexar con embeddings pre-calculados (líneas ~64–71)
embeddings = modelo.encode(textos, normalize_embeddings=True).tolist()
collection.upsert(
ids=ids,
documents=textos,
embeddings=embeddings,
metadatas=metadatas,
)
modelo.encode(...)→ matriz (12, 768);.tolist()porque Chroma espera listas Python, no numpy.upsert= "crea si no existe, actualiza si existe" — la operación segura para pipelines de ingesta. Ver §8.6.- Pasar
embeddings=explícitos evita que Chroma use su modelo interno (diferente dimensionalidad).
Bloque 5: Búsqueda A — sin filtro (líneas ~75–91)
resultados = collection.query(
query_texts=[query],
n_results=3,
include=["documents", "metadatas", "distances"]
)
- Equivalente a tu
buscar(query, k=3, filtro=None). query_textsacepta texto crudo; Chroma lo embebe internamente o puedes pasarquery_embeddings=si ya calculaste el vector con tu modelo.includecontrola qué campos devuelve. Siempre pidedistancespara interpretar scores.
Interpretar distances → similitud:
Chroma con espacio coseno devuelve distancia (no similitud):
0= idénticos2= opuestos (vectores en direcciones contrarias)
Conversión a similitud coseno:
similitud = 1 - distancia / 2
El código del lab hace sim = 1 - dist / 2. Con vectores normalizados, sim estará en [0, 1] (1 = máxima similitud).
Bloque 6: Búsqueda B — con filtro (líneas ~93–107)
resultados_filtro = collection.query(
query_texts=[query],
n_results=3,
where={"categoria": "vacaciones"},
include=["documents", "metadatas", "distances"]
)
- Equivalente a tu
buscar(query, k=3, filtro={"categoria": "vacaciones"}). - Pre-filtering: Chroma filtra antes de rankear. Los 3 resultados garantizados pasan el filtro. Ver operadores en §8.4.
Bloque 7: Filtros avanzados (líneas ~109–127)
where={
"$and": [
{"categoria": {"$in": ["vacaciones", "horario"]}},
{"version": {"$gte": "2024"}}
]
}
Demuestra $and, $in, $gte — lo que en el scratch tendrías que programar a mano con if anidados.
Bloque 8: CRUD (líneas ~129–141)
collection.upsert(ids=["doc_01"], documents=[...], metadatas=[...]) # actualizar
collection.delete(ids=["doc_11", "doc_12"]) # eliminar
collection.get(ids=["doc_01"], include=["metadatas"]) # leer por id
collection.count() # contar
Replica el demo CRUD de tu solucion_scratch.py con APIs nativas. Ver §8.5–8.9.
Sección B — FAISS (demo_faiss)
Bloque 1: Mismo modelo, mismos datos (líneas ~162–174)
modelo = SentenceTransformer("BAAI/bge-base-en-v1.5")
embeddings = modelo.encode(textos, normalize_embeddings=True)
dim = embeddings.shape[1] # 768
Mismo embedding que Chroma. La diferencia empieza después de tener los vectores.
Bloque 2: Construir índice (líneas ~179–189)
index = faiss.IndexFlatIP(dim) # producto punto exacto
index_with_ids = faiss.IndexIDMap(index) # permite IDs numéricos arbitrarios
index_with_ids.add_with_ids(
embeddings.astype(np.float32), # FAISS exige float32
ids_numericos # np.arange(12)
)
IndexFlatIP= búsqueda exhaustiva por producto punto. Con vectores normalizados, IP = coseno — igual que tu buclefor doc in store: coseno(...).IndexIDMapenvuelve el índice para que puedas usar IDs enteros (0, 1, 2…) en lugar de posiciones implícitas.- FAISS no guarda texto ni metadata — solo vectores y posiciones.
Bloque 3: Mapa id → documento (línea ~193)
id_a_doc = {i: docs[i] for i in range(len(docs))}
Obligatorio. Sin este diccionario externo, search() te devuelve índices numéricos (0, 5, 3) pero no sabes qué documento es ni su categoría. Chroma lo resuelve internamente; en FAISS es tu responsabilidad.
Bloque 4: Búsqueda A — sin filtro (líneas ~195–203)
query_vec = modelo.encode([query], normalize_embeddings=True).astype(np.float32)
scores, indices = index_with_ids.search(query_vec, k=3)
scores= producto punto (= similitud coseno si normalizaste). Ya es similitud, no distancia — a diferencia de Chroma.indices= IDs numéricos que pasaste enadd_with_ids.
Bloque 5: Búsqueda B — post-filtering (líneas ~205–225)
k_extra = 12 # pedir TODOS porque FAISS no puede filtrar
scores_all, indices_all = index_with_ids.search(query_vec, k=k_extra)
filtrados = []
for score, idx in zip(scores_all[0], indices_all[0]):
doc = id_a_doc[idx]
if doc["metadata"]["categoria"] == filtro_categoria:
filtrados.append((score, doc))
if len(filtrados) == 3:
break
Por qué k_extra = 12: con solo 12 documentos, pedimos todos y filtramos. Con 1M documentos y filtro restrictivo, pedir k=3 podría devolver 0 resultados válidos (los 3 más similares globalmente no son de categoría "vacaciones"). Solución: pedir k=100 o k=1000 y filtrar — pero el recall se degrada.
Bloque 6: Persistencia (líneas ~227–232)
faiss.write_index(index_with_ids, "/tmp/hr_policies.faiss")
index_recuperado = faiss.read_index("/tmp/hr_policies.faiss")
Solo guarda vectores + estructura del índice. Tu mapa id_a_doc debes persistirlo aparte (JSON, SQLite…). Chroma con PersistentClient guarda todo junto.
Bloque 7: HNSW alternativo (líneas ~234–242)
index_hnsw = faiss.IndexHNSWFlat(dim, 16) # M=16 conexiones por nodo
index_hnsw.add(embeddings.astype(np.float32))
Para colecciones grandes (>100k) donde flat es lento. Aquí con 12 docs es irrelevante — es ilustrativo. Ver §6.3.
Comparativa final (imprimir_comparativa)
Resume en tabla lo que acabas de ver: Chroma = menos código, filtros nativos, CRUD; FAISS = más control, más velocidad a escala, más código manual.
15.6 Gotchas (errores frecuentes al pasar de scratch a framework)
| Gotcha | Qué pasa | Cómo evitarlo |
|---|---|---|
| Distancia ≠ similitud en Chroma | Interpretas distances=0.12 como "12% similar" |
Con coseno: sim = 1 - dist/2. Con vectores normalizados, dist 0 = idénticos, dist 2 = opuestos |
| FAISS sin mapa id→doc | search() devuelve 5 pero no sabes qué documento es |
Mantén id_a_doc = {i: doc} o usa IndexIDMap + mapeo inverso |
| Post-filtering con k muy pequeño | Pides top-3 en FAISS, filtras por categoría, obtienes 0–1 resultados | Pide k_extra grande (10× el k deseado como mínimo) y filtra después |
Olvidar normalize_embeddings=True |
FAISS IP y Chroma coseno dan rankings incorrectos | Siempre normaliza al .encode() y al indexar |
| Tipos float en FAISS | Error silencioso o crash | embeddings.astype(np.float32) — FAISS no acepta float64 |
| Metadata con listas en Chroma | add() lanza error |
Solo str, int, float, bool en metadata — ver ejercicio 17.a |
| Descarga del modelo | Primera ejecución tarda minutos | Planifica la descarga de BGE (~440 MB) con antelación |
| Dos upsert en el demo | El lab hace upsert dos veces (con y sin embeddings explícitos) | En tu código, usa solo uno: o dejas que Chroma embeba, o pasas embeddings= — no ambos |
15.7 Tu checklist antes del taller capa ③
Antes de escribir solucion_framework.py (o tu propia versión), verifica que puedes:
- Instalar
chromadb,faiss-cpu,sentence-transformersy descargar BGE-base. - Explicar qué reemplaza cada función de tu scratch (
embeder,buscar,store, filtro). - Escribir
collection.upsert(...)ycollection.query(..., where=...)sin copiar. - Convertir
distancesde Chroma a similitud con1 - dist/2. - Construir
IndexFlatIP+IndexIDMap+ mapaid_a_docen FAISS. - Implementar post-filtering en FAISS pidiendo
k_extraresultados. - Comparar Chroma vs FAISS para el caso del taller (12 docs, filtro por categoría).
Siguiente paso: lab/enunciado.md — Parte 5 (capa ③ guiada). Compara tu código con lab/solucion_framework.py.
Panorama del mercado: este módulo usa Chroma/FAISS/pgvector como representantes, pero hay 6+ familias de almacenamiento (vector dedicadas, relacional+vector, motores híbridos, NoSQL+vector, grafos, especializadas) y a veces no necesitas una vector DB. Mapa completo y vendor-neutral en
../referencia/panorama-bases-de-datos.md.
16. Checkpoint
Lo sabes si puedes...
- Explicar en 2 minutos qué es un embedding, por qué preserva semántica y cuándo BM25 lo supera.
- Escribir de memoria la fórmula de similitud coseno y calcular el resultado para vectores de 3 dimensiones.
- Explicar la diferencia entre flat, IVF y HNSW: intuición, parámetros clave, trade-offs.
- Decidir qué tipo de índice usar dado N (número de documentos) y el requisito de recall.
- Hacer las 4 operaciones CRUD en ChromaDB con filtros de metadata.
- Enumerar 3 razones para elegir FAISS y 3 para elegir ChromaDB.
- Elegir entre pgvector, Qdrant y Pinecone dado un brief técnico.
- Explicar por qué el template 02 Banca usa
store.pgvectorcon filtrosdoc_type/period. - Nuevo: mapear cada pieza de tu scratch (
embeder,store,buscar, filtro) a su equivalente en sentence-transformers, ChromaDB y FAISS. - Nuevo: escribir de memoria
collection.query(...)con filtrowherey convertirdistancesa similitud. - Nuevo: explicar por qué FAISS necesita un mapa
id_a_docy qué es el post-filtering.
Qué repasar si algo no quedó claro
- Normalización y distancias → sección 3 y 4
- IVF vs HNSW → sección 6 + tabla de decisión
- ChromaDB CRUD → sección 8 completa (con código)
- Puente scratch → framework → sección 15 (esta sección)
- Elegir store → sección 12 (tabla comparativa + anti-patrones)
Siguiente: → ejercicios.md · lab/enunciado.md
Anterior: → M2 — Ingesta
Referencia: → referencia/tecnologias-comparadas.md