docsif 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docsif-0.1.0.dist-info/METADATA +393 -0
- docsif-0.1.0.dist-info/RECORD +86 -0
- docsif-0.1.0.dist-info/WHEEL +4 -0
- docsif-0.1.0.dist-info/entry_points.txt +3 -0
- docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
- sif/__init__.py +24 -0
- sif/_version.py +3 -0
- sif/cli/__init__.py +11 -0
- sif/cli/commands/__init__.py +18 -0
- sif/cli/commands/bench.py +148 -0
- sif/cli/commands/collection.py +360 -0
- sif/cli/commands/context.py +154 -0
- sif/cli/commands/get.py +200 -0
- sif/cli/commands/index.py +294 -0
- sif/cli/commands/ls.py +67 -0
- sif/cli/commands/mcp.py +93 -0
- sif/cli/commands/pull.py +111 -0
- sif/cli/commands/search.py +574 -0
- sif/cli/config.py +206 -0
- sif/cli/formatters.py +269 -0
- sif/cli/main.py +205 -0
- sif/config/__init__.py +30 -0
- sif/config/constants.py +52 -0
- sif/config/settings.py +217 -0
- sif/core/__init__.py +21 -0
- sif/core/collection.py +161 -0
- sif/core/context.py +167 -0
- sif/core/document.py +125 -0
- sif/core/models.py +298 -0
- sif/database/__init__.py +23 -0
- sif/database/connection.py +139 -0
- sif/database/database.py +136 -0
- sif/database/migrations.py +187 -0
- sif/database/repositories.py +500 -0
- sif/database/repository.py +171 -0
- sif/database/schema.py +362 -0
- sif/embedding/__init__.py +22 -0
- sif/embedding/cache.py +196 -0
- sif/embedding/embedder.py +328 -0
- sif/embedding/factory.py +92 -0
- sif/embedding/manager.py +200 -0
- sif/embedding/model.py +126 -0
- sif/indexing/__init__.py +21 -0
- sif/indexing/chunker.py +226 -0
- sif/indexing/indexer.py +226 -0
- sif/indexing/parser.py +192 -0
- sif/indexing/scanner.py +148 -0
- sif/indexing/watcher.py +156 -0
- sif/mcp/README.md +258 -0
- sif/mcp/__init__.py +9 -0
- sif/mcp/claude_desktop_config.json +16 -0
- sif/mcp/cli.py +92 -0
- sif/mcp/protocol.py +336 -0
- sif/mcp/server.py +433 -0
- sif/mcp/server_http.py +54 -0
- sif/mcp/test_mcp.py +108 -0
- sif/mcp/tools.py +497 -0
- sif/mcp/transport_http.py +424 -0
- sif/mcp/transport_stdio.py +292 -0
- sif/mcp_server/__init__.py +26 -0
- sif/mcp_server/handlers.py +68 -0
- sif/mcp_server/server.py +58 -0
- sif/mcp_server/tools.py +216 -0
- sif/mcp_server/transport.py +221 -0
- sif/models/__init__.py +70 -0
- sif/models/collection.py +51 -0
- sif/models/context.py +45 -0
- sif/models/document.py +76 -0
- sif/models/download.py +208 -0
- sif/models/embedding.py +71 -0
- sif/models/search.py +92 -0
- sif/search/__init__.py +22 -0
- sif/search/benchmark.py +110 -0
- sif/search/bm25.py +216 -0
- sif/search/expansion.py +157 -0
- sif/search/hybrid.py +323 -0
- sif/search/rerank.py +390 -0
- sif/search/rrf.py +186 -0
- sif/search/snippets.py +130 -0
- sif/search/strategy.py +97 -0
- sif/search/vector.py +177 -0
- sif/utils/__init__.py +26 -0
- sif/utils/logging.py +143 -0
- sif/utils/paths.py +82 -0
- sif/utils/progress.py +83 -0
- sif/utils/text.py +128 -0
sif/search/rerank.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Result reranking using cross-encoders and LLM-based rerankers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from sif.core.models import SearchResult
|
|
10
|
+
from sif.utils.logging import get_logger, is_quiet, suppress_output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from sentence_transformers import CrossEncoder # noqa: F401
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sort_and_build_results(
|
|
20
|
+
results: list[SearchResult],
|
|
21
|
+
scores: list[float],
|
|
22
|
+
top_k: int,
|
|
23
|
+
) -> list[SearchResult]:
|
|
24
|
+
"""Sort results by score descending and rebuild SearchResult objects."""
|
|
25
|
+
scored = list(zip(results, scores))
|
|
26
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
27
|
+
|
|
28
|
+
reranked: list[SearchResult] = []
|
|
29
|
+
for rank, (result, score) in enumerate(scored[:top_k], 1):
|
|
30
|
+
new_scores = dict(result.scores) if result.scores else {}
|
|
31
|
+
new_scores["reranker_score"] = score
|
|
32
|
+
|
|
33
|
+
reranked.append(
|
|
34
|
+
SearchResult(
|
|
35
|
+
document_id=result.document_id,
|
|
36
|
+
title=result.title,
|
|
37
|
+
path=result.path,
|
|
38
|
+
collection_name=result.collection_name,
|
|
39
|
+
score=score,
|
|
40
|
+
content=result.content,
|
|
41
|
+
highlights=result.highlights,
|
|
42
|
+
rank=rank,
|
|
43
|
+
scores=new_scores,
|
|
44
|
+
snippet=result.snippet,
|
|
45
|
+
context_description=result.context_description,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
return reranked
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LlamaCppReranker:
|
|
52
|
+
"""Rerank search results using a GGUF cross-encoder via llama-cpp-python."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
model_path: str | None = None,
|
|
57
|
+
model_name: str = "",
|
|
58
|
+
n_ctx: int = 512,
|
|
59
|
+
n_threads: int | None = None,
|
|
60
|
+
batch_size: int = 32,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._model_path = model_path
|
|
63
|
+
self._model_name = model_name
|
|
64
|
+
self._n_ctx = n_ctx
|
|
65
|
+
self._n_threads = n_threads
|
|
66
|
+
self._batch_size = batch_size
|
|
67
|
+
self._model = None
|
|
68
|
+
|
|
69
|
+
def load(self) -> None:
|
|
70
|
+
"""Load the GGUF model."""
|
|
71
|
+
if self._model is not None:
|
|
72
|
+
return
|
|
73
|
+
from llama_cpp import Llama # noqa: PLC0415
|
|
74
|
+
|
|
75
|
+
path = self._model_path or self._model_name
|
|
76
|
+
if not path:
|
|
77
|
+
raise ValueError("No model_path or model_name provided for LlamaCppReranker")
|
|
78
|
+
logger.info(f"Loading GGUF reranker model: {path}")
|
|
79
|
+
self._model = Llama(
|
|
80
|
+
model_path=str(path),
|
|
81
|
+
n_ctx=self._n_ctx,
|
|
82
|
+
n_threads=self._n_threads,
|
|
83
|
+
embedding=True,
|
|
84
|
+
verbose=False,
|
|
85
|
+
)
|
|
86
|
+
logger.info("GGUF reranker model loaded successfully")
|
|
87
|
+
|
|
88
|
+
def rerank(
|
|
89
|
+
self,
|
|
90
|
+
query: str,
|
|
91
|
+
results: list[SearchResult],
|
|
92
|
+
top_k: int = 10,
|
|
93
|
+
) -> list[SearchResult]:
|
|
94
|
+
"""Rerank search results."""
|
|
95
|
+
if not results:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
if self._model is None:
|
|
99
|
+
self.load()
|
|
100
|
+
|
|
101
|
+
import numpy as np # noqa: PLC0415
|
|
102
|
+
|
|
103
|
+
# Embed query once
|
|
104
|
+
query_emb = self._model.embed(query)
|
|
105
|
+
query_vec = np.array(query_emb)
|
|
106
|
+
query_norm = np.linalg.norm(query_vec)
|
|
107
|
+
|
|
108
|
+
# Score each result by embedding similarity
|
|
109
|
+
scored = []
|
|
110
|
+
for result in results:
|
|
111
|
+
text = result.content or ""
|
|
112
|
+
if not text and result.highlights:
|
|
113
|
+
text = result.highlights[0]
|
|
114
|
+
if result.title:
|
|
115
|
+
text = f"{result.title}\n\n{text}" if text else result.title
|
|
116
|
+
doc_emb = self._model.embed(text)
|
|
117
|
+
doc_vec = np.array(doc_emb)
|
|
118
|
+
doc_norm = np.linalg.norm(doc_vec)
|
|
119
|
+
sim = np.dot(query_vec, doc_vec) / (query_norm * doc_norm + 1e-8)
|
|
120
|
+
scored.append((result, float(sim)))
|
|
121
|
+
|
|
122
|
+
# Normalize to [0, 1] via sigmoid
|
|
123
|
+
normalized = [1.0 / (1.0 + math.exp(-s)) for _, s in scored]
|
|
124
|
+
|
|
125
|
+
return _sort_and_build_results([r for r, _ in scored], normalized, top_k)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class CrossEncoderReranker:
|
|
129
|
+
"""Rerank search results using sentence-transformers CrossEncoder."""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
|
|
134
|
+
model_path: str | None = None,
|
|
135
|
+
device: str | None = None,
|
|
136
|
+
batch_size: int = 32,
|
|
137
|
+
cache_dir: str | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
self._model_name = model_name
|
|
140
|
+
self._model_path = model_path
|
|
141
|
+
self._device = device
|
|
142
|
+
self._batch_size = batch_size
|
|
143
|
+
self._cache_dir = cache_dir
|
|
144
|
+
self._model = None
|
|
145
|
+
|
|
146
|
+
def load(self) -> None:
|
|
147
|
+
"""Load the cross-encoder model."""
|
|
148
|
+
if self._model is not None:
|
|
149
|
+
return
|
|
150
|
+
from sentence_transformers import CrossEncoder # noqa: PLC0415
|
|
151
|
+
|
|
152
|
+
model_id = self._model_path or self._model_name
|
|
153
|
+
|
|
154
|
+
# If model_id looks like a ModelScope ID (contains "/" and not a local path),
|
|
155
|
+
# download from ModelScope first
|
|
156
|
+
local_path = model_id
|
|
157
|
+
if "/" in model_id and not Path(model_id).exists():
|
|
158
|
+
try:
|
|
159
|
+
from sif.models.download import ModelDownloader # noqa: PLC0415
|
|
160
|
+
|
|
161
|
+
downloader = ModelDownloader(self._cache_dir)
|
|
162
|
+
downloaded = downloader.download(model_id)
|
|
163
|
+
# snapshot_download may return a path with subdirectories
|
|
164
|
+
subdirs = [d for d in downloaded.iterdir() if d.is_dir()]
|
|
165
|
+
local_path = str(subdirs[0]) if subdirs else str(downloaded)
|
|
166
|
+
logger.info(f"Loading ModelScope reranker model: {model_id}")
|
|
167
|
+
except Exception:
|
|
168
|
+
logger.info(f"Loading ST reranker model: {model_id}")
|
|
169
|
+
else:
|
|
170
|
+
logger.info(f"Loading ST reranker model: {model_id}")
|
|
171
|
+
|
|
172
|
+
if is_quiet():
|
|
173
|
+
with suppress_output():
|
|
174
|
+
self._model = CrossEncoder(local_path, device=self._device, max_length=512)
|
|
175
|
+
else:
|
|
176
|
+
self._model = CrossEncoder(local_path, device=self._device, max_length=512)
|
|
177
|
+
logger.info("Reranker model loaded successfully")
|
|
178
|
+
|
|
179
|
+
def rerank(
|
|
180
|
+
self,
|
|
181
|
+
query: str,
|
|
182
|
+
results: list[SearchResult],
|
|
183
|
+
top_k: int = 10,
|
|
184
|
+
) -> list[SearchResult]:
|
|
185
|
+
"""Rerank search results."""
|
|
186
|
+
if not results:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
if self._model is None:
|
|
190
|
+
self.load()
|
|
191
|
+
|
|
192
|
+
# Build query-document pairs
|
|
193
|
+
pairs: list[tuple[str, str]] = []
|
|
194
|
+
for result in results:
|
|
195
|
+
text = result.content or ""
|
|
196
|
+
if not text and result.highlights:
|
|
197
|
+
text = result.highlights[0]
|
|
198
|
+
if result.title:
|
|
199
|
+
text = f"{result.title}\n\n{text}" if text else result.title
|
|
200
|
+
pairs.append((query, text))
|
|
201
|
+
|
|
202
|
+
# Score all pairs
|
|
203
|
+
raw_scores = self._model.predict(pairs, batch_size=self._batch_size) # type: ignore[union-attr]
|
|
204
|
+
|
|
205
|
+
# Normalize scores to [0, 1] using sigmoid
|
|
206
|
+
normalized = [1.0 / (1.0 + math.exp(-float(s))) for s in raw_scores]
|
|
207
|
+
|
|
208
|
+
return _sort_and_build_results(results, normalized, top_k)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Qwen3Reranker:
|
|
212
|
+
"""Reranker for Qwen3-Reranker models using AutoModelForCausalLM.
|
|
213
|
+
|
|
214
|
+
Qwen3-Reranker models are Causal LMs that predict "yes"/"no" tokens
|
|
215
|
+
to judge query-document relevance. See:
|
|
216
|
+
https://huggingface.co/Qwen/Qwen3-Reranker-0.6B
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__( # noqa: PLR0913
|
|
220
|
+
self,
|
|
221
|
+
model_name: str = "Qwen/Qwen3-Reranker-0.6B",
|
|
222
|
+
model_path: str | None = None,
|
|
223
|
+
device: str | None = None,
|
|
224
|
+
batch_size: int = 8,
|
|
225
|
+
cache_dir: str | None = None,
|
|
226
|
+
max_length: int = 8192,
|
|
227
|
+
) -> None:
|
|
228
|
+
self._model_name = model_name
|
|
229
|
+
self._model_path = model_path
|
|
230
|
+
self._device = device
|
|
231
|
+
self._batch_size = batch_size
|
|
232
|
+
self._cache_dir = cache_dir
|
|
233
|
+
self._max_length = max_length
|
|
234
|
+
self._model = None
|
|
235
|
+
self._tokenizer = None
|
|
236
|
+
self._token_true_id: int | None = None
|
|
237
|
+
self._token_false_id: int | None = None
|
|
238
|
+
|
|
239
|
+
def load(self) -> None:
|
|
240
|
+
"""Load the Qwen3 reranker model."""
|
|
241
|
+
if self._model is not None:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
import torch # noqa: PLC0415
|
|
245
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer # noqa: PLC0415
|
|
246
|
+
|
|
247
|
+
model_id = self._model_path or self._model_name
|
|
248
|
+
local_path = model_id
|
|
249
|
+
|
|
250
|
+
# Download from ModelScope if needed
|
|
251
|
+
if "/" in model_id and not Path(model_id).exists():
|
|
252
|
+
from sif.models.download import ModelDownloader # noqa: PLC0415
|
|
253
|
+
|
|
254
|
+
downloader = ModelDownloader(self._cache_dir)
|
|
255
|
+
downloaded = downloader.download(model_id)
|
|
256
|
+
subdirs = [d for d in downloaded.iterdir() if d.is_dir()]
|
|
257
|
+
local_path = str(subdirs[0]) if subdirs else str(downloaded)
|
|
258
|
+
|
|
259
|
+
logger.info(f"Loading Qwen3 reranker model: {model_id}")
|
|
260
|
+
|
|
261
|
+
if is_quiet():
|
|
262
|
+
with suppress_output():
|
|
263
|
+
self._tokenizer = AutoTokenizer.from_pretrained(local_path, padding_side="left")
|
|
264
|
+
self._model = AutoModelForCausalLM.from_pretrained(
|
|
265
|
+
local_path,
|
|
266
|
+
torch_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
|
|
267
|
+
).eval()
|
|
268
|
+
|
|
269
|
+
if self._device:
|
|
270
|
+
self._model = self._model.to(self._device)
|
|
271
|
+
else:
|
|
272
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
273
|
+
self._model = self._model.to(device)
|
|
274
|
+
|
|
275
|
+
self._token_true_id = self._tokenizer.convert_tokens_to_ids("yes")
|
|
276
|
+
self._token_false_id = self._tokenizer.convert_tokens_to_ids("no")
|
|
277
|
+
else:
|
|
278
|
+
self._tokenizer = AutoTokenizer.from_pretrained(local_path, padding_side="left")
|
|
279
|
+
self._model = AutoModelForCausalLM.from_pretrained(
|
|
280
|
+
local_path,
|
|
281
|
+
torch_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
|
|
282
|
+
).eval()
|
|
283
|
+
|
|
284
|
+
if self._device:
|
|
285
|
+
self._model = self._model.to(self._device)
|
|
286
|
+
else:
|
|
287
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
288
|
+
self._model = self._model.to(device)
|
|
289
|
+
|
|
290
|
+
self._token_true_id = self._tokenizer.convert_tokens_to_ids("yes")
|
|
291
|
+
self._token_false_id = self._tokenizer.convert_tokens_to_ids("no")
|
|
292
|
+
logger.info("Qwen3 reranker model loaded successfully")
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _format_prompt(instruction: str | None, query: str, doc: str) -> str:
|
|
296
|
+
"""Format query-document pair into Qwen3 reranker prompt."""
|
|
297
|
+
if instruction is None:
|
|
298
|
+
instruction = (
|
|
299
|
+
"Given a web search query, retrieve relevant passages that answer the query"
|
|
300
|
+
)
|
|
301
|
+
return f"<Instruct>: {instruction}\n<Query>: {query}\n<Document>: {doc}"
|
|
302
|
+
|
|
303
|
+
def rerank(
|
|
304
|
+
self,
|
|
305
|
+
query: str,
|
|
306
|
+
results: list[SearchResult],
|
|
307
|
+
top_k: int = 10,
|
|
308
|
+
) -> list[SearchResult]:
|
|
309
|
+
"""Rerank search results using Qwen3 Causal LM."""
|
|
310
|
+
if not results:
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
if self._model is None:
|
|
314
|
+
self.load()
|
|
315
|
+
|
|
316
|
+
import torch # noqa: PLC0415
|
|
317
|
+
|
|
318
|
+
# Build prompts
|
|
319
|
+
texts: list[str] = []
|
|
320
|
+
for result in results:
|
|
321
|
+
text = result.content or ""
|
|
322
|
+
if not text and result.highlights:
|
|
323
|
+
text = result.highlights[0]
|
|
324
|
+
if result.title:
|
|
325
|
+
text = f"{result.title}\n\n{text}" if text else result.title
|
|
326
|
+
texts.append(self._format_prompt(None, query, text))
|
|
327
|
+
|
|
328
|
+
# Score in batches
|
|
329
|
+
all_scores: list[float] = []
|
|
330
|
+
for i in range(0, len(texts), self._batch_size):
|
|
331
|
+
batch = texts[i : i + self._batch_size]
|
|
332
|
+
inputs = self._tokenizer(
|
|
333
|
+
batch,
|
|
334
|
+
padding=True,
|
|
335
|
+
truncation=True,
|
|
336
|
+
max_length=self._max_length,
|
|
337
|
+
return_tensors="pt",
|
|
338
|
+
)
|
|
339
|
+
inputs = {k: v.to(self._model.device) for k, v in inputs.items()} # type: ignore[union-attr]
|
|
340
|
+
|
|
341
|
+
with torch.no_grad():
|
|
342
|
+
outputs = self._model(**inputs) # type: ignore[operator]
|
|
343
|
+
|
|
344
|
+
# Last token logits -> yes/no probabilities
|
|
345
|
+
logits = outputs.logits[:, -1, :] # type: ignore[union-attr]
|
|
346
|
+
true_scores = logits[:, self._token_true_id]
|
|
347
|
+
false_scores = logits[:, self._token_false_id]
|
|
348
|
+
batch_logits = torch.stack([false_scores, true_scores], dim=1)
|
|
349
|
+
batch_probs = torch.nn.functional.softmax(batch_logits, dim=1)
|
|
350
|
+
scores = batch_probs[:, 1].tolist()
|
|
351
|
+
all_scores.extend(scores)
|
|
352
|
+
|
|
353
|
+
return _sort_and_build_results(results, all_scores, top_k)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def create_reranker(
|
|
357
|
+
settings,
|
|
358
|
+
) -> LlamaCppReranker | CrossEncoderReranker | Qwen3Reranker:
|
|
359
|
+
"""Factory: create a reranker based on settings."""
|
|
360
|
+
model_type = getattr(settings, "reranker_model_type", "gguf")
|
|
361
|
+
model_name = getattr(settings, "reranker_model_name", "")
|
|
362
|
+
cache_dir = str(settings.get_cache_dir()) if hasattr(settings, "get_cache_dir") else None
|
|
363
|
+
|
|
364
|
+
# Auto-detect Qwen3 reranker models
|
|
365
|
+
if "qwen3-reranker" in model_name.lower() or "qwen3_reranker" in model_name.lower():
|
|
366
|
+
return Qwen3Reranker(
|
|
367
|
+
model_name=settings.reranker_model_name,
|
|
368
|
+
model_path=str(settings.reranker_model_path) if settings.reranker_model_path else None,
|
|
369
|
+
batch_size=settings.reranker_batch_size,
|
|
370
|
+
cache_dir=cache_dir,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if model_type == "gguf":
|
|
374
|
+
return LlamaCppReranker(
|
|
375
|
+
model_path=str(settings.reranker_model_path) if settings.reranker_model_path else None,
|
|
376
|
+
model_name=settings.reranker_model_name,
|
|
377
|
+
batch_size=settings.reranker_batch_size,
|
|
378
|
+
)
|
|
379
|
+
if model_type == "sentence_transformers":
|
|
380
|
+
return CrossEncoderReranker(
|
|
381
|
+
model_name=settings.reranker_model_name,
|
|
382
|
+
model_path=str(settings.reranker_model_path) if settings.reranker_model_path else None,
|
|
383
|
+
batch_size=settings.reranker_batch_size,
|
|
384
|
+
cache_dir=cache_dir,
|
|
385
|
+
)
|
|
386
|
+
raise ValueError(f"Unknown reranker model_type: {model_type}")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# Backwards-compatible alias
|
|
390
|
+
Reranker = LlamaCppReranker
|
sif/search/rrf.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Reciprocal Rank Fusion (RRF) implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sif.core.models import SearchResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RRFFusion:
|
|
9
|
+
"""Reciprocal Rank Fusion for combining search results."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, k: int = 60) -> None:
|
|
12
|
+
"""Initialize RRF with k parameter.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
k: Constant to prevent division by zero and dampen
|
|
16
|
+
the impact of low rankings. Default is 60.
|
|
17
|
+
"""
|
|
18
|
+
self.k = k
|
|
19
|
+
|
|
20
|
+
def fuse(
|
|
21
|
+
self,
|
|
22
|
+
results_lists: list[list[SearchResult]],
|
|
23
|
+
limit: int = 10,
|
|
24
|
+
) -> list[SearchResult]:
|
|
25
|
+
"""Fuse multiple ranked lists using RRF.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
results_lists: List of ranked result lists
|
|
29
|
+
limit: Maximum number of results to return
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Fused and re-ranked list of results
|
|
33
|
+
"""
|
|
34
|
+
# Map document_id to scores
|
|
35
|
+
scores: dict[str, tuple[float, SearchResult]] = {}
|
|
36
|
+
|
|
37
|
+
for list_idx, results in enumerate(results_lists):
|
|
38
|
+
for rank, result in enumerate(results, 1):
|
|
39
|
+
doc_id = result.document_id
|
|
40
|
+
|
|
41
|
+
# Calculate RRF score: 1 / (k + rank)
|
|
42
|
+
rrf_score = 1.0 / (self.k + rank)
|
|
43
|
+
|
|
44
|
+
# Build scores dict
|
|
45
|
+
doc_scores: dict[str, float | None] = {}
|
|
46
|
+
if result.scores:
|
|
47
|
+
doc_scores.update(result.scores)
|
|
48
|
+
|
|
49
|
+
# Track original score from this list
|
|
50
|
+
score_key = "bm25_score" if list_idx == 0 else "vector_score"
|
|
51
|
+
if score_key not in doc_scores:
|
|
52
|
+
doc_scores[score_key] = result.score
|
|
53
|
+
|
|
54
|
+
if doc_id in scores:
|
|
55
|
+
existing_score, existing_result = scores[doc_id]
|
|
56
|
+
# Merge scores from existing result
|
|
57
|
+
merged_scores: dict[str, float | None] = {}
|
|
58
|
+
if existing_result.scores:
|
|
59
|
+
merged_scores.update(existing_result.scores)
|
|
60
|
+
merged_scores.update(doc_scores)
|
|
61
|
+
scores[doc_id] = (existing_score + rrf_score, existing_result)
|
|
62
|
+
# Update the stored result's scores
|
|
63
|
+
existing_result.scores = merged_scores
|
|
64
|
+
else:
|
|
65
|
+
new_result = SearchResult(
|
|
66
|
+
document_id=result.document_id,
|
|
67
|
+
title=result.title,
|
|
68
|
+
path=result.path,
|
|
69
|
+
collection_name=result.collection_name,
|
|
70
|
+
score=rrf_score,
|
|
71
|
+
content=result.content,
|
|
72
|
+
highlights=result.highlights,
|
|
73
|
+
rank=rank,
|
|
74
|
+
scores=doc_scores,
|
|
75
|
+
snippet=result.snippet,
|
|
76
|
+
context_description=result.context_description,
|
|
77
|
+
)
|
|
78
|
+
scores[doc_id] = (rrf_score, new_result)
|
|
79
|
+
|
|
80
|
+
# Sort by RRF score (descending)
|
|
81
|
+
sorted_scores = sorted(scores.items(), key=lambda x: x[1][0], reverse=True)
|
|
82
|
+
|
|
83
|
+
# Build final results
|
|
84
|
+
fused_results = []
|
|
85
|
+
for rank, (_doc_id, (score, result)) in enumerate(sorted_scores[:limit], 1):
|
|
86
|
+
result.score = score
|
|
87
|
+
result.rank = rank
|
|
88
|
+
result.scores["rrf_score"] = score
|
|
89
|
+
fused_results.append(result)
|
|
90
|
+
|
|
91
|
+
return fused_results
|
|
92
|
+
|
|
93
|
+
def fuse_with_weights(
|
|
94
|
+
self,
|
|
95
|
+
results_lists: list[list[SearchResult]],
|
|
96
|
+
weights: list[float],
|
|
97
|
+
limit: int = 10,
|
|
98
|
+
) -> list[SearchResult]:
|
|
99
|
+
"""Fuse multiple ranked lists with weights.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
results_lists: List of ranked result lists
|
|
103
|
+
weights: Weight for each result list (must sum to 1.0)
|
|
104
|
+
limit: Maximum number of results to return
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Fused and re-ranked list of results
|
|
108
|
+
"""
|
|
109
|
+
if len(results_lists) != len(weights):
|
|
110
|
+
raise ValueError("Number of result lists must match number of weights")
|
|
111
|
+
|
|
112
|
+
# Normalize weights
|
|
113
|
+
total_weight = sum(weights)
|
|
114
|
+
normalized_weights = [w / total_weight for w in weights]
|
|
115
|
+
|
|
116
|
+
# Map document_id to weighted scores
|
|
117
|
+
scores: dict[str, tuple[float, SearchResult]] = {}
|
|
118
|
+
|
|
119
|
+
for list_idx, (results, weight) in enumerate(zip(results_lists, normalized_weights)):
|
|
120
|
+
for rank, result in enumerate(results, 1):
|
|
121
|
+
doc_id = result.document_id
|
|
122
|
+
|
|
123
|
+
# Calculate weighted RRF score
|
|
124
|
+
rrf_score = weight * (1.0 / (self.k + rank))
|
|
125
|
+
|
|
126
|
+
# Build scores dict
|
|
127
|
+
doc_scores: dict[str, float | None] = {}
|
|
128
|
+
if result.scores:
|
|
129
|
+
doc_scores.update(result.scores)
|
|
130
|
+
|
|
131
|
+
# Track original score from this list
|
|
132
|
+
score_key = "bm25_score" if list_idx == 0 else "vector_score"
|
|
133
|
+
if score_key not in doc_scores:
|
|
134
|
+
doc_scores[score_key] = result.score
|
|
135
|
+
|
|
136
|
+
if doc_id in scores:
|
|
137
|
+
existing_score, existing_result = scores[doc_id]
|
|
138
|
+
# Merge scores from existing result
|
|
139
|
+
merged_scores: dict[str, float | None] = {}
|
|
140
|
+
if existing_result.scores:
|
|
141
|
+
merged_scores.update(existing_result.scores)
|
|
142
|
+
merged_scores.update(doc_scores)
|
|
143
|
+
scores[doc_id] = (existing_score + rrf_score, existing_result)
|
|
144
|
+
# Update the stored result's scores
|
|
145
|
+
existing_result.scores = merged_scores
|
|
146
|
+
else:
|
|
147
|
+
new_result = SearchResult(
|
|
148
|
+
document_id=result.document_id,
|
|
149
|
+
title=result.title,
|
|
150
|
+
path=result.path,
|
|
151
|
+
collection_name=result.collection_name,
|
|
152
|
+
score=rrf_score,
|
|
153
|
+
content=result.content,
|
|
154
|
+
highlights=result.highlights,
|
|
155
|
+
rank=rank,
|
|
156
|
+
scores=doc_scores,
|
|
157
|
+
snippet=result.snippet,
|
|
158
|
+
context_description=result.context_description,
|
|
159
|
+
)
|
|
160
|
+
scores[doc_id] = (rrf_score, new_result)
|
|
161
|
+
|
|
162
|
+
# Sort by weighted score (descending)
|
|
163
|
+
sorted_scores = sorted(scores.items(), key=lambda x: x[1][0], reverse=True)
|
|
164
|
+
|
|
165
|
+
# Build final results
|
|
166
|
+
fused_results = []
|
|
167
|
+
for rank, (_doc_id, (score, result)) in enumerate(sorted_scores[:limit], 1):
|
|
168
|
+
result.score = score
|
|
169
|
+
result.rank = rank
|
|
170
|
+
result.scores["rrf_score"] = score
|
|
171
|
+
fused_results.append(result)
|
|
172
|
+
|
|
173
|
+
return fused_results
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def compute_rrf_score(rank: int, k: int = 60) -> float:
|
|
177
|
+
"""Compute RRF score for a single rank.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
rank: The rank position (1-indexed)
|
|
181
|
+
k: RRF constant
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
RRF score
|
|
185
|
+
"""
|
|
186
|
+
return 1.0 / (k + rank)
|
sif/search/snippets.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Smart snippet extraction from document chunks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from sif.utils.logging import get_logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SmartSnippetExtractor:
|
|
14
|
+
"""Extract the most relevant snippet from a chunk based on query term frequency."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, max_length: int = 300, context_radius: int = 80) -> None:
|
|
17
|
+
"""Initialize snippet extractor.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
max_length: Maximum snippet length in characters
|
|
21
|
+
context_radius: Context radius around best sentence (unused, kept for API compat)
|
|
22
|
+
"""
|
|
23
|
+
self.max_length = max_length
|
|
24
|
+
self.context_radius = context_radius
|
|
25
|
+
|
|
26
|
+
def extract(self, text: str, query_terms: list[str]) -> str:
|
|
27
|
+
"""Extract the best snippet from text matching query terms.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
text: Full chunk or document text
|
|
31
|
+
query_terms: Lowercase query terms to score by
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Best snippet string (may be truncated with ellipsis)
|
|
35
|
+
"""
|
|
36
|
+
if not text:
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
if not query_terms:
|
|
40
|
+
return self._fallback_snippet(text)
|
|
41
|
+
|
|
42
|
+
# Split into sentences
|
|
43
|
+
sentences = self._split_sentences(text)
|
|
44
|
+
if len(sentences) <= 1:
|
|
45
|
+
return self._truncate(text)
|
|
46
|
+
|
|
47
|
+
# Score each sentence by query term frequency
|
|
48
|
+
scored = []
|
|
49
|
+
for i, sentence in enumerate(sentences):
|
|
50
|
+
score = self._score_sentence(sentence, query_terms)
|
|
51
|
+
scored.append((i, score, sentence))
|
|
52
|
+
|
|
53
|
+
# Find the highest-scoring sentence
|
|
54
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
55
|
+
best_idx, best_score, _best_sentence = scored[0]
|
|
56
|
+
|
|
57
|
+
if best_score == 0:
|
|
58
|
+
return self._fallback_snippet(text)
|
|
59
|
+
|
|
60
|
+
# Build a window around the best sentence
|
|
61
|
+
return self._build_window(sentences, best_idx, query_terms)
|
|
62
|
+
|
|
63
|
+
def _split_sentences(self, text: str) -> list[str]:
|
|
64
|
+
"""Split text into sentences."""
|
|
65
|
+
# Simple sentence splitting on .!? followed by space or end
|
|
66
|
+
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
|
|
67
|
+
return [s.strip() for s in sentences if s.strip()]
|
|
68
|
+
|
|
69
|
+
def _score_sentence(self, sentence: str, query_terms: list[str]) -> float:
|
|
70
|
+
"""Score a sentence by weighted query term frequency."""
|
|
71
|
+
sentence_lower = sentence.lower()
|
|
72
|
+
score = 0.0
|
|
73
|
+
for term in query_terms:
|
|
74
|
+
count = sentence_lower.count(term.lower())
|
|
75
|
+
# Longer matches get higher weight
|
|
76
|
+
score += count * len(term)
|
|
77
|
+
return score
|
|
78
|
+
|
|
79
|
+
def _build_window(self, sentences: list[str], center_idx: int, _query_terms: list[str]) -> str:
|
|
80
|
+
"""Build a contiguous window of sentences around the center."""
|
|
81
|
+
# Start with center sentence
|
|
82
|
+
window = [sentences[center_idx]]
|
|
83
|
+
total_len = len(window[0])
|
|
84
|
+
|
|
85
|
+
# Expand outward
|
|
86
|
+
left = center_idx - 1
|
|
87
|
+
right = center_idx + 1
|
|
88
|
+
|
|
89
|
+
while True:
|
|
90
|
+
added = False
|
|
91
|
+
# Try adding from left
|
|
92
|
+
if left >= 0:
|
|
93
|
+
candidate = sentences[left]
|
|
94
|
+
if total_len + len(candidate) + 1 <= self.max_length:
|
|
95
|
+
window.insert(0, candidate)
|
|
96
|
+
total_len += len(candidate) + 1
|
|
97
|
+
left -= 1
|
|
98
|
+
added = True
|
|
99
|
+
|
|
100
|
+
# Try adding from right
|
|
101
|
+
if right < len(sentences):
|
|
102
|
+
candidate = sentences[right]
|
|
103
|
+
if total_len + len(candidate) + 1 <= self.max_length:
|
|
104
|
+
window.append(candidate)
|
|
105
|
+
total_len += len(candidate) + 1
|
|
106
|
+
right += 1
|
|
107
|
+
added = True
|
|
108
|
+
|
|
109
|
+
if not added:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
snippet = " ".join(window)
|
|
113
|
+
|
|
114
|
+
# Add ellipsis if truncated
|
|
115
|
+
if left >= 0:
|
|
116
|
+
snippet = "..." + snippet
|
|
117
|
+
if right < len(sentences):
|
|
118
|
+
snippet = snippet + "..."
|
|
119
|
+
|
|
120
|
+
return snippet.strip()
|
|
121
|
+
|
|
122
|
+
def _fallback_snippet(self, text: str) -> str:
|
|
123
|
+
"""Return the beginning of text when no query terms match."""
|
|
124
|
+
return self._truncate(text)
|
|
125
|
+
|
|
126
|
+
def _truncate(self, text: str) -> str:
|
|
127
|
+
"""Truncate text to max_length with ellipsis."""
|
|
128
|
+
if len(text) <= self.max_length:
|
|
129
|
+
return text.strip()
|
|
130
|
+
return text[: self.max_length].strip() + "..."
|