Un Retriever, tres backends: Portar la búsqueda de NumPy a Chroma, FAISS y Pinecone
Lleve un recuperador de coseno de NumPy elaborado a mano a Chroma, FAISS y Pinecone, y descubra qué aportan los índices, los filtros de metadatos y la persistencia, además de los errores que afectan la relevancia.
Fuentes: Vector Databases, Hands-On — From Your numpy Array to Pinecone
El almacén de vectores más simple es una matriz NumPy de embeddings junto con una sola operación aritmética: chunk_vectors @ q_vec calcula la puntuación de cada bloque en relación con la consulta, y un ordenamiento selecciona los resultados más relevantes. Eso constituye una verdadera base de datos vectorial, solo la versión más mínima posible. Esta guía toma exactamente ese mecanismo de recuperación y lo adapta a tres backends reales, Chroma, FAISS y Pinecone, para que pueda ver con precisión qué aporta cada uno, cuál de los numerosos productos disponibles (Chroma, FAISS, Qdrant, Weaviate, Milvus, pgvector, Pinecone) se adapta a cada situación, y qué errores pueden arruinar silenciosamente la relevancia de los resultados.
Los ejemplos locales no requieren claves API y son gratuitos. Pinecone ofrece una versión inicial sin servidores gratuita que necesita un registro y una clave, pero, en el momento de escribir esto, no se exige tarjeta de pago, por lo que también se puede probar la opción gestionada de forma gratuita. El código asume la existencia de un único script, vdb.py, que se ejecuta bloque por bloque.
Cuando un array de NumPy ya no es suficiente
Un recuperador creado manualmente calcula la similitud coseno con cada fragmento almacenado en cada consulta. Con unos pocos cientos de fragmentos, el proceso es instantáneo. Pero con varios millones, se vuelve lento: toda la matriz debe permanecer en RAM y todos los embeddings desaparecen cuando finaliza el proceso. Una base de datos vectorial soluciona tres problemas:
- Rapidez a gran escala. En lugar de realizar una comparación exacta O(n) con cada vector, se crea un índice de vecino más cercano aproximado (ANN) como HNSW, que encuentra los vectores casi más cercanos en aproximadamente O(log n). Se sacrifica una pequeña cantidad de precisión a cambio de una gran mejora en la velocidad.
- Persistencia. Los vectores se almacenan en disco o en un servidor gestionado y sobreviven a los reinicios.
- Metadatos y filtrado. Cada vector puede contener atributos como
{"source": "doc12", "date": "2026-01"}, y una consulta puede restringirse a ellos, por ejemplo, los vectores más cercanos cuyo origen seadoc12.
El contraste entre los dos métodos de consulta se resume en dos líneas:
your numpy array: query ──► compare to ALL n vectors ──► sort ──► top-k (exact, slow at scale)
a vector database: query ──► walk HNSW graph ──► ~top-k (approximate, fast, on disk, filterable)
Esa es toda la propuesta de valor. Todo lo demás son detalles técnicos.
Vocabulario que seguirás encontrando
- Incorporación o vector: la lista de números que codifica el significado; los ejemplos aquí utilizan
all-MiniLM-L6-v2. - Métrica de distancia: cómo se mide la cercanía. Coseno (el ángulo entre vectores) es la opción habitual para texto; producto escalar y euclidiano (L2) son las alternativas. Utiliza el que esté adaptado al modelo de incorporación con el que se entrenó.
- Índice: la estructura que permite búsquedas rápidas. HNSW, un grafo navegable, es el predeterminado en la mayoría de las bases de datos; IVF y PQ equilibran la memoria con la velocidad de maneras diferentes.
- Upsert: insertar un vector por ID, o actualizarlo si ese ID ya existe.
Chroma: la primera actualización natural
Chroma se ejecuta dentro de su proceso Python tras una sola instalación, sin servidor ni Docker. Eso lo convierte en el paso más directo para mejorar una matriz NumPy. Úselo junto con la biblioteca de embedding:
pip install chromadb sentence-transformers
El script a continuación utiliza un pequeño corpus que describe un producto ficticio de toma de notas llamado Nimbus, con una etiqueta de tema por documento. Crea un cliente en memoria, abre una colección configurada para la distancia de coseno mediante hnsw:space, incrusta explícitamente los documentos y los agrega con IDs y metadatos. Luego incrusta una pregunta y solicita los dos documentos más cercanos, imprimiendo cada uno junto con su distancia. Observe el comentario sobre chromadb.Client(): basta con reemplazarlo por PersistentClient(path=...) para guardar los datos en disco.
# vdb.py
import chromadb
from sentence_transformers import SentenceTransformer
embedder = SentenceTransformer("all-MiniLM-L6-v2")
# Same Nimbus corpus from the RAG tutorial
DOCS = [
"Nimbus Pro costs 8 dollars per month billed annually, with 50 GB storage.",
"Nimbus offers a 30-day refund policy on all paid plans.",
"Nimbus Lite (free) includes 1 GB of storage and up to 50 notes.",
"Nimbus stores notes encrypted at rest with AES-256; E2E encryption is Pro-only.",
"Live chat support is Pro-only, Mon-Fri 9-6 UTC; free users get 48h email support.",
]
metadatas = [{"topic": t} for t in ["pricing","refund","pricing","security","support"]]
client = chromadb.Client() # in-memory; use PersistentClient(path=...) to save to disk
collection = client.create_collection(
"nimbus", metadata={"hnsw:space": "cosine"} # choose the distance metric
)
# Embed and add. (↔ your chunk_vectors = embedder.encode(...))
collection.add(
ids=[f"doc{i}" for i in range(len(DOCS))],
embeddings=embedder.encode(DOCS).tolist(),
documents=DOCS,
metadatas=metadatas,
)
# Query. (↔ your retrieve(): embed query, find nearest)
q = "How much does the paid plan cost?"
res = collection.query(query_embeddings=embedder.encode([q]).tolist(), n_results=2)
for doc, dist in zip(res["documents"][0], res["distances"][0]):
print(f"[{dist:.3f}] {doc}")
Si lo relacionamos con la versión hecha a mano, no hay nada conceptualmente nuevo. collection.add reemplaza el proceso de codificación de los documentos y el almacenamiento en la matriz. collection.query sustituye al producto de matrices, a argsort y al corte de los k mejores resultados. Lo que aporta Chroma es el índice HNSW, la persistencia y el soporte para metadatos basados en esa misma idea.
También es posible que Chroma incorpore texto por usted mediante un modelo integrado si se pasa documents= sin especificar embeddings=. Al definir los embeddings de forma explícita, como se mencionó anteriormente, se mantiene visible el funcionamiento interno y la elección del modelo queda en sus manos, lo cual es importante porque se debe utilizar el mismo modelo tanto para agregar como para consultar datos.
Filtrado por metadatos
El filtrado es una función que un array simple nunca tuvo. La siguiente consulta busca “seguridad de datos”, pero restringe los resultados a aquellos vectores cuyo topic es security mediante el argumento where:
res = collection.query(
query_embeddings=embedder.encode(["data security"]).tolist(),
n_results=3,
where={"topic": "security"}, # only search vectors tagged security
)
print(res["documents"][0])
En entornos de producción, esto es lo que permite solicitudes como “tickets similares de este cliente” o “documentación actualizada este año”. El filtro se aplica junto con la búsqueda por vectores en lugar de como un paso de procesamiento posterior, por lo que aún se obtienen hasta n_results resultados que cumplen con él. Como ejercicio, cambie el filtro a where={"topic": "pricing"} y confirme que los documentos relacionados con reembolsos y seguridad nunca aparezcan.
FAISS: una biblioteca de índices rápida, no una base de datos
FAISS, de Meta, no es en absoluto una base de datos. Se trata de una biblioteca altamente optimizada para buscar vecinos más cercanos, sin metadatos ni capa de almacenamiento propia, diseñada para integrarse en su aplicación. Muchos otros sistemas la utilizan internamente. Instale la versión para CPU:
pip install faiss-cpu
El código codifica los documentos con normalize_embeddings=True y los convierte a float32, que es lo que requiere FAISS. Se crea un IndexFlatIP, un índice de producto interno; como los vectores están normalizados a una longitud unitaria, el producto interno equivale a la similitud coseno. Después de agregar los vectores, se codifica la consulta de la misma manera y se llama a search para obtener los dos resultados principales, luego se mapean las posiciones devueltas a los documentos originales.
import faiss, numpy as np
vectors = embedder.encode(DOCS, normalize_embeddings=True).astype("float32")
index = faiss.IndexFlatIP(vectors.shape[1]) # IP = inner product = cosine on normalized vectors
index.add(vectors) # ↔ exactly your numpy array, but optimized in C++
q = embedder.encode(["refund window?"], normalize_embeddings=True).astype("float32")
scores, idx = index.search(q, k=2) # ↔ your argsort top-k
for s, i in zip(scores[0], idx[0]):
print(f"[{s:.3f}] {DOCS[i]}")
Esto es prácticamente el buscador de NumPy: IndexFlatIP es una búsqueda por coseno por fuerza bruta, implementada simplemente en C++ optimizado. La ventaja se obtiene al reemplazarlo por un índice aproximado como IndexHNSWFlat o IndexIVFFlat, que mantienen la misma interfaz pero realizan búsquedas en tiempo sublineal. Elige FAISS cuando quieras la máxima velocidad dentro de un único proceso y estés dispuesto a gestionar tú mismo la persistencia y los metadatos, por ejemplo manteniendo un mapeo separado de las posiciones a los registros de documentos.
Pinecone: totalmente gestionado y sin servidores
Hasta ahora todo funcionaba en tu propio proceso. Pinecone es una base de datos vectorial gestionada a la que se puede acceder por red: no hay servidor que ejecutar, y escala automáticamente. Es la opción para equipos que no desean operar infraestructura.
Tenga en cuenta que el SDK de Pinecone ha cambiado sustancialmente. El estilo antiguo pinecone.init(api_key=, environment=) ya no existe; ahora debe crear un objeto de cliente Pinecone, y el paquete se denomina pinecone, no pinecone-client. Revise la documentación actual del SDK si algo de lo indicado anteriormente falla.
El bloque comienza con la orden de instalación y luego con el código en Python (sepárelos si lo copia). Se crea un cliente, se genera un índice sin servidor llamado nimbus solo si aún no existe, con dimension=384 para compatibilidad con MiniLM y metric="cosine", y luego se establece conexión a él. Se insertan o actualizan las tuplas (id, vector, metadata), almacenando el texto original en los metadatos ya que Pinecone solo devuelve lo que se ha guardado. Finalmente, se realizan búsquedas de los dos mejores resultados restringidos por un filtro $eq al estilo Mongo en la columna topic, y se imprimen cada puntuación y el texto correspondiente. En el código real, se debe leer la clave desde una variable de entorno o un almacén de secretos en lugar de incluirla directamente en el código fuente.
pip install pinecone
from pinecone import Pinecone, ServerlessSpec
pc = Pinecone(api_key="YOUR_API_KEY") # get a free key at app.pinecone.io
# Create a serverless index (only once). dimension must match your embedder: MiniLM = 384.
if not pc.has_index("nimbus"):
pc.create_index(
name="nimbus",
dimension=384,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
)
index = pc.Index("nimbus")
# Upsert: (id, vector, metadata) tuples
index.upsert(vectors=[
(f"doc{i}", embedder.encode(d).tolist(), {"text": d, "topic": m["topic"]})
for i, (d, m) in enumerate(zip(DOCS, metadatas))
])
# Query with metadata filtering
res = index.query(
vector=embedder.encode("how much is pro?").tolist(),
top_k=2,
include_metadata=True,
filter={"topic": {"$eq": "pricing"}}, # Mongo-style filter operators
)
for match in res["matches"]:
print(f"[{match['score']:.3f}] {match['metadata']['text']}")
La estructura coincide exactamente con Chroma: se crea un índice, se insertan o actualizan IDs, vectores y metadatos, y luego se realiza una consulta de los top-k con un filtro opcional. El paso de recuperación es el mismo que antes, pero ahora se ejecuta en la infraestructura de escalado automático de otra persona a través de una API. Pinecone también admite namespaces, que dividen un único índice, y integrated inference, donde upsert_records y search_records incrustan el texto por usted, de modo que no necesita ejecutar un proceso de incrustación por su cuenta.
La lección general es que pasar de Chroma a Pinecone solo cambió la forma en que se conecta y se llama al almacenamiento, no la lógica RAG subyacente. En una aplicación bien estructurada, el almacenamiento de vectores es un componente intercambiable, y esa es también la razón por la cual un método de framework como .as_retriever() de LangChain se ve idéntico independientemente del backend.
Elegir un backend según sus limitaciones
Ninguna base de datos vectorial es la mejor en general; una es la más adecuada para un conjunto específico de limitaciones. A modo aproximado:
- Array NumPy (construido a mano): funciona dentro del proceso; adecuado para menos de unos 10 mil vectores, para fines educativos o cuando se desea un control total.
- Chroma: base de datos incrustada, que funciona dentro del proceso o como servidor local; ideal para prototipos y aplicaciones de tamaño pequeño a mediano, siendo la opción más sencilla para comenzar.
- FAISS: biblioteca de índices, que funciona dentro del proceso; ofrece la máxima velocidad cuando se gestiona el almacenamiento y los metadatos por uno mismo.
- pgvector: una extensión de Postgres dentro de su base de datos existente; ideal cuando ya se utiliza Postgres y se desean vectores junto con datos relacionales.
- Qdrant: base de datos que se puede alojar localmente con Docker o en la nube; ofrece un filtrado potente, rendimiento gracias a Rust, es de código abierto y permite búsquedas híbridas.
La decisión que se repite una y otra vez es servicios gestionados versus autohospedado. Los servicios gestionados, como Pinecone o las versiones en la nube de los demás, eliminan el trabajo operativo pero conllevan costos continuos y almacenan tus datos en los servidores del proveedor. Autohospedar Chroma, Qdrant, Weaviate, Milvus o pgvector significa ejecutarlos tú mismo mientras mantienes el control sobre los costos y la residencia de los datos. Una regla práctica comúnmente citada es que el autohospedado comienza a ser rentable cuando la factura mensual de los servicios gestionados supera los cientos de dólares; por debajo de ese umbral, las horas de trabajo técnico invertidas en operaciones suelen costar más de lo que ahorran. Considéralo como una heurística, no como una ley, ya que la capacidad operativa de tu equipo es tan importante como la factura.
Para el aprendizaje y la mayoría de los proyectos paralelos, Chroma o pgvector son suficientes. Pinecone o Qdrant Cloud resultan adecuados cuando la infraestructura es algo que su equipo prefiere no gestionar, y Milvus es la opción adecuada cuando realmente trabaja con miles de millones de vectores. Si está considerando si realmente necesita un almacén de vectores dedicado, consulte por qué el RAG en producción está yendo más allá de las bases de datos de vectores dedicadas.
Errores que causan problemas una vez que la base de datos está en funcionamiento
- Incongruencia de dimensiones es el error más común. La
dimensiondel índice debe ser igual al tamaño de salida del modelo de incrustación: 384 para MiniLM y 1536 para muchos modelos de incrustación de OpenAI. Mezclar modelos en un mismo índice conlleva errores inevitables.
ef_search para HNSW o nprobe para IVF, que aumenta la capacidad de recuperación a costa de la latencia. Para conocer más sobre estos parámetros, consulte ajustar índices HNSW para RAG en producción.PersistentClient de Chroma o una base de datos respaldada por servidor.Un buen ejercicio es tomar un script RAG existente basado en NumPy y reemplazar solo su función de recuperación por la colección Chroma mostrada anteriormente. La parte de generación no cambia en absoluto, lo que demuestra que la recuperación es realmente una capa independiente y reemplazable.
Referencia rápida para solución de problemas
La dimensión no coincide: sus vectores no tienen el tamaño que espera el índice. Cree un nuevo índice con ladimensióncorrecta, o utilice el generador de vectores para el cual fue creado.- Los resultados de Chroma parecen aleatorios: es probable que las métricas estén mezcladas, o se hayan utilizado generadores de vectores diferentes para agregar y consultar datos. Ambos deben coincidir.
has_index o create_index en Pinecone: asegúrese de estar utilizando la versión actual del SDK (from pinecone import Pinecone) y no el estilo obsoleto pinecone.init.search de FAISS devuelve IDs -1: el índice contiene menos vectores que k. Añada más datos o reduzca el valor de k.Puntos clave
- Una base de datos vectorial es un sistema de recuperación creado manualmente que se vuelve rápido, persistente y filtrable. Agregar vectores implica codificación además de almacenamiento; las consultas se realizan mediante el método cosine top-k.
- El índice ANN es la única innovación real: al aceptar respuestas aproximadas, la búsqueda sigue siendo rápida a medida que aumentan los datos.
- El filtrado y la persistencia de metadatos son funcionalidades para producción que una simple matriz carece.
- Cuando la calidad de la recuperación es deficiente, primero decida si el problema radica en los embeddings (modelo o métrica incorrectos) o en el índice (conjunto de recuperación demasiado bajo). Las distancias devueltas suelen indicar cuál es el problema.
- Mantenga el almacenamiento detrás de una interfaz sencilla para que al pasar de Chroma a Pinecone, o a cualquier otro sistema, solo sea necesario modificar el código de conexión y las llamadas.