最終更新 3 weeks ago

Script completo de búsqueda híbrida para la base de conocimiento RAG. Combina FTS5 (BM25) con cosine similarity sobre embeddings de bge-m3. Parámetro alpha para ajustar el peso entre búsqueda exacta y semántica. Episodio 821 de atareao con Linux.

修正履歴 d0b89697fbfd330a20cdefca2e8f933a772873d1

rag_hybrid.py Raw
1#!/usr/bin/env python3
2"""
3rag_hybrid.py — Búsqueda híbrida (FTS5 + cosine similarity) para la base de conocimiento RAG.
4
5Combina 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
9La fórmula de combinación es:
10 score_total = alpha * sigmoid(fts_score/10) + (1 - alpha) * cosine_sim
11
12Uso:
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
17import argparse
18import math
19import os
20import sqlite3
21import sys
22from typing import Any
23
24import numpy as np
25from numpy.linalg import norm
26
27DB_PATH = os.path.expanduser("~/.cerebro/rag_conocimiento.db")
28EMBEDDING_DIM = 1024
29EMBEDDING_BYTES = EMBEDDING_DIM * 4 # 4096 bytes
30
31
32def 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
38def normalizar(vector: np.ndarray) -> np.ndarray:
39 norma = norm(vector)
40 return vector / norma if norma > 0 else vector
41
42
43def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
44 return float(np.dot(a, b))
45
46
47def sigmoid_fts(fts_score: float) -> float:
48 return 1.0 / (1.0 + math.exp(-fts_score / 10.0))
49
50
51def 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
62def 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
125def 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
149if __name__ == "__main__":
150 main()