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
@@ -0,0 +1,111 @@
1
+ """Model download command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from urllib import request
7
+
8
+ import click
9
+ from rich.console import Console
10
+
11
+
12
+ try:
13
+ from huggingface_hub import hf_hub_download
14
+ except ImportError: # pragma: no cover
15
+ hf_hub_download = None # type: ignore[misc,assignment]
16
+
17
+ try:
18
+ from modelscope import snapshot_download
19
+ except ImportError: # pragma: no cover
20
+ snapshot_download = None # type: ignore[misc,assignment]
21
+
22
+ console = Console()
23
+
24
+ _MIN_SPEC_PARTS = 3
25
+
26
+
27
+ def _download_from_url(model_spec: str, target_cache: Path) -> Path:
28
+ local_name = Path(model_spec).name
29
+ if not local_name:
30
+ raise click.ClickException("Could not determine filename from URL")
31
+ downloaded = target_cache / local_name
32
+ console.print(f"[dim]Downloading from URL: {model_spec}[/dim]")
33
+ try:
34
+ request.urlretrieve(model_spec, downloaded)
35
+ except Exception as e:
36
+ raise click.ClickException(f"Download failed: {e}") from e
37
+ return downloaded
38
+
39
+
40
+ def _download_from_hf(repo_id: str, filename: str, target_cache: Path) -> Path:
41
+ if hf_hub_download is None:
42
+ raise ImportError("huggingface_hub not installed")
43
+
44
+ console.print(f"[dim]Downloading from HuggingFace: {repo_id}/{filename}[/dim]")
45
+ path = hf_hub_download(
46
+ repo_id=repo_id,
47
+ filename=filename,
48
+ cache_dir=str(target_cache),
49
+ local_files_only=False,
50
+ )
51
+ return Path(path)
52
+
53
+
54
+ def _download_from_modelscope(repo_id: str, filename: str, target_cache: Path) -> Path:
55
+ if snapshot_download is None:
56
+ raise ImportError("modelscope not installed")
57
+
58
+ console.print("[dim]Trying ModelScope fallback...[/dim]")
59
+ model_path = snapshot_download(repo_id, cache_dir=str(target_cache))
60
+ return Path(model_path) / filename
61
+
62
+
63
+ def _download_model(model_spec: str, target_cache: Path) -> Path:
64
+ if model_spec.startswith(("http://", "https://")):
65
+ return _download_from_url(model_spec, target_cache)
66
+
67
+ parts = model_spec.split("/")
68
+ if len(parts) < _MIN_SPEC_PARTS:
69
+ raise click.ClickException("Model spec must be owner/repo/filename.gguf or a direct URL")
70
+ repo_id = "/".join(parts[:-1])
71
+ filename = parts[-1]
72
+
73
+ try:
74
+ return _download_from_hf(repo_id, filename, target_cache)
75
+ except Exception as hf_err:
76
+ console.print(f"[yellow]HuggingFace failed: {hf_err}[/yellow]")
77
+ try:
78
+ return _download_from_modelscope(repo_id, filename, target_cache)
79
+ except ImportError:
80
+ raise click.ClickException(
81
+ f"HuggingFace failed: {hf_err}. ModelScope not installed."
82
+ ) from hf_err
83
+ except Exception as ms_err:
84
+ raise click.ClickException(f"Download failed: HF={hf_err}, MS={ms_err}") from ms_err
85
+
86
+
87
+ @click.command("pull")
88
+ @click.argument("model_spec")
89
+ @click.option(
90
+ "--cache-dir",
91
+ type=click.Path(),
92
+ help="Custom cache directory",
93
+ )
94
+ @click.pass_context
95
+ def pull_cmd(
96
+ _ctx: click.Context,
97
+ model_spec: str,
98
+ cache_dir: str | None,
99
+ ) -> None:
100
+ """Download a GGUF model file."""
101
+ target_cache = (
102
+ Path(cache_dir).expanduser().resolve() if cache_dir else Path.home() / ".sif" / "models"
103
+ )
104
+ target_cache.mkdir(parents=True, exist_ok=True)
105
+
106
+ downloaded = _download_model(model_spec, target_cache)
107
+
108
+ if not downloaded.exists() or downloaded.stat().st_size == 0:
109
+ raise click.ClickException("Downloaded file is missing or empty.")
110
+
111
+ console.print(f"[green]Downloaded to {downloaded}[/green]")
@@ -0,0 +1,574 @@
1
+ """Search commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from sif.cli.formatters import add_line_numbers_to_results, prepend_line_numbers
12
+ from sif.core.models import SearchOptions
13
+ from sif.database.database import Database
14
+ from sif.database.repositories import CollectionRepository
15
+ from sif.search.bm25 import BM25Searcher
16
+ from sif.search.hybrid import SearchPipeline
17
+ from sif.utils.logging import set_quiet
18
+
19
+
20
+ console = Console()
21
+
22
+
23
+ def format_results_json(results: list) -> str:
24
+ """Format results as JSON."""
25
+ return json.dumps(
26
+ [r.to_dict() if hasattr(r, "to_dict") else r for r in results],
27
+ indent=2,
28
+ ensure_ascii=False,
29
+ )
30
+
31
+
32
+ def format_results_csv(results: list) -> str:
33
+ """Format results as CSV."""
34
+ lines = ["rank,score,title,path,collection"]
35
+ lines.extend(
36
+ f'"{r.rank}","{r.score:.4f}","{r.title}","{r.path}","{r.collection_name}"' for r in results
37
+ )
38
+ return "\n".join(lines)
39
+
40
+
41
+ def format_results_md(results: list, query: str) -> str:
42
+ """Format results as Markdown."""
43
+ lines = [f"# Search Results: {query}", ""]
44
+ for r in results:
45
+ lines.append(f"## {r.rank}. {r.title}")
46
+ lines.append(f"- **Score:** {r.score:.4f}")
47
+ lines.append(f"- **Path:** `{r.path}`")
48
+ lines.append(f"- **Collection:** {r.collection_name}")
49
+ if hasattr(r, "get") and r.get("line_numbers"):
50
+ lines.append(f"- **Line Numbers:** `{r.get('line_numbers')}`")
51
+ elif hasattr(r, "line_numbers") and r.line_numbers:
52
+ lines.append(f"- **Line Numbers:** `{r.line_numbers}`")
53
+ if hasattr(r, "highlights") and r.highlights:
54
+ lines.append("")
55
+ lines.append("> " + "\n> ".join(r.highlights[:3]))
56
+ lines.append("")
57
+ return "\n".join(lines)
58
+
59
+
60
+ def format_results_xml(results: list, query: str) -> str:
61
+ """Format results as XML."""
62
+ lines = ['<?xml version="1.0" encoding="UTF-8"?>', f'<search query="{query}">']
63
+ for r in results:
64
+ lines.append(f' <result rank="{r.rank}" score="{r.score:.4f}">')
65
+ lines.append(f" <title>{r.title}</title>")
66
+ lines.append(f" <path>{r.path}</path>")
67
+ lines.append(f" <collection>{r.collection_name}</collection>")
68
+ if hasattr(r, "get") and r.get("line_numbers"):
69
+ lines.append(f" <line_numbers>{r.get('line_numbers')}</line_numbers>")
70
+ elif hasattr(r, "line_numbers") and r.line_numbers:
71
+ lines.append(f" <line_numbers>{r.line_numbers}</line_numbers>")
72
+ lines.append(" </result>")
73
+ lines.append("</search>")
74
+ return "\n".join(lines)
75
+
76
+
77
+ @click.group("search")
78
+ def search_group() -> None:
79
+ """Search commands."""
80
+ pass
81
+
82
+
83
+ @search_group.command("search")
84
+ @click.argument("query")
85
+ @click.option("-n", "--limit", default=10, help="Number of results")
86
+ @click.option("-c", "--collection", multiple=True, help="Collection to search")
87
+ @click.option("--all", "search_all", is_flag=True, help="Search all collections")
88
+ @click.option("--min-score", default=0.0, help="Minimum score threshold")
89
+ @click.option("--full", is_flag=True, help="Include full content")
90
+ @click.option("--explain", is_flag=True, help="Show search explanation")
91
+ @click.option("--line-numbers", is_flag=True, help="Show line numbers in content")
92
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
93
+ @click.option("--csv", "output_csv", is_flag=True, help="Output as CSV")
94
+ @click.option("--md", "output_md", is_flag=True, help="Output as Markdown")
95
+ @click.option("--xml", "output_xml", is_flag=True, help="Output as XML")
96
+ @click.option("--files", "output_files", is_flag=True, help="Output file paths only")
97
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress non-error output")
98
+ @click.pass_context
99
+ def search_cmd( # noqa: C901, PLR0912, PLR0913, PLR0915
100
+ ctx: click.Context,
101
+ query: str,
102
+ limit: int,
103
+ collection: tuple,
104
+ search_all: bool,
105
+ min_score: float,
106
+ full: bool,
107
+ explain: bool,
108
+ line_numbers: bool,
109
+ output_json: bool,
110
+ output_csv: bool,
111
+ output_md: bool,
112
+ output_xml: bool,
113
+ output_files: bool,
114
+ quiet: bool,
115
+ ) -> None:
116
+ """Search documents using BM25."""
117
+ index_path = ctx.obj["index_path"]
118
+ quiet = quiet or ctx.obj.get("quiet", False)
119
+ if quiet:
120
+ set_quiet(True)
121
+
122
+ if not index_path.exists():
123
+ if not quiet:
124
+ console.print("[yellow]No index found. Run 'sif update' first.[/yellow]")
125
+ return
126
+
127
+ db = Database(index_path)
128
+ db.init_schema()
129
+
130
+ # Build options
131
+ options = SearchOptions(
132
+ limit=limit,
133
+ min_score=min_score,
134
+ include_content=full,
135
+ include_highlights=True,
136
+ )
137
+
138
+ # Get collection IDs if specified
139
+ if collection:
140
+ with db.connection:
141
+ repo = CollectionRepository(db.connection)
142
+ options.collection_ids = []
143
+ for name in collection:
144
+ coll = repo.get_by_name(name)
145
+ if coll:
146
+ options.collection_ids.append(coll.id)
147
+ elif not search_all:
148
+ with db.connection:
149
+ repo = CollectionRepository(db.connection)
150
+ enabled = repo.list_enabled()
151
+ options.collection_ids = [c.id for c in enabled]
152
+
153
+ # Search
154
+ with db.connection:
155
+ searcher = BM25Searcher(db.connection)
156
+ results = searcher.search(query, options)
157
+
158
+ # Output results
159
+ quiet = quiet or ctx.obj.get("quiet", False)
160
+ if output_files or (quiet and not any([output_json, output_csv, output_md, output_xml])):
161
+ for r in results:
162
+ console.print(r.path)
163
+ elif output_json:
164
+ console.print(
165
+ format_results_json(
166
+ add_line_numbers_to_results([r.to_dict() for r in results])
167
+ if line_numbers
168
+ else results
169
+ )
170
+ )
171
+ elif output_csv:
172
+ console.print(
173
+ format_results_csv(
174
+ add_line_numbers_to_results([r.to_dict() for r in results])
175
+ if line_numbers
176
+ else results
177
+ )
178
+ )
179
+ elif output_md:
180
+ console.print(
181
+ format_results_md(
182
+ add_line_numbers_to_results([r.to_dict() for r in results])
183
+ if line_numbers
184
+ else results,
185
+ query,
186
+ )
187
+ )
188
+ elif output_xml:
189
+ console.print(
190
+ format_results_xml(
191
+ add_line_numbers_to_results([r.to_dict() for r in results])
192
+ if line_numbers
193
+ else results,
194
+ query,
195
+ )
196
+ )
197
+ else:
198
+ # Rich table output
199
+ if not results:
200
+ if not quiet:
201
+ console.print("[yellow]No results found.[/yellow]")
202
+ return
203
+
204
+ table = Table(title=f'Search Results: "{query}"')
205
+ table.add_column("#", style="cyan", justify="right")
206
+ table.add_column("Score", style="green", justify="right")
207
+ table.add_column("Title", style="yellow")
208
+ table.add_column("Collection", style="blue")
209
+ if line_numbers and any(getattr(r, "content", None) for r in results):
210
+ table.add_column("Content", style="white")
211
+
212
+ for r in results:
213
+ row = [
214
+ str(r.rank),
215
+ f"{r.score:.4f}",
216
+ r.title[:50] + "..." if len(r.title) > 50 else r.title,
217
+ r.collection_name,
218
+ ]
219
+ if line_numbers and getattr(r, "content", None):
220
+ content = prepend_line_numbers(r.content)
221
+ content = content[:200] + "..." if len(content) > 200 else content
222
+ row.append(content)
223
+ table.add_row(*row)
224
+
225
+ console.print(table)
226
+
227
+ if explain:
228
+ console.print(f"\n[dim]Query: {query}[/dim]")
229
+ console.print(f"[dim]Results: {len(results)}[/dim]")
230
+
231
+
232
+ @search_group.command("vsearch")
233
+ @click.argument("query")
234
+ @click.option("-n", "--limit", default=10, help="Number of results")
235
+ @click.option("-c", "--collection", multiple=True, help="Collection to search")
236
+ @click.option("--all", "search_all", is_flag=True, help="Search all collections")
237
+ @click.option("--min-score", default=0.0, help="Minimum score threshold")
238
+ @click.option("--full", is_flag=True, help="Include full content")
239
+ @click.option("--line-numbers", is_flag=True, help="Show line numbers in content")
240
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
241
+ @click.option(
242
+ "--model-type",
243
+ type=click.Choice(["sentence_transformers", "gguf", "openai", "modelscope"]),
244
+ help="Embedding model type override",
245
+ )
246
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress non-error output")
247
+ @click.pass_context
248
+ def vsearch_cmd( # noqa: C901, PLR0912, PLR0913, PLR0915
249
+ ctx: click.Context,
250
+ query: str,
251
+ limit: int,
252
+ collection: tuple,
253
+ search_all: bool,
254
+ min_score: float,
255
+ full: bool,
256
+ line_numbers: bool,
257
+ output_json: bool,
258
+ model_type: str | None = None,
259
+ quiet: bool = False,
260
+ ) -> None:
261
+ """Search documents using vector similarity."""
262
+ index_path = ctx.obj["index_path"]
263
+ quiet = quiet or ctx.obj.get("quiet", False)
264
+ if quiet:
265
+ set_quiet(True)
266
+
267
+ if not index_path.exists():
268
+ if not quiet:
269
+ console.print(
270
+ "[yellow]No index found. Run 'sif update' and 'sif embed' first.[/yellow]"
271
+ )
272
+ return
273
+
274
+ from sif.config.settings import get_settings # noqa: PLC0415
275
+ from sif.database.database import Database # noqa: PLC0415
276
+ from sif.database.repositories import CollectionRepository # noqa: PLC0415
277
+ from sif.embedding.manager import EmbeddingManager # noqa: PLC0415
278
+ from sif.search.vector import VectorSearcher # noqa: PLC0415
279
+
280
+ settings = get_settings()
281
+ if model_type:
282
+ settings = settings.model_copy(update={"model_type": model_type})
283
+
284
+ try:
285
+ manager = EmbeddingManager.from_settings(settings)
286
+ query_embedding = manager.embed_single(query)
287
+ except ImportError as e:
288
+ raise click.ClickException(f"Embedding backend not installed: {e}") from None
289
+ except Exception as e:
290
+ raise click.ClickException(f"Failed to load embedding model: {e}") from e
291
+
292
+ db = Database(index_path)
293
+ db.init_schema()
294
+
295
+ options = SearchOptions(
296
+ limit=limit,
297
+ min_score=min_score,
298
+ include_content=full,
299
+ include_highlights=True,
300
+ )
301
+
302
+ if collection:
303
+ with db.connection:
304
+ repo = CollectionRepository(db.connection)
305
+ options.collection_ids = []
306
+ for name in collection:
307
+ coll = repo.get_by_name(name)
308
+ if coll:
309
+ options.collection_ids.append(coll.id)
310
+ elif not search_all:
311
+ with db.connection:
312
+ repo = CollectionRepository(db.connection)
313
+ enabled = repo.list_enabled()
314
+ options.collection_ids = [c.id for c in enabled]
315
+
316
+ with db.connection:
317
+ searcher = VectorSearcher(db.connection, len(query_embedding))
318
+ results = searcher.search(query_embedding, options)
319
+
320
+ quiet = quiet or ctx.obj.get("quiet", False)
321
+ if quiet and not output_json:
322
+ for r in results:
323
+ console.print(r.path)
324
+ elif output_json:
325
+ console.print(
326
+ format_results_json(
327
+ add_line_numbers_to_results([r.to_dict() for r in results])
328
+ if line_numbers
329
+ else results
330
+ )
331
+ )
332
+ else:
333
+ if not results:
334
+ if not quiet:
335
+ console.print("[yellow]No results found.[/yellow]")
336
+ return
337
+
338
+ table = Table(title=f'Vector Search Results: "{query}"')
339
+ table.add_column("#", style="cyan", justify="right")
340
+ table.add_column("Score", style="green", justify="right")
341
+ table.add_column("Title", style="yellow")
342
+ table.add_column("Collection", style="blue")
343
+ if line_numbers and any(getattr(r, "content", None) for r in results):
344
+ table.add_column("Content", style="white")
345
+
346
+ for r in results:
347
+ row = [
348
+ str(r.rank),
349
+ f"{r.score:.4f}",
350
+ r.title[:50] + "..." if len(r.title) > 50 else r.title,
351
+ r.collection_name,
352
+ ]
353
+ if line_numbers and getattr(r, "content", None):
354
+ content = prepend_line_numbers(r.content)
355
+ content = content[:200] + "..." if len(content) > 200 else content
356
+ row.append(content)
357
+ table.add_row(*row)
358
+
359
+ console.print(table)
360
+
361
+
362
+ @search_group.command("query")
363
+ @click.argument("query")
364
+ @click.option("-n", "--limit", default=10, help="Number of results")
365
+ @click.option("-c", "--collection", multiple=True, help="Collection to search")
366
+ @click.option("--all", "search_all", is_flag=True, help="Search all collections")
367
+ @click.option("--min-score", default=0.0, help="Minimum score threshold")
368
+ @click.option("--full", is_flag=True, help="Include full content")
369
+ @click.option("--explain", is_flag=True, help="Show score breakdowns across pipeline stages")
370
+ @click.option(
371
+ "-C",
372
+ "--candidate-limit",
373
+ default=20,
374
+ type=click.IntRange(1, 200),
375
+ help="Reranker candidate pool size",
376
+ )
377
+ @click.option("--intent", help="Search intent hint for query expansion")
378
+ @click.option("--line-numbers", is_flag=True, help="Show line numbers in content")
379
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
380
+ @click.option("--csv", "output_csv", is_flag=True, help="Output as CSV")
381
+ @click.option("--md", "output_md", is_flag=True, help="Output as Markdown")
382
+ @click.option("--xml", "output_xml", is_flag=True, help="Output as XML")
383
+ @click.option("--files", "output_files", is_flag=True, help="Output file paths only")
384
+ @click.option(
385
+ "--model-type",
386
+ type=click.Choice(["sentence_transformers", "gguf", "openai", "modelscope"]),
387
+ help="Embedding model type override",
388
+ )
389
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress non-error output")
390
+ @click.pass_context
391
+ def query_cmd( # noqa: C901, PLR0912, PLR0913, PLR0915
392
+ ctx: click.Context,
393
+ query: str,
394
+ limit: int,
395
+ collection: tuple,
396
+ search_all: bool,
397
+ min_score: float,
398
+ full: bool,
399
+ explain: bool,
400
+ candidate_limit: int,
401
+ intent: str | None,
402
+ line_numbers: bool,
403
+ output_json: bool,
404
+ output_csv: bool,
405
+ output_md: bool,
406
+ output_xml: bool,
407
+ output_files: bool,
408
+ model_type: str | None = None,
409
+ quiet: bool = False,
410
+ ) -> None:
411
+ """Search documents using hybrid approach (BM25 + Vector + RRF).
412
+
413
+ This is the recommended search command for best results.
414
+ """
415
+ index_path = ctx.obj["index_path"]
416
+ quiet = quiet or ctx.obj.get("quiet", False)
417
+ if quiet:
418
+ set_quiet(True)
419
+
420
+ if not index_path.exists():
421
+ if not quiet:
422
+ console.print("[yellow]No index found. Run 'sif update' first.[/yellow]")
423
+ return
424
+
425
+ db = Database(index_path)
426
+ db.init_schema()
427
+
428
+ # Build options
429
+ options = SearchOptions(
430
+ limit=limit,
431
+ min_score=min_score,
432
+ include_content=full,
433
+ include_highlights=True,
434
+ explain=explain,
435
+ candidate_limit=candidate_limit,
436
+ intent=intent,
437
+ )
438
+
439
+ # Get collection IDs if specified
440
+ if collection:
441
+ with db.connection:
442
+ repo = CollectionRepository(db.connection)
443
+ options.collection_ids = []
444
+ for name in collection:
445
+ coll = repo.get_by_name(name)
446
+ if coll:
447
+ options.collection_ids.append(coll.id)
448
+ elif not search_all:
449
+ with db.connection:
450
+ repo = CollectionRepository(db.connection)
451
+ enabled = repo.list_enabled()
452
+ options.collection_ids = [c.id for c in enabled]
453
+
454
+ # Load embedder for hybrid search
455
+ from sif.config.settings import get_settings # noqa: PLC0415
456
+ from sif.embedding.manager import EmbeddingManager # noqa: PLC0415
457
+ from sif.search.expansion import QueryExpansion # noqa: PLC0415
458
+ from sif.search.rerank import create_reranker # noqa: PLC0415
459
+ from sif.search.snippets import SmartSnippetExtractor # noqa: PLC0415
460
+
461
+ settings = get_settings()
462
+ if model_type:
463
+ settings = settings.model_copy(update={"model_type": model_type})
464
+
465
+ try:
466
+ manager = EmbeddingManager.from_settings(settings)
467
+ manager.load_model()
468
+ embedding_dim = len(manager.embed_single("probe"))
469
+ except ImportError as e:
470
+ raise click.ClickException(f"Embedding backend not installed: {e}") from None
471
+ except Exception as e:
472
+ raise click.ClickException(f"Failed to load embedding model: {e}") from e
473
+ # Create optional components
474
+ query_expander = QueryExpansion(embedding_manager=manager)
475
+
476
+ reranker = None
477
+ if settings.reranker_model_name or settings.reranker_model_path:
478
+ try:
479
+ reranker = create_reranker(settings)
480
+ except ImportError:
481
+ if not quiet:
482
+ console.print(
483
+ "[yellow]Reranker not available. "
484
+ "Install with: pip install -e '.[embed]'[/yellow]"
485
+ )
486
+
487
+ snippet_extractor = SmartSnippetExtractor(max_length=options.snippet_max_length)
488
+
489
+ # Search using pipeline
490
+ with db.connection:
491
+ pipeline = SearchPipeline(
492
+ db.connection,
493
+ embedder=manager._model, # noqa: SLF001
494
+ query_expander=query_expander,
495
+ reranker=reranker,
496
+ snippet_extractor=snippet_extractor,
497
+ embedding_dim=embedding_dim,
498
+ )
499
+ results = pipeline.search(query, options)
500
+
501
+ # Output results
502
+ quiet = quiet or ctx.obj.get("quiet", False)
503
+ if output_files or (quiet and not any([output_json, output_csv, output_md, output_xml])):
504
+ for r in results:
505
+ console.print(r.path)
506
+ elif output_json:
507
+ console.print(
508
+ format_results_json(
509
+ add_line_numbers_to_results([r.to_dict() for r in results])
510
+ if line_numbers
511
+ else results
512
+ )
513
+ )
514
+ elif output_csv:
515
+ console.print(
516
+ format_results_csv(
517
+ add_line_numbers_to_results([r.to_dict() for r in results])
518
+ if line_numbers
519
+ else results
520
+ )
521
+ )
522
+ elif output_md:
523
+ console.print(
524
+ format_results_md(
525
+ add_line_numbers_to_results([r.to_dict() for r in results])
526
+ if line_numbers
527
+ else results,
528
+ query,
529
+ )
530
+ )
531
+ elif output_xml:
532
+ console.print(
533
+ format_results_xml(
534
+ add_line_numbers_to_results([r.to_dict() for r in results])
535
+ if line_numbers
536
+ else results,
537
+ query,
538
+ )
539
+ )
540
+ else:
541
+ # Rich table output
542
+ if not results:
543
+ if not quiet:
544
+ console.print("[yellow]No results found.[/yellow]")
545
+ return
546
+
547
+ table = Table(title=f'Hybrid Search Results: "{query}"')
548
+ table.add_column("#", style="cyan", justify="right")
549
+ table.add_column("Score", style="green", justify="right")
550
+ table.add_column("Title", style="yellow")
551
+ table.add_column("Collection", style="blue")
552
+ if line_numbers and any(getattr(r, "content", None) for r in results):
553
+ table.add_column("Content", style="white")
554
+
555
+ for r in results:
556
+ row = [
557
+ str(r.rank),
558
+ f"{r.score:.4f}",
559
+ r.title[:50] + "..." if len(r.title) > 50 else r.title,
560
+ r.collection_name,
561
+ ]
562
+ if line_numbers and getattr(r, "content", None):
563
+ content = prepend_line_numbers(r.content)
564
+ content = content[:200] + "..." if len(content) > 200 else content
565
+ row.append(content)
566
+ table.add_row(*row)
567
+
568
+ console.print(table)
569
+
570
+ if explain:
571
+ for r in results:
572
+ scores_str = ", ".join(f"{k}={v:.4f}" for k, v in r.scores.items() if v is not None)
573
+ if scores_str:
574
+ console.print(f"[dim]{r.title}: {scores_str}[/dim]")