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
- Postgres 16 (ya lo tenía corriendo)
- Extensión
pg_trgm(built-in en la mayoría de distros) - Full-Text Search nativo con configuración
'spanish'
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':
- Aplica stemming: “proyectos” → “proyect”, “contactarlo” → “contact”, “construyo” → “construy”
- Quita stop words automáticamente (que, ha, de, la, etc.)
- Normaliza acentos
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:
- FTS con OR + stemming (el primario)
- Trigram fuzzy (
word_similaritycon threshold 0.2) — captura typos - 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)
| Query | Resultado top |
|---|---|
precios | Precios y planes ✓ |
experiencia con Docker | Skills técnicos y stack ✓ |
disponibilidad freelance | Disponibilidad y cómo contratarlo ✓ |
como contactarlo | Contacto y enlaces ✓ |
que proyectos ha hecho | Proyecto: Security Dashboard ✓ |
sabe de ciberseguridad | Educación y formación ✓ |
donde estudia | Quién es Lalu (menciona UNAM) ✓ |
hace dashboards? | Servicios freelance ✓ |
Cuándo SÍ querrías embeddings reales
- Catálogos grandes (>10k documentos)
- Búsqueda multi-idioma con queries mezcladas
- Similitud semántica fina (“¿qué piensa este autor sobre X?”)
- RAG sobre PDFs/papers académicos largos
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.