File size: 7,368 Bytes
b414ebf 413a89a b414ebf 413a89a b414ebf 413a89a b414ebf 413a89a b414ebf 413a89a b414ebf 5de4047 413a89a 5de4047 b414ebf 413a89a b414ebf 413a89a e6b5c2b 413a89a e6b5c2b 413a89a e6b5c2b 413a89a 0ea9cb5 413a89a b414ebf e6b5c2b 413a89a b4a1c1a 413a89a b4a1c1a 413a89a 5de4047 413a89a b4a1c1a 413a89a b4a1c1a 413a89a b4a1c1a 413a89a b4a1c1a 413a89a b4a1c1a 413a89a e6b5c2b 413a89a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | """Knowledge base module for ORTOS Telegram Bot.
Hybrid search: local bge-m3 + BM25 + FAISS (RRF fusion).
Fallback: HF Inference API ? TF-IDF.
"""
import json, os, requests, logging
import numpy as np
from pydantic import BaseModel
from rank_bm25 import BM25Okapi
import faiss
logger = logging.getLogger(__name__)
class KnowledgeItem(BaseModel):
id: str
title: str
content: str
_index = None
_sections = []
_bm25 = None
_bm25_corpus = []
_local_model = None
_tfidf_backup = None
_items_backup = None
_hf_token = os.getenv("HF_TOKEN")
_morph = None
_morph_loaded = False
def _get_morph():
global _morph, _morph_loaded
if _morph_loaded:
return _morph
_morph_loaded = True
try:
from pymorphy3 import MorphAnalyzer
_morph = MorphAnalyzer()
logger.info("pymorphy3 loaded for BM25")
except Exception as e:
logger.warning(f"pymorphy3 unavailable: {e}")
_morph = None
return _morph
def _stem_word(word: str) -> str:
if not word.isalpha():
return word
m = _get_morph()
if m is not None:
try:
return m.parse(word)[0].normal_form
except Exception:
pass
return word
def _tokenize(text: str) -> list[str]:
if _get_morph() is None:
return text.lower().split()
return [_stem_word(w) for w in text.lower().split()]
def _build_bm25(sections: list[KnowledgeItem]):
global _bm25, _bm25_corpus
_bm25_corpus = [_tokenize(s.title + " " + s.content) for s in sections]
_bm25 = BM25Okapi(_bm25_corpus)
def _load_faiss():
global _index
index_path = "embeddings/index.faiss"
if os.path.exists(index_path):
_index = faiss.read_index(index_path)
return True
return False
def _load_sections(paths):
global _sections
if isinstance(paths, str):
paths = [paths]
_sections = []
for path in paths:
with open(path, encoding="utf-8") as f:
data = json.load(f)
for name, sec in data["sections"].items():
_sections.append(KnowledgeItem(
id=f"{path}:{name}",
title=sec["title"],
content=sec["content"],
))
return _sections
def _init_tfidf_backup(paths):
global _tfidf_backup, _items_backup
try:
from knowledge_tfidf_backup import reload_knowledge as tfidf_reload
_items_backup, _tfidf_backup = tfidf_reload(paths)
except Exception:
pass
def _init_local_model():
global _local_model
try:
from sentence_transformers import SentenceTransformer
logger.info("Loading bge-m3 locally...")
_local_model = SentenceTransformer("BAAI/bge-m3", trust_remote_code=True)
logger.info("bge-m3 loaded locally")
return True
except Exception as e:
logger.warning(f"Local model unavailable: {e}")
return False
def _embed_local(query: str) -> np.ndarray | None:
if _local_model is None:
return None
try:
emb = _local_model.encode([query], normalize_embeddings=True)
return emb.astype(np.float32)
except Exception:
return None
def _embed_via_hfapi(query: str) -> np.ndarray | None:
headers = {"Authorization": f"Bearer {_hf_token}"} if _hf_token else {}
try:
resp = requests.post(
"https://api-inference.huggingface.co/models/BAAI/bge-m3",
headers=headers,
json={"inputs": query},
timeout=15,
)
if resp.status_code == 200:
emb = np.array(resp.json(), dtype=np.float32)
emb = emb / np.linalg.norm(emb)
return emb.reshape(1, -1).astype(np.float32)
except Exception:
pass
return None
def _embed_query(query: str) -> np.ndarray | None:
emb = _embed_local(query)
if emb is not None:
return emb
emb = _embed_via_hfapi(query)
if emb is not None:
return emb
return None
def load_knowledge_base(paths: list[str] | str) -> list[KnowledgeItem]:
return _load_sections(paths)
def search(query: str, top_k: int = 2, items: list[KnowledgeItem] = None,
tfidf: dict = None) -> list[KnowledgeItem]:
return search_debug(query, top_k)["items"]
def search_debug(query: str, top_k: int = 2) -> dict:
"""Returns {items, method, details: [{title, bm25_score, embed_rank, rrf_score}]}"""
n = len(_sections)
if n == 0:
return {"items": [], "method": "none", "details": []}
bm25_scores = None
if _bm25 is not None:
bm25_scores = _bm25.get_scores(_tokenize(query))
embed_ranks = None
qvec = _embed_query(query)
if qvec is not None and _index is not None and _index.ntotal > 0:
embed_scores, embed_indices = _index.search(qvec, min(n, _index.ntotal))
embed_ranks = {int(idx): rank for rank, idx in enumerate(embed_indices[0])}
# --- RRF fusion ---
if bm25_scores is not None and embed_ranks is not None:
rrf = {}
for i in range(n):
bm25_rank = sorted(range(n), key=lambda j: -bm25_scores[j]).index(i)
rrf_score = 0.0
rrf_score += 0.3 * (1 / (bm25_rank + 1))
if i in embed_ranks:
rrf_score += 0.7 * (1 / (embed_ranks[i] + 1))
rrf[i] = rrf_score
top_indices = sorted(rrf.keys(), key=lambda i: -rrf[i])[:top_k]
details = [{
"id": _sections[i].id,
"title": _sections[i].title,
"bm25_rank": sorted(range(n), key=lambda j: -bm25_scores[j]).index(i),
"embed_rank": embed_ranks.get(i, None),
"rrf_score": round(rrf[i], 4),
} for i in top_indices]
return {"items": [_sections[i] for i in top_indices], "method": "hybrid (bge-m3+BM25)", "details": details}
# --- Embedding only ---
if embed_ranks is not None:
top_indices = sorted(embed_ranks.keys(), key=lambda i: embed_ranks[i])[:top_k]
details = [{"id": _sections[i].id, "title": _sections[i].title, "embed_rank": embed_ranks[i], "rrf_score": None} for i in top_indices]
return {"items": [_sections[i] for i in top_indices], "method": "bge-m3 only", "details": details}
# --- BM25 only ---
if bm25_scores is not None:
top_indices = sorted(range(n), key=lambda i: -bm25_scores[i])[:top_k]
details = [{"id": _sections[i].id, "title": _sections[i].title, "bm25_rank": i, "rrf_score": None} for i in top_indices]
return {"items": [_sections[i] for i in top_indices], "method": "BM25 only", "details": details}
# --- TF-IDF fallback ---
if _tfidf_backup is not None and _items_backup:
from knowledge_tfidf_backup import search as tfidf_search
items = tfidf_search(query, top_k=top_k, items=_items_backup, tfidf=_tfidf_backup)
details = [{"id": it.id, "title": it.title, "rrf_score": None} for it in items]
return {"items": items, "method": "TF-IDF fallback", "details": details}
return {"items": [], "method": "none", "details": []}
def reload_knowledge(paths: list[str] | str) -> tuple[list[KnowledgeItem], dict]:
if isinstance(paths, str):
paths = [paths]
_load_faiss()
items = _load_sections(paths)
_build_bm25(items)
_init_tfidf_backup(paths)
_init_local_model()
return items, {}
|