
Vale, confesión. En el episodio 819 construimos un pipeline chulísimo para indexar nuestras notas en Markdown dentro de una base de datos SQLite con FTS5, generamos los embeddings de cada trozo y lo metimos todo en una base de datos vectorial. Lo probé, funcionaba, y durante una semana fui feliz. Pero llegó el momento de la verdad: le pregunté a mi sistema cómo se configura un proxy inverso con Caddy y el FTS5 me devolvió los trozos exactos donde aparecía la palabra Caddy y proxy. También me devolvió un montón de resultados donde salía Caddy pero hablando de otra cosa. Y ninguno donde se hablaba de servidor web sin mencionar la palabra mágica. Y entonces me di cuenta de algo que debería haber sabido desde el principio: el FTS5 es rapidísimo, pero es literal. No entiende sinónimos. No entiende contexto. No entiende que proxy inverso y reverse proxy son exactamente la misma mierda. 😅
Así que después de darle muchas vueltas, recapacité. Si tenemos una base de conocimiento con miles de documentos troceados en decenas de miles de fragmentos, y cada fragmento tiene su embedding semántico guardado, ¿por qué conformarme con buscar solo por palabras exactas? En este episodio te voy a enseñar tres técnicas que he implementado para exprimir al máximo mi cerebro digital: la búsqueda híbrida que combina lo mejor de FTS5 y los embeddings semánticos, el re-ranking con cross-encoder para afinar los resultados, y HyDE esa técnica que parece sacada de la ciencia ficción donde le pides a la inteligencia artificial que invente un documento para encontrar lo que buscas. Todo ello implementado en Python y en Rust, con scripts listos para usar desde la terminal, un plugin para Neovim y un adelanto de lo que vendrá en próximos episodios.
El problema de la búsqueda sintáctica
Si alguna vez has usado grep o ripgrep para buscar en tus notas, sabes exactamente de lo que hablo. Buscas factura y encuentras documentos que contienen la palabra factura. Hasta aquí todo bien. Pero ¿y si lo que necesitas son documentos que hablan de pagos, de transferencias, de recibos? En ninguno de ellos aparece la palabra factura, pero todos están relacionados. Ahí es donde la búsqueda sintáctica se queda corta.
El FTS5 que montamos en el episodio 819 usa el tokenizer porter unicode61 remove_diacritics 1. Esto significa que quita tildes, pasa a minúsculas y aplica stemming del inglés. Pero si en tu vault tienes instalar Docker en Debian y buscas cómo configuro contenedores en Debian, el FTS5 no te devuelve gran cosa porque no encuentra las palabras exactas. Es así de simple.
La búsqueda semántica con embeddings funciona al revés, convierte la consulta y los documentos en vectores de 1024 números (4096 bytes por cada chunk) y mide la distancia entre ellos. Si el significado es parecido, los vectores apuntan en direcciones parecidas. El problema es que la semántica sola falla con términos muy concretos: nombres propios, números de versión, rutas de fichero. Si buscas nginx.conf con búsqueda semántica, te va a devolver cualquier cosa que signifique algo parecido a configuración de servidores, pero no el archivo exacto que contiene la directiva proxy_pass.
La solución es usar los dos. Y llamarlo hybrid search para que quede bonito. 😊
Búsqueda híbrida: lo mejor de dos mundos
La idea es sencilla, realizar una doble búsqueda (una exacta con FTS5 y otra semántica con cosine similarity sobre los embeddings) y combinar los resultados con una ponderación. Así obtienes lo mejor de ambos mundos: la precisión de la búsqueda exacta para términos concretos y la flexibilidad de la búsqueda semántica para conceptos relacionados.
Lo primero que necesitamos es poder leer esos BLOBs de 4096 bytes que guardamos en la base de datos. Para eso usamos numpy.frombuffer, que interpreta los bytes directamente como floats de 32 bits. 4096 bytes dividido entre 4 bytes por float = 1024 floats. Fíjate en un detalle importante: la llamada a .copy() es crucial porque frombuffer te da una vista de solo lectura, y luego vas a querer normalizar el vector.
import sqlite3
import numpy as np
from numpy.linalg import norm
import math
DB_PATH = os.path.expanduser("~/.cerebro/rag_conocimiento.db")
def blob_to_vector(blob: bytes) -> np.ndarray:
"""Convierte BLOB de 4096 bytes (1024 f32) a numpy array"""
return np.frombuffer(blob, dtype=np.float32).copy()
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Producto escalar de vectores normalizados = cosine similarity"""
return float(np.dot(a, b))
Ahora, el hybrid search completo. La idea es: primero buscar con FTS5 y obtener un score BM25, luego calcular la cosine similarity entre el embedding de la consulta y el de cada chunk, y finalmente combinar los dos con una ponderación. Y aquí viene la clave: el parámetro alfa.
def hybrid_search(query: str, alpha: float = 0.4, top_k: int = 10):
"""
alpha = peso para FTS5, (1-alpha) para cosine similarity.
alpha ~0.4 suele funcionar bien. Más alpha = más peso a coincidencia exacta.
"""
conn = sqlite3.connect(DB_PATH)
query_emb = get_embedding_ollama(query) # llamada a Ollama
cursor = conn.execute("""
SELECT c.id, c.contenido, c.embedding,
bm25(chunks_fts, 0.0, 0.0, 5.0, 5.0) as fts_score
FROM chunks_fts
JOIN chunks c ON chunks_fts.rowid = c.id
WHERE chunks_fts MATCH ?
ORDER BY fts_score DESC
LIMIT ?
""", (query, top_k * 2))
results = []
for row in cursor:
chunk_id, contenido, emb_blob, fts_score = row
# Normalizar FTS score a [0,1] con sigmoide
fts_norm = 1.0 / (1.0 + math.exp(-fts_score / 10.0))
# Cosine similarity
emb_vec = blob_to_vector(emb_blob)
emb_vec = emb_vec / norm(emb_vec)
cos_sim = cosine_similarity(query_emb, emb_vec)
# Combinación ponderada
score_total = alpha * fts_norm + (1.0 - alpha) * cos_sim
results.append({"contenido": contenido, "score": score_total,
"fts_score": fts_norm, "cos_sim": cos_sim})
results.sort(key=lambda x: x["score"], reverse=True)
conn.close()
return results[:top_k]
Fíjate en una cosa importante: pedimos el doble de resultados al FTS5 de los que vamos a devolver (top_k * 2). Esto es necesario porque el reranking combinado puede cambiar el orden. Si pides 10 resultados finales, pide 20 al FTS5 para tener margen de maniobra. Y el sigmoide ese de 1.0 / (1.0 + exp(-score / 10.0)) —¿por qué existe? Porque el score de BM25 no está acotado. Puede valer 2, 15, 50… El sigmoide lo mapea al rango [0, 1] para poder combinarlo limpiamente con la cosine similarity.
Un error común que te puedes encontrar: si el BLOB no tiene exactamente 4096 bytes, np.frombuffer te va a dar un vector de tamaño incorrecto. La primera comprobación que debes hacer es SELECT length(embedding) FROM chunks LIMIT 1 en SQLite.
El parámetro alfa
El alfa define el equilibrio entre la búsqueda exacta y la semántica. Un valor de 0.4 significa que el resultado final es un 40% FTS5 y un 60% embedding semántico. Esto no es una ley grabada en piedra —cada cual tiene que ajustarlo a su caso. Pero aquí tienes una guía rápida:
| Escenario | Alfa recomendado |
|---|---|
| Nombres propios, rutas, términos técnicos exactos | 0.5–0.6 (más FTS) |
| Búsqueda conceptual amplia | 0.2–0.3 (más semántica) |
| Equilibrio general (punto de partida) | 0.4 |
Mi recomendación es que empieces con 0.4, lo pruebes con varias consultas, y lo ajustes según necesites. Es uno de esos parámetros que se entienden mejor probando que leyendo. Si buscas hooks de git y quieres encontrar exactamente eso, usa 0.6. Si buscas cómo gestionar versiones de software y quieres encontrar documentos que hablen de Git, tags, releases y versionado, usa 0.2.
CLI cerebro: tu conocimiento en la terminal
Tener el hybrid search funcionando está muy bien, pero ejecutar python3 -c "from cerebro import hybrid_search; print(hybrid_search('...'))" cada vez que quieras buscar no es precisamente productivo. Necesitamos una interfaz de línea de comandos que mole. Y como soy un vago, la he llamado cerebro. Porque literalmente es tu cerebro en la terminal.
El esqueleto es un script Python con argparse que expone dos subcomandos principales: query para buscar y list para explorar, más un modo interactivo. Puedes ver la implementación completa de los tres scripts Python en este gist con hybrid search, reranker y HyDE.
#!/usr/bin/env python3
"""
cerebro — CLI para consultar la base de conocimiento RAG.
"""
import argparse
import sys
import sqlite3
import numpy as np
import math
import os
DB_PATH = os.path.expanduser("~/.cerebro/rag_conocimiento.db")
def main():
parser = argparse.ArgumentParser(prog="cerebro",
description="Consulta tu base de conocimiento RAG",
epilog="""Ejemplos:
cerebro query "qu\u00e9 es un hook en git"
cerebro query --alpha 0.6 "instalar docker en debian"
cerebro --interactive
cerebro list tags""")
subparsers = parser.add_subparsers(dest="command")
query_parser = subparsers.add_parser("query", help="Buscar en la BD")
query_parser.add_argument("texto", nargs="?", help="Texto de la consulta")
query_parser.add_argument("-a", "--alpha", type=float, default=0.4)
query_parser.add_argument("-k", "--top-k", type=int, default=5)
query_parser.add_argument("--tag", help="Filtrar por tag")
query_parser.add_argument("--fzf", action="store_true",
help="Usar fzf para selecci\u00f3n interactiva")
list_parser = subparsers.add_parser("list", help="Listar docs/tags/stats")
list_parser.add_argument("tipo", choices=["docs", "tags", "stats"])
parser.add_argument("-i", "--interactive", action="store_true",
help="Modo interactivo")
args = parser.parse_args()
# ... l\u00f3gica seg\u00fan args
El modo interactivo es mi favorito porque te permite encadenar consultas. Arrancas con cerebro --interactive y entras en un bucle donde escribes consultas y te devuelve resultados pasados por fzf. Y si no conoces fzf… ¿en qué has estado todo este tiempo? Si no lo tienes instalado, sudo apt install fzf y te cambia la vida. Es de esas herramientas que una vez que pruebas, no entiendes cómo has podido vivir sin ella. Te permite filtrar resultados escribiendo, navegar con las flechas del teclado y ver una vista previa del contenido antes de seleccionar.
La integración con fzf es espectacular porque cuando tienes diez resultados, poder navegarlos con el teclado, ver el preview del contenido, y seleccionar el que te interesa es otra experiencia. No es lo mismo leer una lista numérica que interactuar con los resultados. Además, desde el modo interactivo puedes filtrar por tags directamente escribiendo tags:linux seguido de la consulta, y el script parsea ese prefijo antes de enviar la petición a la base de datos.
Luego tenemos los filtros. Porque si tu base de conocimiento tiene miles de documentos, buscar sin filtrar es como buscar una aguja en un pajar. El filtro por tag usa LIKE porque los tags están en una columna TEXT separada por comas. No es la solución más elegante del mundo, pero para empezar funciona de maravilla. Si la BD crece más allá de diez mil documentos, ya migraremos a una tabla normalizada de tags.
Y la instalación es trivial: chmod +x cerebro.py, ln -s cerebro.py ~/.local/bin/cerebro, y ya lo tienes en el PATH. O si eres más de Python, un pip install -e . con un setup.py mínimo. También he preparado scripts de autocompletado para bash y fish, así que cuando empieces a escribir cerebro q y pulses TAB, te va a sugerir los subcomandos disponibles. Ese tipo de detalles marcan la diferencia en el día a día.
cerebro query "hooks de git" --tag git
cerebro query "docker compose" --tag docker --fzf
cerebro list tags
cerebro list stats
Plugin de Neovim con sqlite.lua
¿No sería genial poder buscar en tu base de conocimiento sin salir del editor? Pues eso es exactamente lo que he hecho con sqlite.lua. Es una librería de kkharji —el mismo de impatient.nvim— que permite acceder a bases de datos SQLite directamente desde Lua, usando el binding nativo vía LuaJIT FFI.
La instalación es sencilla con lazy.nvim. Si make falla, te faltan los headers de sqlite3: sudo apt install libsqlite3-dev y listo.
{
"kkharji/sqlite.lua",
build = "make", -- compila el binding nativo
}
La API es sorprendentemente simple. No estás escribiendo SQL embebido en strings raros —bueno, sí, sigues escribiendo SQL, pero la integración es limpia. db:eval() te devuelve una tabla de Lua directamente. Cada fila es un diccionario con las columnas como claves. No hay que mapear nada, no hay ORM, no hay overhead.
local sqlite = require("sqlite")
local db = sqlite:open(vim.fn.expand("~/.cerebro/rag_conocimiento.db"))
local results = db:eval([[
SELECT c.id, c.contenido,
bm25(chunks_fts, 0, 0, 5, 5) as score
FROM chunks_fts
JOIN chunks c ON chunks_fts.rowid = c.id
WHERE chunks_fts MATCH ?
ORDER BY score DESC
LIMIT 10
]], { query_text })
db:close()
Y ahora, el plugin completo. Lo he estructurado como un módulo Lua que puedes poner en ~/.config/nvim/lua/plugins/cerebro.lua. La parte más interesante es la floating window. Porque ver los resultados en una ventana flotante, con bordes redondeados y título, es mucho más agradable que un split.
function M._show_float(results)
local width = math.floor(vim.o.columns * 0.8)
local height = math.floor(vim.o.lines * 0.6)
local buf = vim.api.nvim_create_buf(false, true)
local lines = {}
for i, r in ipairs(results) do
local content = r.contenido:gsub("\n", " "):sub(1, width - 10)
table.insert(lines, string.format("[%d] [%.3f] %s", i, r.score, content))
end
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
-- Ventana flotante con bordes redondeados
local win = vim.api.nvim_open_win(buf, true, {
relative = "editor",
width = width,
height = height,
col = math.floor((vim.o.columns - width) / 2),
row = math.floor((vim.o.lines - height) / 2),
style = "minimal",
border = "rounded",
title = " Resultados cerebro ",
title_pos = "center",
})
end
Luego he añadido keymaps para que sea rápido de usar: <leader>rq para hacer una consulta y <leader>rf para un selector interactivo con vim.ui.select. Y si usas Telescope, también he preparado una integración que te permite buscar en la base de conocimiento con el previewer nativo, mostrando el contenido completo de cada chunk en la ventana de vista previa. La configuración es mínima: registras una extensión que hace las consultas FTS5 directamente desde Lua y los resultados aparecen formateados con el sorter de Telescope. Es una pasada.
¿El resultado? Cuando estoy escribiendo código y necesito recordar cómo hice algo, pulso <leader>rq, escribo la consulta, y tengo la respuesta en una ventana flotante sin perder contexto. Es como tener un cheat sheet de tu propio conocimiento.
Rust CLI: cuando la velocidad importa
Vale, confesión número dos del episodio. Me encanta Python, de verdad. Pero para una CLI que vas a llamar decenas de veces al día, el startup time de Python pesa. Cada vez que ejecutas cerebro query "algo", Python tiene que cargar argparse, numpy, sqlite3, y todo el overhead. Hablamos de 300 a 500 milisegundos solo de arranque. En Rust, el mismo binario arranca en 2 milisegundos. Y no exagero.
He montado cerebro-cli con tres dependencias principales: rusqlite con la feature bundled para incluir SQLite compilado desde C dentro del binario, clap para el parseo de argumentos, y bytemuck para reinterpretar los BLOBs sin copiar memoria. El código completo del CLI en Rust lo tienes en este gist del cerebro-cli.
La feature bundled de rusqlite es clave: compila SQLite desde C dentro del binario. No necesitas tener SQLite instalado en el sistema. Y viene con FTS5 incluido. El binario resultante pesa unos 5 o 6 megas, pero es totalmente estático. Lo copias a cualquier máquina Linux y funciona.
Y aquí viene lo bonito: la cosine similarity en Rust puro, sin numpy, sin ndarray, sin drama.
use bytemuck::cast_slice;
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
dot / (norm_a * norm_b + 1e-10)
}
Fíjate en bytemuck::cast_slice. Hace lo mismo que numpy.frombuffer: reinterpreta un &[u8] como &[f32] sin copiar memoria. Zero-cost abstraction, que dicen los de Rust.
Para la lectura de BLOBs, usamos rusqlite directamente. Y el CLI con clap es una maravilla. Con el derive macro defines toda la interfaz en un enum:
#[derive(Parser)]
#[command(name = "cerebro", version, about = "RAG knowledge base CLI")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Query {
texto: Option<String>,
#[arg(short, long, default_value_t = 0.4)]
alpha: f32,
#[arg(short = 'k', long, default_value_t = 5)]
top_k: u32,
#[arg(long)]
tag: Option<String>,
},
List { tipo: String },
}
La diferencia de velocidad es notable. He medido tiempos y el binario de Rust es consistentemente 2 o 3 veces más rápido que Python para la misma operación. En consultas FTS5 simples, la diferencia es aún mayor porque Rust no paga el startup time.
# Python
time cerebro query "hooks de git" # ~350ms
# Rust
time ./target/release/cerebro query "hooks de git" # ~12ms
Para instalar la versión Rust: cargo build --release y luego cp ./target/release/cerebro ~/.local/bin/. O si quieres, cargo install --path ..
Con cerebro list stats puedes consultar las estadísticas de tu base de conocimiento. En mi caso particular, tengo 6059 documentos troceados en 37573 chunks ocupando 262 MB. Y con cerebro list tags puedes ver los 536 tags diferentes que tienes en tu base de conocimiento. No está mal, ¿eh? 🔥
Re-ranking: del bi-encoder al cross-encoder
Vale, vamos a meternos en un poco de teoría. Hasta ahora hemos usado un bi-encoder: el modelo bge-m3 genera un vector para la consulta y otro para cada chunk, y medimos la distancia. Es rápido, pero el modelo procesa consulta y chunk por separado. No hay interacción entre ellos.
El cross-encoder funciona diferente: recibe la consulta y el chunk juntos, como un solo texto, y calcula un score de relevancia. Esto permite atención cruzada entre ambos textos. Es mucho más preciso, pero también mucho más lento. No puedes precomputar nada.
La estrategia ganadora: usar el hybrid search para obtener un candidato amplio (digamos 20 resultados), y luego pasar esos 20 por el cross-encoder para quedarnos con los mejores 5.
| Característica | Bi-encoder (bge-m3) | Cross-encoder (bge-reranker) |
|---|---|---|
| Latencia | Baja (precomputable) | Alta (por par) |
| Uso | Recuperación inicial | Re-ranking de pocos resultados |
| Precisión | Buena | Excelente |
| Tamaño modelo | ~1 GB | ~0.6 GB |
La implementación en Python es sorprendentemente sencilla gracias a sentence-transformers:
from sentence_transformers import CrossEncoder
class Reranker:
def __init__(self, model_name="BAAI/bge-reranker-v2-m3", use_fp16=True):
self.model = CrossEncoder(model_name, max_length=512, device="cpu")
def rerank(self, query: str, candidates: list[dict],
top_k: int = 5) -> list[dict]:
# Construir pares (query, chunk)
pairs = [(query, c["contenido"]) for c in candidates]
# Obtener scores del cross-encoder
scores = self.model.predict(pairs)
for i, score in enumerate(scores):
candidates[i]["rerank_score"] = float(score)
candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
return candidates[:top_k]
# Pipeline completo: hybrid top20 -> reranker -> top5
def search_with_rerank(query, alpha=0.4, top_k_hybrid=20, top_k_final=5):
candidates = hybrid_search(query, alpha=alpha, top_k=top_k_hybrid)
reranker = Reranker()
return reranker.rerank(query, candidates, top_k=top_k_final)
¿Y cómo de lento es? En una CPU moderna, cargar el modelo por primera vez son entre 5 y 10 segundos, se descarga de Hugging Face y se cachea en ~/.cache/. Pero una vez cargado, rerankear 20 pares lleva entre 0.5 y 2 segundos. Para 50 pares, entre 1 y 5 segundos. Un consejo: usa use_fp16=True para acelerar aproximadamente el doble con pérdida mínima de precisión.
También existe FlagEmbedding que es una alternativa más rápida y ligera que sentence-transformers. Si prefieres ir por ese camino:
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)
score = reranker.compute_score(['query', 'passage'], normalize=True)
La pregunta del millón: ¿merece la pena? En mi experiencia, sí. El cross-encoder capta matices que el bi-encoder no puede. Por ejemplo, si buscas cómo configurar certificados SSL y tienes un chunk que habla de certificados Let’s Encrypt con acme.sh y otro que habla de certificados SSL en nginx, el cross-encoder va a entender que el segundo es más relevante porque configurar, SSL y nginx aparecen juntos en el mismo contexto.
Pero ojo, no todo es el reranker. Llega el momento de la última feature, y esta es mi favorita.
HyDE: cuando la IA inventa para encontrar
HyDE significa Hypothetical Document Embeddings. Y viene de un paper de 2022 de Gao y compañeros (te dejo el enlace en la sección de recursos). La idea es tan brillante como simple: en lugar de buscar con la consulta real, le pides a un LLM que genere un documento hipotético que respondería esa pregunta, y usas el embedding de ese documento falso para la búsqueda.
Suena a trampa, ¿verdad? Pero tiene una lógica aplastante: los documentos reales de tu base de conocimiento tienen un estilo, un vocabulario, una estructura. Tu consulta es corta y ambigua. Si el LLM genera un texto que se parece a tus documentos, el embedding de ese texto va a caer cerca de los embeddings reales.
La implementación es directa. Primero le pedimos a Ollama que genere un documento hipotético usando llama3.2, luego obtenemos su embedding con bge-m3, y finalmente hacemos cosine similarity contra todos los chunks de la base de datos. Sin FTS5, solo semántica pura.
import requests
import numpy as np
OLLAMA_URL = "http://localhost:11434/api"
def generate_hypothetical_document(query, model="llama3.2"):
prompt = f"""Genera un texto breve y factual que responda a la siguiente pregunta.
El texto debe ser informativo, objetivo y escrito en espa\u00f1ol.
Pregunta: {query}
Texto informativo:"""
response = requests.post(f"{OLLAMA_URL}/generate", json={
"model": model, "prompt": prompt, "stream": False
})
return response.json()["response"]
def hyde_search(query, top_k=5):
hypothetic_doc = generate_hypothetical_document(query)
print(f"[HyDE] Documento generado: {hypothetic_doc[:100]}...")
hyde_emb = get_embedding(hypothetic_doc)
conn = sqlite3.connect(DB_PATH)
cursor = conn.execute("SELECT id, contenido, embedding FROM chunks")
results = []
for row in cursor:
chunk_id, contenido, emb_blob = row
emb_vec = blob_to_vector(emb_blob)
emb_vec = emb_vec / (np.linalg.norm(emb_vec) + 1e-10)
cos_sim = float(np.dot(hyde_emb, emb_vec))
results.append((cos_sim, contenido[:200]))
results.sort(key=lambda x: x[0], reverse=True)
conn.close()
return results[:top_k]
¿Y cuándo funciona bien? Para consultas conceptuales o abstractas. Cosas como explica el patrón observer en Python o qué es la inyección de dependencias, consultas donde el LLM puede generar un texto que se parezca a lo que tú has escrito en tus notas. También funciona de maravilla para consultas cortas y ambiguas donde no sabes exactamente cómo expresar lo que buscas.
¿Y cuándo NO funciona? Para consultas factuales muy concretas. ¿Quién ganó las elecciones en 2022? o ¿Cuál es la IP de mi servidor?, ahí el LLM puede alucinar, generar un texto incorrecto, y el embedding te va a llevar a resultados erróneos. Es como pedirle a alguien que invente un mapa y luego buscar con ese mapa. Si el invento es bueno, genial. Si no, te pierdes.
Luego está la latencia: generar el documento hipotético con Ollama lleva tiempo (entre 1 y 3 segundos en una CPU moderna) y luego hay que generar el embedding. Duplicas el tiempo de respuesta.
| Situación | HyDE mejora | Explicación |
|---|---|---|
| Consultas conceptuales | Sí | Explica la teoría de la relatividad -> genera documento similar a los reales |
| Consultas cortas y ambiguas | Sí | ¿Qué es un hook? -> genera contexto concreto |
| Consultas factuales precisas | No | ¿Quién ganó en 2022? -> puede alucinar |
| Fuera del dominio del LLM | Depende | Si el LLM no conoce el tema, genera ruido |
La combinación que mejor me ha funcionado es HyDE + Hybrid Search: HyDE para la parte semántica, FTS5 para la exactitud. Con un alpha bajo, tipo 0.3, porque HyDE ya está capturando mucha semántica.
def hyde_hybrid_search(query, alpha=0.3, top_k=5):
hyde_doc = generate_hypothetical_document(query)
hyde_emb = get_embedding(hyde_doc)
# Combinar con FTS5 con alpha bajo...
# (el codigo es el mismo que hybrid_search pero
# usando hyde_emb en lugar del embedding de la query)
Un truco que he descubierto: si la consulta incluye términos muy concretos como Docker o nginx, mejor usar el hybrid search normal con alpha más alto. Si la consulta es abstracta como arquitectura de microservicios, ahí HyDE brilla.
Próximos episodios
Me he quedado a medias en el sentido de que hemos visto las búsquedas híbridas, el re-ranking y el HyDE, pero no he terminado de casar las búsquedas dentro de Neovim y Obsidian. Tendré un episodio dedicado a esto, pero no va a ser ni el 823 ni el 825, porque esos dos episodios van a ser uno dedicado a multi-agentes en Open Code y otro dedicado a Anacleto.
El de multi-agentes en Open Code (episodio 823) va sobre cómo he conseguido crear un agente que se encarga de coordinar a otros agentes especializados: agentes que hacen búsquedas en internet, agentes que redactan documentos, agentes que verifican que los documentos cumplen con unos requisitos. En el episodio 825 te quiero hablar de Anacleto, algo similar a Open Code pero enfocado en agentes y sub-agentes, en coordinar agentes. No quiero hablar explícitamente sobre la herramienta que he implementado, sino sobre los conceptos que he ido aprendiendo creándola: cómo funciona todo esto de los modelos, las herramientas, los MCP, cómo se le piden cosas y qué te devuelven.
Resumen
Y hasta aquí el episodio de hoy. Vamos a hacer un resumen rápido de todo lo que hemos construido:
| Herramienta | Tecnología | Cuándo usarla |
|---|---|---|
rag_hybrid.py | Python + numpy | Motor base: combina FTS5 + embeddings |
cerebro CLI | Python + argparse | Consultas rápidas desde terminal |
cerebro.nvim | Lua + sqlite.lua | Búsqueda sin salir de Neovim |
cerebro-cli | Rust + rusqlite | Máximo rendimiento, binario estático |
rag_reranker.py | sentence-transformers | Refinamiento: hybrid -> reranker -> top5 |
| HyDE | Ollama + bge-m3 | Consultas conceptuales abstractas |
Si te llevas algo de este episodio, que sea esto: FTS5 solo es buscar palabras. Embeddings solo es buscar significados. Juntos, es buscar con inteligencia.
Mi recomendación: instala cerebro hoy mismo. Si usas Neovim, no te saltes el plugin, la integración con <leader>rq es de esas cosas que no sabes que necesitas hasta que las pruebas. Y si eres de los que les gusta el rendimiento, compila la versión Rust y nota la diferencia.
Recuerda que todo esto empezó en el episodio 798 donde hablamos de MCP, RAG y notas, continuó en el episodio 809 con el RAG casero con Ollama, sentó las bases en el episodio 819 con la base de conocimiento, embeddings y chunking, y hoy hemos dado el salto definitivo para exprimir al máximo esa base de conocimiento.
Y nada más. Recuerda que este es un podcast de la red de Podcast de Sospechos Habituales donde puedes encontrar fantásticos y maravillosos podcasts. Puedes participar en esa red entrando en la web. De la misma manera, si quieres participar en el grupo de atareao con Linux, simplemente tienes que buscar atareao con Linux y allí nos vas a encontrar hablando de RAG, de modelos de lenguaje, de Open Code, probablemente también de Anacleto y de cualquier cosa relacionada con Linux y con el mundo del open source. Recuerda que la vida son dos días y uno ya ha pasado, así que disfruta como si no hubiera mañana y si puede ser con Linux y en este caso con el tema de los RAG, mejor que mejor. Un saludo y nos escuchamos el próximo jueves. 🐧
Más información
- SQLite FTS5 Documentation — oficial — Documentación completa del motor de búsqueda de texto completo FTS5 de SQLite
- HyDE Paper: Precise Zero-Shot Dense Retrieval without Relevance Labels (Gao et al. 2022) — Paper original que introduce la técnica de Hypothetical Document Embeddings
- Sentence-BERT Cross-Encoder documentation — Documentación oficial de los cross-encoders para re-ranking
- BAAI/bge-reranker-v2-m3 en Hugging Face — Modelo de cross-encoder usado para re-ranking
- FlagEmbedding GitHub — Implementación alternativa más rápida del reranker
- numpy.frombuffer — documentación oficial — Cómo leer BLOBs binarios como arrays de numpy
- fzf — Fuzzy finder para terminal — Herramienta interactiva para filtrar y seleccionar resultados en terminal
- sqlite.lua — Binding nativo de SQLite para Neovim — Librería LuaJIT para acceder a SQLite desde Neovim
- rusqlite — SQLite para Rust — Binding de SQLite para Rust con soporte FTS5
- Ollama Embed API — API para generar embeddings con modelos locales vía Ollama
- Gist: cerebro-cli en Rust — Código completo del CLI de cerebro implementado en Rust
- Gist: Scripts Python de RAG — Código completo de hybrid search, reranker y HyDE en Python
- Episodio 798 — MCP, RAG y notas — Cómo interactuar con tus notas usando IA
- Episodio 809 — RAG casero con Ollama y SQLite — Primer RAG casero con Ollama
- Episodio 819 — Base de conocimiento con embeddings y chunking — Creación de la base de conocimiento que usamos hoy