⚖️
LegalMind AI
Plataforma RAG Jurídica · Implementación Búsqueda Híbrida

Búsqueda Híbrida con RRF Fusion

Vectorial (pgvector) + BM25 (ts_vector) + Cross-Encoder Reranking · PostgreSQL 16
⚡ pgvector 0.7 ✓ Recall@5: 61% → 87% ⏱ p95 latencia: 94ms 🔬 280k docs legales
Recall@5 anterior
61%
Solo vectorial
Recall@5 híbrido
82%
RRF sin rerank
Recall@5 + rerank
87%
Híbrido + cross-encoder
Latencia p95
94ms
Sin rerank (req. <120ms)
Latencia p95 + rerank
210ms
Con cross-encoder
Docs indexados
280k
Contratos + jurisprudencia
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 LegalMind

Linear Combination

score = α·v_norm + (1-α)·k_norm. Más ajustable pero sensible a la escala de scores. Requiere tuning por dominio.

Alternativa afinada

Cross-Encoder Reranking

Modelo neural evalúa cada par (query, doc) individualmente. Máxima calidad, +26 latencia ms. Activar para queries complejas.

Modo premium

Cascade / Two-Stage

Prefiltra con BM25 para reducir candidatos, luego vectorial en el subconjunto. Eficiente en colecciones >1M docs.

Para escalar >1M
Benchmark comparativo · 500 queries legales de evaluación
Método Recall@5 MRR@10 Precisión@5 Latencia p50 Latencia p95 Notas
Vector puro
61%
0.54 58% 31ms 58ms Falla en refs exactas (art., STS...)
BM25 puro
54%
0.48 51% 8ms 22ms Excelente en refs; falla en sem.
Híbrido RRF
82%
0.74 79% 44ms 94ms α=0.6 vector, k=60. ✓ bajo SLA
Híbrido + Rerank ★
87%
0.82 85% 120ms 210ms Máx. calidad. Activar para docs críticos.
Híbrido Linear α=0.7
79%
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
89%
BM25 recupera art. 1902
94%
↑ rerank consolida
"cláusula suelo hipoteca consumidores abusiva"
71%
semántica buena
83%
mejora leve
88%
rerank ordena
"STS 23 abril 2021 despido improcedente indemnización"
38%
fecha no indexada
81%
FTS recupera fecha
87%
mejor contexto
"GDPR art. 17 derecho supresión datos personales"
44%
sigla GDPR perd.
86%
sigla + sem.
91%
top resultado
"plazo prescripción acción reclamación cantidad"
68%
query semántica
79%
cobertura mayor
85%
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.