RAG sin pagar embeddings: Postgres FTS en español

Cómo monté un sistema de retrieval-augmented generation usando solo Postgres con full-text search en español. Cero llamadas a Voyage/Cohere, cero costo recurrente, y los resultados son sorprendentemente buenos.


La narrativa estándar de RAG dice: divides tus documentos en chunks, llamas a una API de embeddings (Voyage, Cohere, OpenAI), guardas los vectores en pgvector o Pinecone, y luego haces búsqueda semántica con coseno.

Funciona muy bien. Pero cuesta dinero y agrega una dependencia más a tu sistema. Para mi oficina virtual necesitaba que un agente respondiera preguntas sobre mí — “¿qué experiencia tiene Lalu con Docker?”, “¿cuánto cobra?”, “¿está disponible?”. ~10 documentos curados, consultas en español, frecuencia moderada.

Pagar embeddings para eso es matar moscas con cañón. Aquí está cómo lo resolví con solo Postgres.

El stack

Nada más. Cero APIs externas, cero ms de latencia de red.

El esquema

CREATE TABLE portfolio_kb (
  id SERIAL PRIMARY KEY,
  section TEXT NOT NULL,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_portfolio_kb_trgm
  ON portfolio_kb USING gin ((title || ' ' || content) gin_trgm_ops);

El índice trigram es para el fallback fuzzy (typos). El FTS no necesita índice dedicado para 10 documentos.

La query — el truco está aquí

La primera versión usaba plainto_tsquery('spanish', $1) y fallaba en queries con stop words:

"que proyectos ha hecho" → "proyect & hech" (AND)
                        → ningún doc tiene "hecho" → 0 resultados

plainto_tsquery usa AND entre términos. Si la query tiene una palabra que no está en ningún documento, no matchea nada.

La solución: construir la tsquery con OR y dejar que ts_rank ordene:

import re

words = [w for w in re.findall(r"\w+", q.lower()) if len(w) > 2]
tsq_or = " | ".join(words)  # "proyectos | hecho"
SELECT section, title, content,
       ts_rank(to_tsvector('spanish', title || ' ' || content),
               to_tsquery('spanish', $1)) AS score
FROM portfolio_kb
WHERE to_tsvector('spanish', title || ' ' || content)
      @@ to_tsquery('spanish', $1)
ORDER BY score DESC
LIMIT 4

Postgres con 'spanish':

Resultado: la query “que proyectos ha hecho” matchea el documento con título “Proyecto: Security Dashboard” perfectamente.

La validación injection-safe

Construir tsquery con input crudo es riesgoso (sintaxis de tsquery permite &, |, !, <->, paréntesis). La defensa: regex \w+ en Python antes de construir la query. Solo letras y números, nunca operadores.

words = re.findall(r"\w+", q.lower())  # alphanumeric only

Después Postgres stemmer normaliza esas palabras como lexemas, no como operadores.

Fallback en capas

El sistema tiene tres niveles, en orden:

  1. FTS con OR + stemming (el primario)
  2. Trigram fuzzy (word_similarity con threshold 0.2) — captura typos
  3. Fallback fijo (identidad + skills) — si ambos fallan, el agente al menos tiene contexto base
if not rows:  # FTS sin resultados → fallback trigram
    rows = await conn.fetch(
        """SELECT ..., word_similarity($1, title || ' ' || content) AS score
             FROM portfolio_kb
            WHERE $1 <% (title || ' ' || content)
            ORDER BY score DESC LIMIT $2""",
        q, limit,
    )

if not rows:  # trigram tampoco → contexto base
    rows = await conn.fetch(
        "SELECT ... FROM portfolio_kb WHERE section IN ('identidad','skills')"
    )

Así el agente nunca llama el LLM sin nada útil. Y nunca alucina sobre mí.

Resultados de calidad (8/8 queries de prueba)

QueryResultado top
preciosPrecios y planes ✓
experiencia con DockerSkills técnicos y stack ✓
disponibilidad freelanceDisponibilidad y cómo contratarlo ✓
como contactarloContacto y enlaces ✓
que proyectos ha hechoProyecto: Security Dashboard ✓
sabe de ciberseguridadEducación y formación ✓
donde estudiaQuién es Lalu (menciona UNAM) ✓
hace dashboards?Servicios freelance ✓

Cuándo SÍ querrías embeddings reales

Para todo lo demás — FAQs, documentación interna, perfiles, knowledge bases curadas — Postgres FTS es suficiente y no tienes que mantener un servicio extra ni pagar por tokens de embeddings.

El código completo

Está en producción en mi oficina virtual. Los agentes Sales, Creative y General lo consultan vía la tool search_portfolio. Cuando alguien le pregunta al bot algo sobre mí, esta función responde con datos reales en lugar de alucinar.

Tiempo total que me tomó implementarlo: ~40 minutos. Costo recurrente: $0.


Si quieres ver el sistema en acción, abre oficina.lalu.dev y observa al agente General responder preguntas sobre mí. Sus respuestas vienen de esta misma función.


← todos los posts