# Vector Stores — Referencia completa

Guía de selección, configuración e integración de vector stores para aplicaciones RAG.

---

## Comparativa de vector stores

| Vector Store | Despliegue | Escala | Búsqueda híbrida | Ideal para |
|-------------|-----------|--------|-----------------|-----------|
| pgvector | On-premise / Cloud | < 10M vectores | Manual (RRF) | Ya tienes PostgreSQL |
| Chroma | Local / Self-hosted | < 1M vectores | No | Prototipado rápido |
| Pinecone | Serverless | Sin límite práctico | Sí (nativo) | Producción sin DevOps |
| Weaviate | Self-hosted / Cloud | Millones | Sí (BM25 + vector) | Búsqueda semántica avanzada |
| Qdrant | Self-hosted / Cloud | Millones | Sí | Alta performance, tipado fuerte |

---

## pgvector con SQLAlchemy

### Instalación y configuración

```bash
# Extensión PostgreSQL
CREATE EXTENSION IF NOT EXISTS vector;

# Dependencias Python
pip install pgvector psycopg2-binary langchain-community
```

### Modelo SQLAlchemy con columna vector

```python
from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, String, Text, Integer
from sqlalchemy.orm import DeclarativeBase

class Base(DeclarativeBase):
    pass

class DocumentoChunk(Base):
    __tablename__ = "documento_chunks"

    id = Column(Integer, primary_key=True, autoincrement=True)
    contenido = Column(Text, nullable=False)
    fuente = Column(String(500))
    pagina = Column(Integer)
    # 1536 dimensiones para text-embedding-3-small de OpenAI
    # 768 para all-MiniLM-L6-v2 de HuggingFace
    embedding = Column(Vector(1536))
```

### Índice HNSW — obligatorio para producción

```sql
-- Crear índice HNSW DESPUÉS de cargar los datos iniciales
-- m=16, ef_construction=64 son valores seguros para empezar
CREATE INDEX idx_chunks_embedding_hnsw
ON documento_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- Para distancia euclidiana
CREATE INDEX idx_chunks_embedding_euclidean
ON documento_chunks
USING hnsw (embedding vector_l2_ops)
WITH (m = 16, ef_construction = 64);
```

**Por qué HNSW y no IVFFlat**: HNSW tiene mejor performance en queries individuales
y no requiere pre-entrenamiento con `lists`. IVFFlat es mejor si el dataset es enorme
(> 10M) y hay tiempo para el clustering inicial.

### Consultas de similitud con LangChain

```python
from langchain_community.vectorstores import PGVector
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# Crear o conectar a store existente
vectorstore = PGVector(
    embeddings=embeddings,
    collection_name="documentos_empresa",
    connection=DATABASE_URL,  # postgresql+psycopg2://...
)

# Búsqueda semántica simple
docs = vectorstore.similarity_search("política de vacaciones", k=4)

# Búsqueda con score de relevancia (1.0 = idéntico, 0.0 = sin relación)
docs_con_score = vectorstore.similarity_search_with_score("política de vacaciones", k=4)
for doc, score in docs_con_score:
    print(f"Score: {score:.3f} | {doc.page_content[:100]}")

# Búsqueda con filtro de metadata
docs_filtrados = vectorstore.similarity_search(
    "instrucciones de seguridad",
    k=4,
    filter={"fuente": "manual_seguridad_2024.pdf"},
)
```

### Búsqueda híbrida manual con pgvector

```sql
-- Combinar búsqueda vectorial + full-text search con Reciprocal Rank Fusion (RRF)
WITH vectorial AS (
    SELECT id, contenido, fuente,
           1 - (embedding <=> $1::vector) AS score_vector,
           ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS rank_vector
    FROM documento_chunks
    ORDER BY embedding <=> $1::vector
    LIMIT 20
),
textual AS (
    SELECT id, contenido, fuente,
           ts_rank(to_tsvector('spanish', contenido), plainto_tsquery('spanish', $2)) AS score_texto,
           ROW_NUMBER() OVER (ORDER BY ts_rank(to_tsvector('spanish', contenido), plainto_tsquery('spanish', $2)) DESC) AS rank_texto
    FROM documento_chunks
    WHERE to_tsvector('spanish', contenido) @@ plainto_tsquery('spanish', $2)
    LIMIT 20
)
SELECT
    COALESCE(v.id, t.id) AS id,
    COALESCE(v.contenido, t.contenido) AS contenido,
    -- RRF score: 1/(k + rank) donde k=60 es estándar
    COALESCE(1.0 / (60 + v.rank_vector), 0) + COALESCE(1.0 / (60 + t.rank_texto), 0) AS score_rrf
FROM vectorial v
FULL OUTER JOIN textual t ON v.id = t.id
ORDER BY score_rrf DESC
LIMIT 5;
```

---

## Chroma — uso local

```python
import chromadb
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

# Persistente en disco (desarrollo)
cliente = chromadb.PersistentClient(path="./chroma_db")

vectorstore = Chroma(
    client=cliente,
    collection_name="documentos",
    embedding_function=OpenAIEmbeddings(),
)

# Agregar documentos
vectorstore.add_documents(chunks)

# Buscar
docs = vectorstore.similarity_search("consulta", k=4)
```

