-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathretriever.py
More file actions
335 lines (264 loc) · 13 KB
/
Copy pathretriever.py
File metadata and controls
335 lines (264 loc) · 13 KB
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
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
"""
retriever.py
------------
Context Retrieval & Ranking Engine.
Now supports three modes:
1. keyword — fast exact matching
2. tfidf — classic TF-IDF cosine similarity
3. hybrid — TF-IDF + Vector Embeddings (best of both worlds)
Optional dependency: sentence-transformers (graceful fallback to random embeddings).
"""
import math
import re
import logging
from collections import Counter
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import numpy as np
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────
# Optional Embeddings Support
# ─────────────────────────────────────────────
EMBEDDINGS_AVAILABLE = False
SentenceTransformer = None
try:
from sentence_transformers import SentenceTransformer
EMBEDDINGS_AVAILABLE = True
except ImportError:
pass # Will use random embeddings fallback
if EMBEDDINGS_AVAILABLE:
# Optional: silence transformers logging globally when embeddings are used
logging.getLogger("transformers").setLevel(logging.ERROR)
# ─────────────────────────────────────────────
# Singleton Embedding Engine
# ─────────────────────────────────────────────
_embedding_instance: Optional["EmbeddingEngine"] = None
class EmbeddingEngine:
"""Singleton embedding engine — loads the model only once."""
def __new__(cls, model_name: str = "all-MiniLM-L6-v2"):
global _embedding_instance
if _embedding_instance is None:
_embedding_instance = super().__new__(cls)
if EMBEDDINGS_AVAILABLE:
_embedding_instance.model = SentenceTransformer(model_name)
_embedding_instance.dim = _embedding_instance.model.get_sentence_embedding_dimension()
logger.info(f"✅ Loaded embedding model: {model_name} ({_embedding_instance.dim} dims)")
else:
_embedding_instance.model = None
_embedding_instance.dim = 384
logger.warning("⚠️ sentence-transformers not installed. Using random embeddings fallback.")
return _embedding_instance
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
# __init__ is only called once due to singleton
pass
def embed(self, texts: List[str]) -> np.ndarray:
"""Encode texts into embeddings."""
if self.model is not None:
return self.model.encode(
texts, convert_to_numpy=True, normalize_embeddings=True
)
else:
# Fallback: random embeddings for demo/testing
return np.random.rand(len(texts), self.dim).astype(np.float32)
def similarity(self, query_emb: np.ndarray, doc_embs: np.ndarray) -> np.ndarray:
"""Compute cosine similarity (assumes normalized embeddings)."""
return np.dot(doc_embs, query_emb.T).flatten()
# ─────────────────────────────────────────────
# Stopwords — improves TF-IDF signal quality
# ─────────────────────────────────────────────
_STOPWORDS = frozenset({
"a", "an", "the", "is", "it", "in", "on", "at", "to", "for",
"of", "and", "or", "but", "not", "with", "this", "that", "are",
"was", "be", "by", "from", "as", "has", "have", "had", "its",
"they", "them", "their", "we", "you", "he", "she", "i", "my",
"your", "our", "how", "what", "which", "who", "when", "where",
"do", "does", "did", "will", "would", "can", "could", "should",
"may", "might", "also", "so", "if", "about", "into", "than",
"more", "such", "both", "each", "all", "no", "any", "there",
})
# ─────────────────────────────────────────────
# Data models
# ─────────────────────────────────────────────
@dataclass
class Document:
"""A unit of knowledge in the context engine."""
id: str
content: str
source: str = "unknown"
tags: List[str] = field(default_factory=list)
def __post_init__(self) -> None:
if not self.id:
raise ValueError("Document.id must not be empty.")
if not self.content or not self.content.strip():
raise ValueError(f"Document '{self.id}' has empty content.")
def __repr__(self) -> str:
preview = self.content[:60].replace("\n", " ")
return f"Document(id={self.id!r}, preview={preview!r}...)"
@dataclass
class ScoredDocument:
"""A document paired with its relevance score."""
document: Document
score: float
match_reason: str = ""
def __repr__(self) -> str:
return f"ScoredDocument(id={self.document.id!r}, score={self.score:.4f})"
# ─────────────────────────────────────────────
# Text utilities
# ─────────────────────────────────────────────
def _tokenize(text: str) -> List[str]:
"""Lowercase, strip punctuation, remove stopwords."""
text = text.lower()
text = re.sub(r"[^a-z0-9\s]", " ", text)
return [
t for t in text.split()
if len(t) > 1 and t not in _STOPWORDS
]
def _term_frequency(tokens: List[str]) -> Dict[str, float]:
"""Raw term frequency: count / total tokens."""
if not tokens:
return {}
counts = Counter(tokens)
total = len(tokens)
return {term: count / total for term, count in counts.items()}
def _cosine_sim(vec_a: Dict[str, float], vec_b: Dict[str, float]) -> float:
"""Cosine similarity between two sparse TF-IDF vectors."""
dot = sum(vec_a.get(k, 0.0) * vec_b.get(k, 0.0) for k in vec_a)
norm_a = math.sqrt(sum(v ** 2 for v in vec_a.values()))
norm_b = math.sqrt(sum(v ** 2 for v in vec_b.values()))
if norm_a == 0.0 or norm_b == 0.0:
return 0.0
return dot / (norm_a * norm_b)
# ─────────────────────────────────────────────
# Retriever — Main Class
# ─────────────────────────────────────────────
class Retriever:
"""
Supports 'keyword', 'tfidf', and 'hybrid' modes.
"""
VALID_MODES = ("keyword", "tfidf", "hybrid")
def __init__(self, documents: Optional[List[Document]] = None, mode: str = "tfidf") -> None:
if mode not in self.VALID_MODES:
raise ValueError(f"mode must be one of {self.VALID_MODES}, got {mode!r}.")
self.mode = mode
self.documents: List[Document] = []
self.embedding_engine: Optional[EmbeddingEngine] = None
# TF-IDF incremental state
self._doc_freq: Counter = Counter()
self._n_docs: int = 0
# Cache for document embeddings (only used in hybrid mode)
self._doc_embeddings: Optional[np.ndarray] = None
if mode == "hybrid":
self.embedding_engine = EmbeddingEngine()
# Add initial documents
for doc in documents or []:
self.add_document(doc)
logger.debug(
"Retriever initialised: mode=%s, docs=%d, vocab=%d",
mode, self._n_docs, len(self._doc_freq)
)
# ── Indexing ──────────────────────────────
def _index_document(self, doc: Document) -> None:
"""Index document for TF-IDF and (if hybrid) cache its embedding."""
self.documents.append(doc)
self._n_docs += 1
# TF-IDF indexing
for term in set(_tokenize(doc.content)):
self._doc_freq[term] += 1
# Hybrid: Update cached embeddings if engine exists
if self.embedding_engine and self._doc_embeddings is not None:
# Recompute all embeddings when a new doc is added (simple but correct)
self._compute_doc_embeddings()
def _idf(self, term: str) -> float:
df = self._doc_freq.get(term, 0)
return math.log((1 + self._n_docs) / (1 + df)) + 1.0
def _compute_doc_embeddings(self) -> None:
"""Pre-compute and cache embeddings for all documents (hybrid mode only)."""
if not self.embedding_engine:
return
contents = [doc.content for doc in self.documents]
self._doc_embeddings = self.embedding_engine.embed(contents)
# ── Public API ────────────────────────────
def add_document(self, doc: Document) -> None:
"""Incrementally add a document and update indexes/caches."""
self._index_document(doc)
logger.debug("Document added: id=%s, total=%d", doc.id, self._n_docs)
def retrieve(self, query: str, top_k: int = 5, alpha: float = 0.65) -> List[ScoredDocument]:
"""
Main retrieval method.
alpha: weight for embeddings when using hybrid mode (0.0 = pure TF-IDF, 1.0 = pure embeddings)
"""
if not query or not query.strip():
logger.warning("retrieve() called with empty query.")
return []
if top_k < 1:
raise ValueError(f"top_k must be >= 1, got {top_k}.")
if self.mode == "keyword":
return self._keyword_retrieve(query, top_k)
elif self.mode == "hybrid":
return self._hybrid_retrieve(query, top_k, alpha)
else: # tfidf
return self._tfidf_retrieve(query, top_k)
# ── Retrieval Implementations ─────────────
def _keyword_retrieve(self, query: str, top_k: int) -> List[ScoredDocument]:
query_tokens = set(_tokenize(query))
if not query_tokens:
return []
results: List[ScoredDocument] = []
for doc in self.documents:
doc_tokens = set(_tokenize(doc.content))
overlap = query_tokens & doc_tokens
if overlap:
score = len(overlap) / len(query_tokens)
results.append(ScoredDocument(
document=doc,
score=round(score, 4),
match_reason=f"keyword overlap: {sorted(overlap)[:5]}"
))
results.sort(key=lambda x: x.score, reverse=True)
return results[:top_k]
def _tfidf_retrieve(self, query: str, top_k: int) -> List[ScoredDocument]:
query_tokens = _tokenize(query)
if not query_tokens:
return []
query_tf = _term_frequency(query_tokens)
query_vec = {term: tf * self._idf(term) for term, tf in query_tf.items()}
results: List[ScoredDocument] = []
for doc in self.documents:
doc_tokens = _tokenize(doc.content)
doc_tf = _term_frequency(doc_tokens)
doc_vec = {term: tf * self._idf(term) for term, tf in doc_tf.items()}
score = _cosine_sim(query_vec, doc_vec)
if score > 0.0:
results.append(ScoredDocument(
document=doc,
score=round(score, 4),
match_reason="tfidf cosine similarity"
))
results.sort(key=lambda x: x.score, reverse=True)
return results[:top_k]
def _hybrid_retrieve(self, query: str, top_k: int, alpha: float = 0.65) -> List[ScoredDocument]:
"""Hybrid TF-IDF + Embedding retrieval with cached document embeddings."""
if not self.embedding_engine:
logger.warning("Hybrid mode requested but embeddings unavailable. Falling back to TF-IDF.")
return self._tfidf_retrieve(query, top_k)
# Lazy compute document embeddings (only once)
if self._doc_embeddings is None:
self._compute_doc_embeddings()
# Get TF-IDF scores for candidate boosting
tfidf_results = self._tfidf_retrieve(query, top_k=12)
tfidf_dict = {r.document.id: r.score for r in tfidf_results}
# Embedding scores
query_emb = self.embedding_engine.embed([query])[0]
emb_scores = self.embedding_engine.similarity(query_emb, self._doc_embeddings)
results = []
for i, doc in enumerate(self.documents):
tf_score = tfidf_dict.get(doc.id, 0.0)
emb_score = float(emb_scores[i])
hybrid_score = alpha * emb_score + (1 - alpha) * tf_score
results.append(ScoredDocument(
document=doc,
score=round(hybrid_score, 4),
match_reason=f"hybrid (α={alpha:.2f})"
))
results.sort(key=lambda x: x.score, reverse=True)
return results[:top_k]