Inicio / Artículos

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.

Editor: REACTAPP.TOP11 min de lectura2493 palabras

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:

  1. 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.
  2. Persistencia. Los vectores se almacenan en disco o en un servidor gestionado y sobreviven a los reinicios.
  3. 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 sea doc12.

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.
  • Filtrado de metadatos: restringir una búsqueda mediante atributos almacenados.
  • Espacio de nombres o colección: una partición dentro de una base de datos para que los datos no relacionados nunca se mezclen.
  • 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.
  • Weaviate: base de datos, autohospedada o en la nube; módulos integrados, búsqueda híbrida y una API GraphQL.
  • Milvus: base de datos, autohospedada (más compleja de operar) o en la nube; diseñada para miles de millones de vectores.
  • Pinecone: solo versión gestionada, en su propia nube; sin operaciones necesarias y con escalado automático sin servidores.
  • 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 dimension del í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.
  • Desequilibrio métrico destruye silenciosamente la relevancia, por ejemplo al usar distancia euclidiana con un modelo diseñado para el coseno. Ajuste la métrica al modelo.
  • Lo aproximado no es exacto. Los índices ANN ocasionalmente omiten al verdadero vecino más cercano; ese es el precio de la velocidad. La mayoría de las bases de datos ofrecen un parámetro, como 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.
  • Volver a incrustar en cada reinicio ocurre cuando no se persiste ningún dato, por lo que debe pagar el costo de incrustación en cada ejecución. Utilice PersistentClient de Chroma o una base de datos respaldada por servidor.
  • Los filtros tienen su propio costo. El filtrado intensivo de metadatos puede ralentizar las consultas; indexe los campos en los que filtre si su base de datos lo permite.
  • 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 la dimensión correcta, 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.
  • Errores de 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.
  • El método search de FAISS devuelve IDs -1: el índice contiene menos vectores que k. Añada más datos o reduzca el valor de k.
  • Todo es lento a gran escala: lo más probable es que esté utilizando un índice plano y exacto. Cambie a HNSW o IVF e ajuste el parámetro de recuperación.
  • 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.