**Limitaciones de Chroma**: no tiene transacciones ACID, rendimiento degrada con
colecciones > 500k vectores, no tiene búsqueda híbrida nativa. Usar solo en desarrollo.

---

## Pinecone — serverless

```python
from pinecone import Pinecone
from langchain_pinecone import PineconeVectorStore
from langchain_openai import OpenAIEmbeddings

# Inicializar cliente
pc = Pinecone(api_key=settings.PINECONE_API_KEY)

# Crear índice si no existe
if "documentos" not in pc.list_indexes().names():
    pc.create_index(
        name="documentos",
        dimension=1536,         # debe coincidir con el modelo de embedding
        metric="cosine",
        spec=ServerlessSpec(cloud="aws", region="us-east-1"),
    )

indice = pc.Index("documentos")
vectorstore = PineconeVectorStore(index=indice, embedding=OpenAIEmbeddings())

# Upsert con metadata
vectorstore.add_documents(chunks)

# Búsqueda con filtro de namespace
docs = vectorstore.similarity_search(
    "consulta",
    k=4,
    namespace="empresa-x",  # para multi-tenancy
)
```

---

## Weaviate — búsqueda híbrida

```python
import weaviate
from langchain_weaviate.vectorstores import WeaviateVectorStore

cliente_weaviate = weaviate.connect_to_local()  # o connect_to_weaviate_cloud(...)

vectorstore = WeaviateVectorStore(
    client=cliente_weaviate,
    index_name="Documentos",
    text_key="contenido",
    embedding=OpenAIEmbeddings(),
)

# Búsqueda híbrida (combina BM25 + vector automáticamente)
docs = vectorstore.similarity_search(
    "consulta sobre vacaciones",
    k=4,
    search_type="hybrid",
    alpha=0.5,  # 0 = solo BM25, 1 = solo vector, 0.5 = equilibrado
)
```

---

## Estrategia de actualización del vector store

### Re-embedding completo — cuándo hacerlo

- Cambio de modelo de embedding (dimensiones o distribución diferentes).
- Cambio radical en la base de documentos (> 50% de contenido nuevo).
- Degradación de métricas RAGAS detectada en evaluación periódica.

### Actualización incremental — el caso más común

```python
import hashlib

def hash_documento(contenido: str) -> str:
    """Fingerprint del contenido para detectar cambios."""
    return hashlib.sha256(contenido.encode()).hexdigest()

async def sincronizar_documento(doc_id: str, contenido: str, vectorstore: PGVector):
    """Solo re-embebede si el contenido cambió."""
    hash_nuevo = hash_documento(contenido)

    # Verificar hash almacenado en metadata
    docs_existentes = vectorstore.similarity_search(
        "", k=1, filter={"doc_id": doc_id}
    )
    if docs_existentes and docs_existentes[0].metadata.get("hash") == hash_nuevo:
        return  # Sin cambios — omitir re-embedding

    # Eliminar chunks anteriores del documento
    vectorstore.delete(ids=obtener_ids_por_doc(doc_id))

    # Re-embebeder y guardar
    chunks_nuevos = splitter.split_text(contenido)
    vectorstore.add_texts(
        texts=chunks_nuevos,
        metadatas=[{"doc_id": doc_id, "hash": hash_nuevo}] * len(chunks_nuevos),
    )
```

---

## Costos de embeddings — referencia de precios

| Modelo | Proveedor | Costo / 1M tokens | Dimensiones | Notas |
|--------|-----------|-------------------|-------------|-------|
| text-embedding-3-small | OpenAI | $0.020 | 1536 | Mejor relación costo/calidad |
| text-embedding-3-large | OpenAI | $0.130 | 3072 | Mayor precisión |
| text-embedding-ada-002 | OpenAI | $0.100 | 1536 | Obsoleto — migrar a v3 |
| voyage-3 | Anthropic | $0.060 | 1024 | Óptimo con Claude |
| all-MiniLM-L6-v2 | HuggingFace | Gratis (local) | 384 | Sin costo de API |
| nomic-embed-text | Ollama | Gratis (local) | 768 | 100% on-premise |

```python
def estimar_costo_corpus(rutas_pdf: list[str], modelo: str = "text-embedding-3-small") -> dict:
    """Estima el costo antes de embeber un corpus completo."""
    import tiktoken
    from langchain_community.document_loaders import PyPDFLoader

    enc = tiktoken.get_encoding("cl100k_base")
    total_tokens = 0

    for ruta in rutas_pdf:
        loader = PyPDFLoader(ruta)
        docs = loader.load()
        for doc in docs:
            total_tokens += len(enc.encode(doc.page_content))

    precios = {
        "text-embedding-3-small": 0.020,
        "text-embedding-3-large": 0.130,
        "voyage-3": 0.060,
    }
    costo_usd = total_tokens / 1_000_000 * precios.get(modelo, 0.020)

    return {
        "total_tokens": total_tokens,
        "modelo": modelo,
        "costo_estimado_usd": round(costo_usd, 4),
    }
```
