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/cli/commands/pull.py
ADDED
|
@@ -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]")
|