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, {}