rag_hybrid.py
· 4.5 KiB · Python
Originalformat
#!/usr/bin/env python3
"""
rag_hybrid.py — Búsqueda híbrida (FTS5 + cosine similarity) para la base de conocimiento RAG.
Combina lo mejor de dos mundos:
- FTS5: búsqueda textual exacta con stemming y diacríticos
- Embeddings: búsqueda semántica por cosine similarity
La fórmula de combinación es:
score_total = alpha * sigmoid(fts_score/10) + (1 - alpha) * cosine_sim
Uso:
python3 rag_hybrid.py "cómo configurar un proxy inverso"
python3 rag_hybrid.py "hooks de git" --alpha 0.6 --limite 10
"""
import argparse
import math
import os
import sqlite3
import sys
from typing import Any
import numpy as np
from numpy.linalg import norm
DB_PATH = os.path.expanduser("~/.cerebro/rag_conocimiento.db")
EMBEDDING_DIM = 1024
EMBEDDING_BYTES = EMBEDDING_DIM * 4 # 4096 bytes
def blob_to_vector(blob: bytes) -> np.ndarray:
if len(blob) != EMBEDDING_BYTES:
raise ValueError(f"BLOB incorrecto: {len(blob)} bytes (esperados {EMBEDDING_BYTES})")
return np.frombuffer(blob, dtype=np.float32).copy()
def normalizar(vector: np.ndarray) -> np.ndarray:
norma = norm(vector)
return vector / norma if norma > 0 else vector
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b))
def sigmoid_fts(fts_score: float) -> float:
return 1.0 / (1.0 + math.exp(-fts_score / 10.0))
def obtener_embedding_ollama(texto: str) -> np.ndarray:
import requests
response = requests.post(
"http://localhost:11434/api/embeddings",
json={"model": "bge-m3", "prompt": texto},
timeout=30,
)
response.raise_for_status()
return np.array(response.json()["embedding"], dtype=np.float32)
def hybrid_search(query: str, alpha: float = 0.4, top_k: int = 10, tag: str | None = None) -> list[dict]:
query_emb = obtener_embedding_ollama(query)
query_emb = normalizar(query_emb)
if not os.path.exists(DB_PATH):
print(f"Error: BD no encontrada en {DB_PATH}", file=sys.stderr)
return []
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
where_clauses: list[str] = ["chunks_fts MATCH ?"]
params: list[Any] = [query]
if tag:
where_clauses.append("c.tags LIKE ?")
params.append(f"%{tag}%")
sql = f"""
SELECT c.id, c.content, e.vector, c.doc_id,
bm25(chunks_fts) as fts_score
FROM chunks_fts
JOIN chunks c ON chunks_fts.rowid = c.id
JOIN embeddings e ON e.chunk_id = c.id
WHERE {" AND ".join(where_clauses)}
ORDER BY fts_score DESC
LIMIT ?
"""
params.append(top_k * 2)
cursor = conn.execute(sql, params)
rows = cursor.fetchall()
conn.close()
if not rows:
print(f"Sin resultados para: {query}")
return []
results = []
for row in rows:
try:
emb_vec = blob_to_vector(row["vector"])
except ValueError as e:
print(f"[aviso] {e}", file=sys.stderr)
continue
emb_vec = normalizar(emb_vec)
cos_sim = cosine_similarity(query_emb, emb_vec)
fts_norm = sigmoid_fts(row["fts_score"])
score_total = alpha * fts_norm + (1.0 - alpha) * cos_sim
results.append({
"contenido": row["content"],
"doc_id": row["doc_id"],
"score": score_total,
"fts_score": round(row["fts_score"], 4),
"cos_sim": round(cos_sim, 4),
})
results.sort(key=lambda x: x["score"], reverse=True)
return results[:top_k]
def main():
parser = argparse.ArgumentParser(description="Búsqueda híbrida FTS5 + embeddings")
parser.add_argument("consulta", help="Texto de la consulta")
parser.add_argument("--alpha", type=float, default=0.4, help="Peso de FTS5 (0-1)")
parser.add_argument("--limite", type=int, default=10, help="Número de resultados")
parser.add_argument("--tag", help="Filtrar por etiqueta")
args = parser.parse_args()
resultados = hybrid_search(query=args.consulta, alpha=args.alpha, top_k=args.limite, tag=args.tag)
if not resultados:
sys.exit(0)
print(f"\n{= * 70}")
print(f" Consulta: {args.consulta} | Alpha: {args.alpha} | Resultados: {len(resultados)}")
print(f"{= * 70}\n")
for i, r in enumerate(resultados, 1):
preview = r["contenido"].replace("\n", " ")[:120]
print(f" [{i:2d}] Score: {r[score]:.4f} (FTS: {r[fts_score]:.4f} | Cos: {r[cos_sim]:.4f})")
print(f" {preview}\n")
print(f"{= * 70}")
if __name__ == "__main__":
main()
| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | rag_hybrid.py — Búsqueda híbrida (FTS5 + cosine similarity) para la base de conocimiento RAG. |
| 4 | |
| 5 | Combina lo mejor de dos mundos: |
| 6 | - FTS5: búsqueda textual exacta con stemming y diacríticos |
| 7 | - Embeddings: búsqueda semántica por cosine similarity |
| 8 | |
| 9 | La fórmula de combinación es: |
| 10 | score_total = alpha * sigmoid(fts_score/10) + (1 - alpha) * cosine_sim |
| 11 | |
| 12 | Uso: |
| 13 | python3 rag_hybrid.py "cómo configurar un proxy inverso" |
| 14 | python3 rag_hybrid.py "hooks de git" --alpha 0.6 --limite 10 |
| 15 | """ |
| 16 | |
| 17 | import argparse |
| 18 | import math |
| 19 | import os |
| 20 | import sqlite3 |
| 21 | import sys |
| 22 | from typing import Any |
| 23 | |
| 24 | import numpy as np |
| 25 | from numpy.linalg import norm |
| 26 | |
| 27 | DB_PATH = os.path.expanduser("~/.cerebro/rag_conocimiento.db") |
| 28 | EMBEDDING_DIM = 1024 |
| 29 | EMBEDDING_BYTES = EMBEDDING_DIM * 4 # 4096 bytes |
| 30 | |
| 31 | |
| 32 | def blob_to_vector(blob: bytes) -> np.ndarray: |
| 33 | if len(blob) != EMBEDDING_BYTES: |
| 34 | raise ValueError(f"BLOB incorrecto: {len(blob)} bytes (esperados {EMBEDDING_BYTES})") |
| 35 | return np.frombuffer(blob, dtype=np.float32).copy() |
| 36 | |
| 37 | |
| 38 | def normalizar(vector: np.ndarray) -> np.ndarray: |
| 39 | norma = norm(vector) |
| 40 | return vector / norma if norma > 0 else vector |
| 41 | |
| 42 | |
| 43 | def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: |
| 44 | return float(np.dot(a, b)) |
| 45 | |
| 46 | |
| 47 | def sigmoid_fts(fts_score: float) -> float: |
| 48 | return 1.0 / (1.0 + math.exp(-fts_score / 10.0)) |
| 49 | |
| 50 | |
| 51 | def obtener_embedding_ollama(texto: str) -> np.ndarray: |
| 52 | import requests |
| 53 | response = requests.post( |
| 54 | "http://localhost:11434/api/embeddings", |
| 55 | json={"model": "bge-m3", "prompt": texto}, |
| 56 | timeout=30, |
| 57 | ) |
| 58 | response.raise_for_status() |
| 59 | return np.array(response.json()["embedding"], dtype=np.float32) |
| 60 | |
| 61 | |
| 62 | def hybrid_search(query: str, alpha: float = 0.4, top_k: int = 10, tag: str | None = None) -> list[dict]: |
| 63 | query_emb = obtener_embedding_ollama(query) |
| 64 | query_emb = normalizar(query_emb) |
| 65 | |
| 66 | if not os.path.exists(DB_PATH): |
| 67 | print(f"Error: BD no encontrada en {DB_PATH}", file=sys.stderr) |
| 68 | return [] |
| 69 | |
| 70 | conn = sqlite3.connect(DB_PATH) |
| 71 | conn.row_factory = sqlite3.Row |
| 72 | |
| 73 | where_clauses: list[str] = ["chunks_fts MATCH ?"] |
| 74 | params: list[Any] = [query] |
| 75 | |
| 76 | if tag: |
| 77 | where_clauses.append("c.tags LIKE ?") |
| 78 | params.append(f"%{tag}%") |
| 79 | |
| 80 | sql = f""" |
| 81 | SELECT c.id, c.content, e.vector, c.doc_id, |
| 82 | bm25(chunks_fts) as fts_score |
| 83 | FROM chunks_fts |
| 84 | JOIN chunks c ON chunks_fts.rowid = c.id |
| 85 | JOIN embeddings e ON e.chunk_id = c.id |
| 86 | WHERE {" AND ".join(where_clauses)} |
| 87 | ORDER BY fts_score DESC |
| 88 | LIMIT ? |
| 89 | """ |
| 90 | params.append(top_k * 2) |
| 91 | |
| 92 | cursor = conn.execute(sql, params) |
| 93 | rows = cursor.fetchall() |
| 94 | conn.close() |
| 95 | |
| 96 | if not rows: |
| 97 | print(f"Sin resultados para: {query}") |
| 98 | return [] |
| 99 | |
| 100 | results = [] |
| 101 | for row in rows: |
| 102 | try: |
| 103 | emb_vec = blob_to_vector(row["vector"]) |
| 104 | except ValueError as e: |
| 105 | print(f"[aviso] {e}", file=sys.stderr) |
| 106 | continue |
| 107 | |
| 108 | emb_vec = normalizar(emb_vec) |
| 109 | cos_sim = cosine_similarity(query_emb, emb_vec) |
| 110 | fts_norm = sigmoid_fts(row["fts_score"]) |
| 111 | score_total = alpha * fts_norm + (1.0 - alpha) * cos_sim |
| 112 | |
| 113 | results.append({ |
| 114 | "contenido": row["content"], |
| 115 | "doc_id": row["doc_id"], |
| 116 | "score": score_total, |
| 117 | "fts_score": round(row["fts_score"], 4), |
| 118 | "cos_sim": round(cos_sim, 4), |
| 119 | }) |
| 120 | |
| 121 | results.sort(key=lambda x: x["score"], reverse=True) |
| 122 | return results[:top_k] |
| 123 | |
| 124 | |
| 125 | def main(): |
| 126 | parser = argparse.ArgumentParser(description="Búsqueda híbrida FTS5 + embeddings") |
| 127 | parser.add_argument("consulta", help="Texto de la consulta") |
| 128 | parser.add_argument("--alpha", type=float, default=0.4, help="Peso de FTS5 (0-1)") |
| 129 | parser.add_argument("--limite", type=int, default=10, help="Número de resultados") |
| 130 | parser.add_argument("--tag", help="Filtrar por etiqueta") |
| 131 | args = parser.parse_args() |
| 132 | |
| 133 | resultados = hybrid_search(query=args.consulta, alpha=args.alpha, top_k=args.limite, tag=args.tag) |
| 134 | if not resultados: |
| 135 | sys.exit(0) |
| 136 | |
| 137 | print(f"\n{= * 70}") |
| 138 | print(f" Consulta: {args.consulta} | Alpha: {args.alpha} | Resultados: {len(resultados)}") |
| 139 | print(f"{= * 70}\n") |
| 140 | |
| 141 | for i, r in enumerate(resultados, 1): |
| 142 | preview = r["contenido"].replace("\n", " ")[:120] |
| 143 | print(f" [{i:2d}] Score: {r[score]:.4f} (FTS: {r[fts_score]:.4f} | Cos: {r[cos_sim]:.4f})") |
| 144 | print(f" {preview}\n") |
| 145 | |
| 146 | print(f"{= * 70}") |
| 147 | |
| 148 | |
| 149 | if __name__ == "__main__": |
| 150 | main() |