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.
Files changed (86) hide show
  1. docsif-0.1.0.dist-info/METADATA +393 -0
  2. docsif-0.1.0.dist-info/RECORD +86 -0
  3. docsif-0.1.0.dist-info/WHEEL +4 -0
  4. docsif-0.1.0.dist-info/entry_points.txt +3 -0
  5. docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. sif/__init__.py +24 -0
  7. sif/_version.py +3 -0
  8. sif/cli/__init__.py +11 -0
  9. sif/cli/commands/__init__.py +18 -0
  10. sif/cli/commands/bench.py +148 -0
  11. sif/cli/commands/collection.py +360 -0
  12. sif/cli/commands/context.py +154 -0
  13. sif/cli/commands/get.py +200 -0
  14. sif/cli/commands/index.py +294 -0
  15. sif/cli/commands/ls.py +67 -0
  16. sif/cli/commands/mcp.py +93 -0
  17. sif/cli/commands/pull.py +111 -0
  18. sif/cli/commands/search.py +574 -0
  19. sif/cli/config.py +206 -0
  20. sif/cli/formatters.py +269 -0
  21. sif/cli/main.py +205 -0
  22. sif/config/__init__.py +30 -0
  23. sif/config/constants.py +52 -0
  24. sif/config/settings.py +217 -0
  25. sif/core/__init__.py +21 -0
  26. sif/core/collection.py +161 -0
  27. sif/core/context.py +167 -0
  28. sif/core/document.py +125 -0
  29. sif/core/models.py +298 -0
  30. sif/database/__init__.py +23 -0
  31. sif/database/connection.py +139 -0
  32. sif/database/database.py +136 -0
  33. sif/database/migrations.py +187 -0
  34. sif/database/repositories.py +500 -0
  35. sif/database/repository.py +171 -0
  36. sif/database/schema.py +362 -0
  37. sif/embedding/__init__.py +22 -0
  38. sif/embedding/cache.py +196 -0
  39. sif/embedding/embedder.py +328 -0
  40. sif/embedding/factory.py +92 -0
  41. sif/embedding/manager.py +200 -0
  42. sif/embedding/model.py +126 -0
  43. sif/indexing/__init__.py +21 -0
  44. sif/indexing/chunker.py +226 -0
  45. sif/indexing/indexer.py +226 -0
  46. sif/indexing/parser.py +192 -0
  47. sif/indexing/scanner.py +148 -0
  48. sif/indexing/watcher.py +156 -0
  49. sif/mcp/README.md +258 -0
  50. sif/mcp/__init__.py +9 -0
  51. sif/mcp/claude_desktop_config.json +16 -0
  52. sif/mcp/cli.py +92 -0
  53. sif/mcp/protocol.py +336 -0
  54. sif/mcp/server.py +433 -0
  55. sif/mcp/server_http.py +54 -0
  56. sif/mcp/test_mcp.py +108 -0
  57. sif/mcp/tools.py +497 -0
  58. sif/mcp/transport_http.py +424 -0
  59. sif/mcp/transport_stdio.py +292 -0
  60. sif/mcp_server/__init__.py +26 -0
  61. sif/mcp_server/handlers.py +68 -0
  62. sif/mcp_server/server.py +58 -0
  63. sif/mcp_server/tools.py +216 -0
  64. sif/mcp_server/transport.py +221 -0
  65. sif/models/__init__.py +70 -0
  66. sif/models/collection.py +51 -0
  67. sif/models/context.py +45 -0
  68. sif/models/document.py +76 -0
  69. sif/models/download.py +208 -0
  70. sif/models/embedding.py +71 -0
  71. sif/models/search.py +92 -0
  72. sif/search/__init__.py +22 -0
  73. sif/search/benchmark.py +110 -0
  74. sif/search/bm25.py +216 -0
  75. sif/search/expansion.py +157 -0
  76. sif/search/hybrid.py +323 -0
  77. sif/search/rerank.py +390 -0
  78. sif/search/rrf.py +186 -0
  79. sif/search/snippets.py +130 -0
  80. sif/search/strategy.py +97 -0
  81. sif/search/vector.py +177 -0
  82. sif/utils/__init__.py +26 -0
  83. sif/utils/logging.py +143 -0
  84. sif/utils/paths.py +82 -0
  85. sif/utils/progress.py +83 -0
  86. 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() + "..."