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/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
+ )