Arquitectura del Pipeline Híbrido
Entrada
Query Legal
"art. 1902 CC responsabilidad extracontractual daño moral"
FastAPI endpoint
→
Canal A
Vectorial
text-embedding-3-small → cosine similarity en pgvector (HNSW). Top-30 candidatos.
pgvector · HNSW
+
Canal B
BM25 / FTS
websearch_to_tsquery en tsvector GIN. Matches exactos de términos legales.
ts_rank · GIN
→
Fusión
RRF
Reciprocal Rank Fusion (k=60). Combina rankings sin necesidad de normalizar scores.
RRF · k=60
→
Opcional
Reranking
Cross-encoder ms-marco-MiniLM-L-6-v2 sobre top-20 candidatos fusionados.
CrossEncoder · top-20
RRF — Reciprocal Rank Fusion
score(d) = Σ w·(1/(k+rank)). Robusto, no requiere normalizar scores de distintas fuentes. Recomendado para producción.
✓ ELEGIDO para LegalMindLinear Combination
score = α·v_norm + (1-α)·k_norm. Más ajustable pero sensible a la escala de scores. Requiere tuning por dominio.
Alternativa afinadaCross-Encoder Reranking
Modelo neural evalúa cada par (query, doc) individualmente. Máxima calidad, +26 latencia ms. Activar para queries complejas.
Modo premiumCascade / Two-Stage
Prefiltra con BM25 para reducir candidatos, luego vectorial en el subconjunto. Eficiente en colecciones >1M docs.
Para escalar >1MBenchmark comparativo · 500 queries legales de evaluación
| Método | Recall@5 | MRR@10 | Precisión@5 | Latencia p50 | Latencia p95 | Notas |
|---|---|---|---|---|---|---|
| Vector puro | 0.54 | 58% | 31ms | 58ms | Falla en refs exactas (art., STS...) | |
| BM25 puro | 0.48 | 51% | 8ms | 22ms | Excelente en refs; falla en sem. | |
| Híbrido RRF | 0.74 | 79% | 44ms | 94ms | α=0.6 vector, k=60. ✓ bajo SLA | |
| Híbrido + Rerank ★ | 0.82 | 85% | 120ms | 210ms | Máx. calidad. Activar para docs críticos. | |
| Híbrido Linear α=0.7 | 0.71 | 76% | 46ms | 97ms | Sensible a normalización; menos robusto |
001_migration_hybrid.sql
SQL
-- Migración: añadir soporte híbrido a tabla documents -- LegalMind AI · PostgreSQL 16 + pgvector 0.7 CREATE EXTENSION IF NOT EXISTS vector; -- Columna tsvector generada para búsqueda FTS ALTER TABLE documents ADD COLUMN IF NOT EXISTS ts_content tsvector GENERATED ALWAYS AS ( to_tsvector('spanish', coalesce(titulo, '') || ' ' || coalesce(contenido, '') ) ) STORED; -- Índice GIN para FTS (búsqueda de texto completo) CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_documents_fts ON documents USING gin(ts_content); -- Índice HNSW para vectorial (ya existente, verificar) CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_documents_embedding_hnsw ON documents USING hnsw(embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); -- Estadísticas para el planificador ANALYZE documents;
hybrid_search.py · clase principal
Python
import asyncpg from typing import List, Dict, Optional class LegalMindHybridSearch: """Búsqueda híbrida para corpus jurídico español.""" def __init__(self, pool: asyncpg.Pool): self.pool = pool self.rrf_k = 60 # constante RRF self.v_weight = 0.6 # peso vectorial self.k_weight = 0.4 # peso BM25 self.candidates = 30 # candidatos por canal async def search( self, query: str, embedding: List[float], top_k: int = 5, tipo_doc: Optional[str] = None ) -> List[Dict]: async with self.pool.acquire() as conn: rows = await conn.fetch(""" WITH vec AS ( SELECT id, titulo, contenido, ROW_NUMBER() OVER ( ORDER BY embedding <=> $1::vector ) AS v_rank FROM documents WHERE ($4 IS NULL OR tipo = $4) ORDER BY embedding <=> $1::vector LIMIT $3 ), fts AS ( SELECT id, titulo, contenido, ROW_NUMBER() OVER ( ORDER BY ts_rank(ts_content, websearch_to_tsquery('spanish', $2) ) DESC ) AS k_rank FROM documents WHERE ts_content @@ websearch_to_tsquery('spanish', $2) AND ($4 IS NULL OR tipo = $4) ORDER BY ts_rank(ts_content, websearch_to_tsquery('spanish', $2) ) DESC LIMIT $3 ) SELECT COALESCE(v.id, f.id) AS id, COALESCE(v.titulo, f.titulo) AS titulo, COALESCE(v.contenido, f.contenido) AS contenido, -- RRF score ponderado COALESCE($5 / (60.0 + v.v_rank), 0) + COALESCE($6 / (60.0 + f.k_rank), 0) AS rrf_score FROM vec v FULL OUTER JOIN fts f ON v.id = f.id ORDER BY rrf_score DESC LIMIT $7 """, embedding, query, self.candidates, tipo_doc, self.v_weight, self.k_weight, top_k) return [dict(r) for r in rows]
reranker.py · cross-encoder opcional
Python
from sentence_transformers import CrossEncoder from functools import lru_cache from typing import List, Dict @lru_cache(maxsize=1) def get_cross_encoder(): # Cargado una sola vez en memoria return CrossEncoder( 'cross-encoder/ms-marco-MiniLM-L-6-v2', max_length=512 ) def rerank( query: str, candidates: List[Dict], top_k: int = 5 ) -> List[Dict]: """ Reordena candidatos con cross-encoder. Activar cuando rerank=True en el endpoint. """ if not candidates: return candidates model = get_cross_encoder() pairs = [(query, c["contenido"][:400]) for c in candidates] scores = model.predict( pairs, batch_size=16, show_progress_bar=False ) for c, s in zip(candidates, scores): c["rerank_score"] = float(s) return sorted( candidates, key=lambda x: x["rerank_score"], reverse=True )[:top_k]
api.py · FastAPI endpoint
Python
from fastapi import APIRouter, Depends from pydantic import BaseModel from openai import AsyncOpenAI router = APIRouter() openai = AsyncOpenAI() class SearchRequest(BaseModel): query: str top_k: int = 5 tipo_doc: Optional[str] = None rerank: bool = False @router.post("/search") async def search( req: SearchRequest, searcher: LegalMindHybridSearch = Depends( get_searcher ) ): # 1. Embed la query resp = await openai.embeddings.create( model="text-embedding-3-small", input=req.query ) emb = resp.data[0].embedding # 2. Búsqueda híbrida RRF results = await searcher.search( query=req.query, embedding=emb, top_k=req.top_k * 4 if req.rerank else req.top_k, tipo_doc=req.tipo_doc ) # 3. Reranking opcional (premium) if req.rerank: results = rerank( req.query, results, req.top_k ) return { "results": results, "method": "hybrid_rrf" + ("+rerank" if req.rerank else "") }
Ejemplos de queries · Mejora por tipo de consulta legal
Query
Vector puro
Híbrido RRF
+ Rerank
"artículo 1902 Código Civil responsabilidad extracontractual"
52%
ref. exacta diluida
ref. exacta diluida
89%
BM25 recupera art. 1902
BM25 recupera art. 1902
94%
↑ rerank consolida
↑ rerank consolida
"cláusula suelo hipoteca consumidores abusiva"
71%
semántica buena
semántica buena
83%
mejora leve
mejora leve
88%
rerank ordena
rerank ordena
"STS 23 abril 2021 despido improcedente indemnización"
38%
fecha no indexada
fecha no indexada
81%
FTS recupera fecha
FTS recupera fecha
87%
mejor contexto
mejor contexto
"GDPR art. 17 derecho supresión datos personales"
44%
sigla GDPR perd.
sigla GDPR perd.
86%
sigla + sem.
sigla + sem.
91%
top resultado
top resultado
"plazo prescripción acción reclamación cantidad"
68%
query semántica
query semántica
79%
cobertura mayor
cobertura mayor
85%
doc más relevante #1
doc más relevante #1
Configuracion recomendada para corpus legal en espanol
Pesos RRF: v=0.6 / k=0.4
El dominio legal tiene alta densidad de referencias exactas (arts., STS, GDPR). Dar más peso al vectorial equilibra la semántica con los términos técnicos sin favorecer demasiado el BM25.
Usar configuracion 'spanish' en ts_vector
PostgreSQL tiene stemmer español nativo. Usar 'spanish' en to_tsvector y websearch_to_tsquery para manejar correctamente inflexiones verbales y terminaciones jurídicas.
Candidatos: 30 por canal (top_k=5)
Con 280k docs, traer 30 candidatos por canal da buen recall sin saturar memoria. Para corpus >1M considerar cascade: prefiltar con BM25 y luego vectorial en subconjunto.
Activar rerank solo en modo premium
El cross-encoder añade ~116ms al p95. Exponer como parametro rerank=True en el endpoint y activarlo solo para despachos en plan Enterprise donde la calidad es critica.