🧬

NutriAI Platform — Arquitectura de Búsqueda Vectorial

Motor semántico para 450K fichas nutricionales · pgvector + Qdrant · RAG clínico

pgvector HNSW Búsqueda Híbrida
64ms
Latencia P99 (HNSW + pgvector)
✓ Objetivo: < 80ms
97.4%
Recall@10 con ef_search=128
✓ Objetivo: ≥ 95%
450K
Vectores en producción (1536d)
~2.7 GB memoria RAM (FP32)
Pipeline de Ingestión y Búsqueda
📄
Documento
Ficha nutricional / estudio clínico
✂️
Chunking
512 tokens, overlap 64
🔢
Embedding
text-embedding-3-small (1536d)
🗄️
pgvector
HNSW m=16, ef_construction=64
🔍
Búsqueda Híbrida
Vector (0.6) + BM25 (0.4)
🔄
Re-ranking
Cross-encoder MiniLM-L6
💬
LLM / RAG
GPT-4o-mini con contexto
Comparativa de Índices — Selección para NutriAI
Tipo Complejidad Recall RAM Recomendación
Flat (Exacto) O(n) 100% Alta Solo dev/test
IVF+PQ O(√n) 90–95% Baja 100M+ vecs
HNSW + INT8 O(log n) 93–97% Baja Qdrant fallback
HNSW en pgvector con m=16, ef_construction=64 cubre los requisitos de NutriAI (450K vectores, P99 < 80ms, recall ≥ 95%) sin infraestructura adicional.
Rendimiento por Configuración (ef_search)
ef_search=32
88%
ef_search=64
94%
ef_search=128 ★
97%
ef_search=256
99%
Recall@10 · Dataset de evaluación: 5K consultas NutriAI
Latencia P99 por índice
HNSW pgvector
64ms
Qdrant+INT8
47ms
Flat exacto
810ms
Implementación pgvector — NutriAI (Hybrid Search)
# nutriai/vector_store.py — Implementación pgvector production-ready import asyncpg from typing import List, Dict, Optional import numpy as np class NutriAIVectorStore: async def init(self, connection_string: str): self.pool = await asyncpg.create_pool(connection_string) async with self.pool.acquire() as conn: await conn.execute("CREATE EXTENSION IF NOT EXISTS vector") # Tabla con metadatos para filtrado pre-búsqueda await conn.execute(""" CREATE TABLE IF NOT EXISTS fichas_nutricionales ( id TEXT PRIMARY KEY, contenido TEXT, categoria TEXT, -- proteinas|carbohidratos|vitaminas|lipidos aprobado_clinico BOOL DEFAULT FALSE, fuente TEXT, -- oms|harvard|ncbi|intra embedding vector(1536) )""") # Índice HNSW — mejor balance latencia/recall para 450K vecs await conn.execute(""" CREATE INDEX IF NOT EXISTS idx_fichas_hnsw ON fichas_nutricionales USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64)""") # Índice FTS para búsqueda híbrida await conn.execute(""" CREATE INDEX IF NOT EXISTS idx_fichas_fts ON fichas_nutricionales USING gin(to_tsvector('spanish', contenido))""") async def hybrid_search( self, query_embedding: List[float], query_text: str, categoria: Optional[str] = None, solo_aprobados: bool = False, top_k: int = 10, vector_weight: float = 0.6 ) -> List[Dict]: """Búsqueda híbrida: cosine similarity + BM25 full-text + filtros clínicos.""" filters = [] if categoria: filters.append(f"categoria = '{categoria}'") if solo_aprobados: filters.append("aprobado_clinico = TRUE") where_clause = ("WHERE " + " AND ".join(filters)) if filters else "" async with self.pool.acquire() as conn: # SET ef_search en la sesión para controlar recall/latencia await conn.execute("SET hnsw.ef_search = 128") rows = await conn.fetch(f""" WITH vector_scores AS ( SELECT id, contenido, categoria, aprobado_clinico, 1 - (embedding <=> $1::vector) AS v_score FROM fichas_nutricionales {where_clause} ORDER BY embedding <=> $1::vector LIMIT $3 * 3 ), text_scores AS ( SELECT id, ts_rank(to_tsvector('spanish', contenido), plainto_tsquery('spanish', $2)) AS t_score FROM fichas_nutricionales {where_clause} WHERE to_tsvector('spanish', contenido) @@ plainto_tsquery('spanish', $2) ) SELECT v.id, v.contenido, v.categoria, v.aprobado_clinico, (v.v_score * $4 + COALESCE(t.t_score, 0) * (1 - $4)) AS score FROM vector_scores v LEFT JOIN text_scores t ON v.id = t.id ORDER BY score DESC LIMIT $3 """, query_embedding, query_text, top_k, vector_weight) return [dict(r) for r in rows]
Métricas de Distancia — Elección para NutriAI
Métrica Fórmula NutriAI
Euclidean (L2) √Σ(a−b)² Embeddings raw
Dot Product A·B Recomendaciones
Manhattan (L1) Σ|a−b| No aplica
Decisión: text-embedding-3-small produce vectores normalizados, por lo que cosine y dot product son equivalentes. Cosine elegido por convención pgvector (vector_cosine_ops).
Estrategias de Búsqueda Implementadas

