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/hybrid.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Hybrid search combining BM25 and vector search with RRF."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from sif.core.models import Embedder, SearchOptions, SearchResult, SearchType
|
|
10
|
+
from sif.search.bm25 import BM25Searcher
|
|
11
|
+
from sif.search.rrf import RRFFusion
|
|
12
|
+
from sif.search.vector import VectorSearcher
|
|
13
|
+
from sif.utils.logging import get_logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from sif.search.expansion import QueryExpansion
|
|
18
|
+
from sif.search.rerank import CrossEncoderReranker, LlamaCppReranker
|
|
19
|
+
from sif.search.snippets import SmartSnippetExtractor
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HybridSearcher:
|
|
25
|
+
"""Hybrid search combining multiple search strategies."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
db: sqlite3.Connection,
|
|
30
|
+
embedder: Embedder | None = None,
|
|
31
|
+
embedding_dim: int = 768,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.db = db
|
|
34
|
+
self.embedder = embedder
|
|
35
|
+
self.bm25 = BM25Searcher(db)
|
|
36
|
+
self.vector = VectorSearcher(db, embedding_dim)
|
|
37
|
+
self.rrf = RRFFusion(k=60)
|
|
38
|
+
|
|
39
|
+
def search(
|
|
40
|
+
self,
|
|
41
|
+
query: str,
|
|
42
|
+
options: SearchOptions | None = None,
|
|
43
|
+
) -> list[SearchResult]:
|
|
44
|
+
"""Search using hybrid approach (BM25 + Vector + RRF).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
query: Search query
|
|
48
|
+
options: Search options
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Ranked list of search results
|
|
52
|
+
"""
|
|
53
|
+
if options is None:
|
|
54
|
+
options = SearchOptions()
|
|
55
|
+
|
|
56
|
+
# Get BM25 results
|
|
57
|
+
bm25_results = self.bm25.search(query, options)
|
|
58
|
+
|
|
59
|
+
# Get vector results if embedder is available
|
|
60
|
+
vector_results: list[SearchResult] = []
|
|
61
|
+
if self.embedder is not None:
|
|
62
|
+
query_embedding = self.embedder.embed(query)
|
|
63
|
+
vector_results = self.vector.search(query_embedding, options)
|
|
64
|
+
|
|
65
|
+
# If only one method returned results, return those
|
|
66
|
+
if not vector_results:
|
|
67
|
+
return self._attach_contexts(bm25_results)
|
|
68
|
+
if not bm25_results:
|
|
69
|
+
return self._attach_contexts(vector_results)
|
|
70
|
+
|
|
71
|
+
# Fuse results using RRF
|
|
72
|
+
fused_results = self.rrf.fuse([bm25_results, vector_results], options.limit)
|
|
73
|
+
|
|
74
|
+
# Re-fetch content and highlights if needed
|
|
75
|
+
if options.include_content or options.include_highlights:
|
|
76
|
+
for result in fused_results:
|
|
77
|
+
if options.include_content and result.content is None:
|
|
78
|
+
result.content = self._get_document_content(result.document_id)
|
|
79
|
+
if options.include_highlights and not result.highlights:
|
|
80
|
+
result.highlights = self._get_highlights(
|
|
81
|
+
result.document_id, query, options.max_highlights
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Deduplicate by document_id, keeping highest score
|
|
85
|
+
deduped = self._deduplicate_results(fused_results)
|
|
86
|
+
|
|
87
|
+
return self._attach_contexts(deduped)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _deduplicate_results(results: list[SearchResult]) -> list[SearchResult]:
|
|
91
|
+
"""Deduplicate results by document_id, keeping the highest score.
|
|
92
|
+
|
|
93
|
+
Vector search operates at chunk level, so the same document may appear
|
|
94
|
+
multiple times. We keep the best-scoring entry per document and
|
|
95
|
+
reassign sequential ranks.
|
|
96
|
+
"""
|
|
97
|
+
best: dict[str, SearchResult] = {}
|
|
98
|
+
for result in results:
|
|
99
|
+
doc_id = result.document_id
|
|
100
|
+
if doc_id not in best or result.score > best[doc_id].score:
|
|
101
|
+
best[doc_id] = result
|
|
102
|
+
|
|
103
|
+
deduped = list(best.values())
|
|
104
|
+
deduped.sort(key=lambda r: r.score, reverse=True)
|
|
105
|
+
for rank, result in enumerate(deduped, 1):
|
|
106
|
+
result.rank = rank
|
|
107
|
+
return deduped
|
|
108
|
+
|
|
109
|
+
def _attach_contexts(self, results: list[SearchResult]) -> list[SearchResult]:
|
|
110
|
+
"""Attach path context descriptions to search results via batch query."""
|
|
111
|
+
if not results:
|
|
112
|
+
return results
|
|
113
|
+
paths = list({r.path for r in results})
|
|
114
|
+
placeholders = ", ".join(["?"] * len(paths))
|
|
115
|
+
sql = f"""
|
|
116
|
+
SELECT target_id, content FROM contexts
|
|
117
|
+
WHERE context_type = 'path' AND target_id IN ({placeholders})
|
|
118
|
+
"""
|
|
119
|
+
cursor = self.db.execute(sql, paths)
|
|
120
|
+
rows = cursor.fetchall()
|
|
121
|
+
# Handle both sqlite3.Row (dict-like) and plain tuple rows
|
|
122
|
+
context_map: dict[str, str] = {}
|
|
123
|
+
for row in rows:
|
|
124
|
+
if hasattr(row, "keys") and callable(getattr(row, "keys", None)):
|
|
125
|
+
try:
|
|
126
|
+
context_map[os.path.realpath(row["target_id"])] = row["content"]
|
|
127
|
+
continue
|
|
128
|
+
except (KeyError, TypeError):
|
|
129
|
+
pass
|
|
130
|
+
# Fallback: assume tuple-like access (skip MagicMock rows in tests)
|
|
131
|
+
try:
|
|
132
|
+
key = row[0]
|
|
133
|
+
val = row[1]
|
|
134
|
+
if not isinstance(key, str):
|
|
135
|
+
continue
|
|
136
|
+
context_map[os.path.realpath(key)] = val
|
|
137
|
+
except (KeyError, IndexError, TypeError):
|
|
138
|
+
continue
|
|
139
|
+
for result in results:
|
|
140
|
+
normalized_path = os.path.realpath(result.path)
|
|
141
|
+
if normalized_path in context_map:
|
|
142
|
+
result.context_description = context_map[normalized_path]
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
def _get_document_content(self, document_id: str) -> str | None:
|
|
146
|
+
"""Get document content."""
|
|
147
|
+
cursor = self.db.execute("SELECT content FROM documents WHERE id = ?", (document_id,))
|
|
148
|
+
row = cursor.fetchone()
|
|
149
|
+
return row[0] if row else None
|
|
150
|
+
|
|
151
|
+
def _get_highlights(
|
|
152
|
+
self,
|
|
153
|
+
document_id: str,
|
|
154
|
+
query: str,
|
|
155
|
+
max_highlights: int = 3,
|
|
156
|
+
) -> list[str]:
|
|
157
|
+
"""Get highlighted snippets."""
|
|
158
|
+
return self.bm25._get_highlights(document_id, query, max_highlights) # noqa: SLF001
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SearchPipeline:
|
|
162
|
+
"""Complete search pipeline with prefix routing, expansion, and reranking."""
|
|
163
|
+
|
|
164
|
+
def __init__( # noqa: PLR0913
|
|
165
|
+
self,
|
|
166
|
+
db: sqlite3.Connection,
|
|
167
|
+
embedder: Embedder | None = None,
|
|
168
|
+
query_expander: QueryExpansion | None = None,
|
|
169
|
+
reranker: LlamaCppReranker | CrossEncoderReranker | None = None,
|
|
170
|
+
snippet_extractor: SmartSnippetExtractor | None = None,
|
|
171
|
+
embedding_dim: int = 768,
|
|
172
|
+
) -> None:
|
|
173
|
+
self.db = db
|
|
174
|
+
self.hybrid = HybridSearcher(db, embedder, embedding_dim)
|
|
175
|
+
self.query_expander = query_expander
|
|
176
|
+
self.reranker = reranker
|
|
177
|
+
self.snippet_extractor = snippet_extractor
|
|
178
|
+
|
|
179
|
+
def search( # noqa: C901, PLR0912
|
|
180
|
+
self,
|
|
181
|
+
query: str,
|
|
182
|
+
options: SearchOptions | None = None,
|
|
183
|
+
) -> list[SearchResult]:
|
|
184
|
+
"""Execute full search pipeline with prefix routing.
|
|
185
|
+
|
|
186
|
+
1. Parse query prefix to determine search mode
|
|
187
|
+
2. Route to appropriate search mode (BM25, vector, HyDE, hybrid)
|
|
188
|
+
3. Expand query (optional, for expand: prefix or default hybrid)
|
|
189
|
+
4. Search with each query variant
|
|
190
|
+
5. Fuse results with RRF
|
|
191
|
+
6. Apply reranking with candidate capping
|
|
192
|
+
7. Extract smart snippets
|
|
193
|
+
"""
|
|
194
|
+
if options is None:
|
|
195
|
+
options = SearchOptions()
|
|
196
|
+
|
|
197
|
+
# Parse query prefix
|
|
198
|
+
parsed_query, search_type = self._parse_query_prefix(query)
|
|
199
|
+
|
|
200
|
+
# Apply intent if provided
|
|
201
|
+
if options.intent:
|
|
202
|
+
parsed_query = f"{options.intent}: {parsed_query}"
|
|
203
|
+
|
|
204
|
+
# Route to appropriate search mode
|
|
205
|
+
if search_type == SearchType.BM25:
|
|
206
|
+
return self.hybrid.bm25.search(parsed_query, options)
|
|
207
|
+
if search_type == SearchType.VECTOR:
|
|
208
|
+
if self.hybrid.embedder is None:
|
|
209
|
+
raise RuntimeError("Vector search requires an embedder")
|
|
210
|
+
query_embedding = self.hybrid.embedder.embed(parsed_query)
|
|
211
|
+
return self.hybrid.vector.search(query_embedding, options)
|
|
212
|
+
if search_type == SearchType.HYDE:
|
|
213
|
+
hyde_doc = self._generate_hypothetical_document(parsed_query)
|
|
214
|
+
if self.hybrid.embedder is None:
|
|
215
|
+
raise RuntimeError("HyDE search requires an embedder")
|
|
216
|
+
hyde_embedding = self.hybrid.embedder.embed(hyde_doc)
|
|
217
|
+
return self.hybrid.vector.search(hyde_embedding, options)
|
|
218
|
+
|
|
219
|
+
# Default: hybrid search with optional expansion
|
|
220
|
+
queries = [parsed_query]
|
|
221
|
+
|
|
222
|
+
# Expand query if expander is available and prefix is expand: or default
|
|
223
|
+
if self.query_expander is not None and search_type in (
|
|
224
|
+
SearchType.HYBRID,
|
|
225
|
+
SearchType.EXPAND,
|
|
226
|
+
):
|
|
227
|
+
try:
|
|
228
|
+
expanded = self.query_expander.expand(parsed_query)
|
|
229
|
+
# Deduplicate: keep original first, then unique expanded variants
|
|
230
|
+
seen = {parsed_query.lower()}
|
|
231
|
+
for variant in expanded[1:]:
|
|
232
|
+
if variant.lower() not in seen:
|
|
233
|
+
seen.add(variant.lower())
|
|
234
|
+
queries.append(variant)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.warning(f"Query expansion failed: {e}")
|
|
237
|
+
|
|
238
|
+
# Search with each query
|
|
239
|
+
all_results = []
|
|
240
|
+
for q in queries:
|
|
241
|
+
results = self.hybrid.search(q, options)
|
|
242
|
+
if results:
|
|
243
|
+
all_results.append(results)
|
|
244
|
+
|
|
245
|
+
# If only one query or no expansion, return results
|
|
246
|
+
if len(all_results) <= 1:
|
|
247
|
+
results = all_results[0] if all_results else []
|
|
248
|
+
else:
|
|
249
|
+
# Fuse results from multiple queries
|
|
250
|
+
results = self.hybrid.rrf.fuse(all_results, options.limit)
|
|
251
|
+
|
|
252
|
+
# Apply candidate limit before reranking
|
|
253
|
+
if self.reranker is not None and len(results) > 0:
|
|
254
|
+
candidates = results[: options.candidate_limit]
|
|
255
|
+
try:
|
|
256
|
+
reranked = self.reranker.rerank(parsed_query, candidates, top_k=options.limit)
|
|
257
|
+
results = reranked
|
|
258
|
+
except Exception as e:
|
|
259
|
+
raise RuntimeError(f"Reranking failed: {e}") from e
|
|
260
|
+
|
|
261
|
+
# Extract smart snippets if not already present
|
|
262
|
+
if self.snippet_extractor is not None:
|
|
263
|
+
for result in results:
|
|
264
|
+
if result.snippet is None and result.content:
|
|
265
|
+
query_terms = parsed_query.lower().split()
|
|
266
|
+
result.snippet = self.snippet_extractor.extract(result.content, query_terms)
|
|
267
|
+
|
|
268
|
+
return self.hybrid._attach_contexts(results) # noqa: SLF001
|
|
269
|
+
|
|
270
|
+
def _parse_query_prefix(self, query: str) -> tuple[str, SearchType]:
|
|
271
|
+
"""Parse query prefix to determine search mode."""
|
|
272
|
+
PREFIX_MAP = {
|
|
273
|
+
"lex:": SearchType.BM25,
|
|
274
|
+
"vec:": SearchType.VECTOR,
|
|
275
|
+
"hyde:": SearchType.HYDE,
|
|
276
|
+
"expand:": SearchType.EXPAND,
|
|
277
|
+
}
|
|
278
|
+
for prefix, search_type in PREFIX_MAP.items():
|
|
279
|
+
if query.startswith(prefix):
|
|
280
|
+
return query[len(prefix) :].strip(), search_type
|
|
281
|
+
return query, SearchType.HYBRID
|
|
282
|
+
|
|
283
|
+
def _generate_hypothetical_document(self, query: str) -> str:
|
|
284
|
+
"""Generate a hypothetical ideal answer document for HyDE search.
|
|
285
|
+
|
|
286
|
+
Uses a lightweight prompt template approach. If the embedder supports text
|
|
287
|
+
generation (e.g., GGUF models with .generate() or .create_completion()),
|
|
288
|
+
it generates a short answer. Otherwise raises RuntimeError since HyDE
|
|
289
|
+
requires text generation capability.
|
|
290
|
+
"""
|
|
291
|
+
if self.hybrid.embedder is None:
|
|
292
|
+
raise RuntimeError("HyDE search requires an embedder")
|
|
293
|
+
|
|
294
|
+
# Check if embedder has generate capability
|
|
295
|
+
if hasattr(self.hybrid.embedder, "generate"):
|
|
296
|
+
prompt = (
|
|
297
|
+
f"Answer the following question concisely and directly. "
|
|
298
|
+
f"Provide only the answer, no preamble.\n\n"
|
|
299
|
+
f"Question: {query}\n\nAnswer:"
|
|
300
|
+
)
|
|
301
|
+
return self.hybrid.embedder.generate(prompt)
|
|
302
|
+
|
|
303
|
+
# Check for llama-cpp style completion API
|
|
304
|
+
if hasattr(self.hybrid.embedder, "create_completion"):
|
|
305
|
+
prompt = (
|
|
306
|
+
f"Answer the following question concisely and directly. "
|
|
307
|
+
f"Provide only the answer, no preamble.\n\n"
|
|
308
|
+
f"Question: {query}\n\nAnswer:"
|
|
309
|
+
)
|
|
310
|
+
result = self.hybrid.embedder.create_completion(
|
|
311
|
+
prompt,
|
|
312
|
+
max_tokens=256,
|
|
313
|
+
temperature=0.3,
|
|
314
|
+
stop=["\n\n"],
|
|
315
|
+
)
|
|
316
|
+
return result["choices"][0]["text"].strip()
|
|
317
|
+
|
|
318
|
+
# No text generation capability available
|
|
319
|
+
raise RuntimeError(
|
|
320
|
+
"HyDE search requires a text-generation-capable model (e.g., GGUF). "
|
|
321
|
+
"The current embedder does not support .generate() or .create_completion(). "
|
|
322
|
+
"Use vec: prefix for vector-only search instead."
|
|
323
|
+
)
|