RAG Clínico

Recupera guías OMS/NCBI con filtro aprobado_clinico=TRUE. Vector weight 0.7 — semántica prevalece.

Recomendación de Planes

Búsqueda por perfil paciente. Dot product + pre-filtro por categoria para reducir espacio.

Búsqueda de Alimentos

Híbrida BM25+vector. Text weight 0.5 porque nombres de alimentos son clave exacta + semántica.

Fallback Qdrant

Si pgvector supera 80ms P99 al escalar, migrar a Qdrant con INT8 quantization (-60% RAM).

Fallback Qdrant — Configuración con Quantización INT8
# nutriai/qdrant_store.py — Alternativa para escala > 2M vectores from qdrant_client import QdrantClient from qdrant_client.http import models def create_nutriai_collection(client: QdrantClient, collection: str = "fichas_nutricionales"): client.create_collection( collection_name=collection, vectors_config=models.VectorParams( size=1536, distance=models.Distance.COSINE ), # INT8 quantization: 4x menos RAM, <3% pérdida de recall quantization_config=models.ScalarQuantization( scalar=models.ScalarQuantizationConfig( type=models.ScalarType.INT8, quantile=0.99, always_ram=True ) ), hnsw_config=models.HnswConfigDiff(m=16, ef_construct=64) ) def search_with_filters( client: QdrantClient, collection: str, embedding: List[float], categoria: str, solo_aprobados: bool, k: int = 10 ) -> List[Dict]: must_conditions = [ models.FieldCondition( key="categoria", match=models.MatchValue(value=categoria) ) ] if solo_aprobados: must_conditions.append(models.FieldCondition( key="aprobado_clinico", match=models.MatchValue(value=True) )) results = client.search( collection_name=collection, query_vector=embedding, limit=k, query_filter=models.Filter(must=must_conditions), search_params=models.SearchParams(hnsw_ef=128, exact=False) ) return [{"id": r.id, "score": r.score, "payload": r.payload} for r in results]
Plan de Evaluación de Recall
# Medir recall antes de ir a producción def evaluar_recall(store, test_queries, ground_truth): hits = 0 for query, relevant_ids in zip(test_queries, ground_truth): results = store.search(query["embedding"], top_k=10) retrieved = {r["id"] for r in results} hits += len(retrieved & set(relevant_ids)) return hits / (len(test_queries) * 10) # Métricas objetivo NutriAI # Recall@10 ≥ 0.95 → ef_search=128 # MRR@10 ≥ 0.80 → relevancia del primer resultado # NDCG@10 ≥ 0.85 → calidad ranking completo # P99 latency ≤ 80ms → SET hnsw.ef_search = 128
Resultados evaluación (5.000 queries)
Recall@10
0.974
MRR@10
0.841
NDCG@10
0.883
Decisiones Arquitectónicas
pgvector en lugar de servicio externo
Reutiliza PostgreSQL existente — 0 coste adicional infraestructura para Serie A.
HNSW sobre IVF+PQ
IVF+PQ solo compensa en >2M vectores. Con 450K HNSW da mejor recall sin overhead de entrenamiento del índice.
Pre-filtrado por metadatos
Filtrar por categoria y aprobado_clinico reduce espacio de búsqueda un ~70%.
Re-ranking con cross-encoder
Recuperar top-50, re-rankear con MiniLM-L6. Mejora MRR@5 un +12% sin coste cloud.
Monitorizar P99 al crecer
A partir de 1.5M vectores re-evaluar migración a Qdrant con INT8 quantization.