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/models/embedding.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Pydantic models for Embedding operations."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ModelType(str, Enum):
|
|
9
|
+
"""Type of embedding model."""
|
|
10
|
+
|
|
11
|
+
GGUF = "gguf"
|
|
12
|
+
SENTENCE_TRANSFORMERS = "sentence_transformers"
|
|
13
|
+
OPENAI = "openai"
|
|
14
|
+
HUGGINGFACE = "huggingface"
|
|
15
|
+
MODELSCOPE = "modelscope"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmbeddingConfig(BaseModel):
|
|
19
|
+
"""Configuration for embedding models."""
|
|
20
|
+
|
|
21
|
+
model_type: ModelType = Field(ModelType.MODELSCOPE, description="Model type")
|
|
22
|
+
model_path: str | None = Field(None, description="Path to model file")
|
|
23
|
+
model_name: str = Field("Qwen/Qwen3-Embedding-0.6B", description="Model name or identifier")
|
|
24
|
+
|
|
25
|
+
# Model parameters
|
|
26
|
+
embedding_dim: int = Field(1024, ge=1, description="Embedding dimension")
|
|
27
|
+
max_tokens: int = Field(512, ge=1, description="Maximum tokens per input")
|
|
28
|
+
batch_size: int = Field(32, ge=1, description="Batch size for inference")
|
|
29
|
+
|
|
30
|
+
# GGUF specific
|
|
31
|
+
n_gpu_layers: int = Field(0, ge=0, description="Number of GPU layers")
|
|
32
|
+
n_ctx: int = Field(2048, ge=512, description="Context size")
|
|
33
|
+
|
|
34
|
+
# API keys (for remote models)
|
|
35
|
+
api_key: str | None = Field(None, exclude=True)
|
|
36
|
+
api_base: str | None = None
|
|
37
|
+
|
|
38
|
+
# Caching
|
|
39
|
+
cache_embeddings: bool = Field(True, description="Cache embeddings")
|
|
40
|
+
cache_dir: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EmbeddingModelInfo(BaseModel):
|
|
44
|
+
"""Information about an embedding model."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(from_attributes=True)
|
|
47
|
+
|
|
48
|
+
id: str = Field(..., description="Model ID")
|
|
49
|
+
name: str = Field(..., description="Model name")
|
|
50
|
+
model_type: ModelType = Field(...)
|
|
51
|
+
embedding_dim: int = Field(..., ge=1)
|
|
52
|
+
max_tokens: int = Field(..., ge=1)
|
|
53
|
+
loaded: bool = Field(False, description="Whether model is loaded")
|
|
54
|
+
device: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EmbeddingRequest(BaseModel):
|
|
58
|
+
"""Request for generating embeddings."""
|
|
59
|
+
|
|
60
|
+
texts: list[str] = Field(..., min_length=1, description="Texts to embed")
|
|
61
|
+
normalize: bool = Field(True, description="Normalize embeddings")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class EmbeddingResponse(BaseModel):
|
|
65
|
+
"""Response with generated embeddings."""
|
|
66
|
+
|
|
67
|
+
embeddings: list[list[float]] = Field(..., description="Generated embeddings")
|
|
68
|
+
model_id: str = Field(..., description="Model used")
|
|
69
|
+
dimensions: int = Field(..., ge=1)
|
|
70
|
+
total_tokens: int = Field(..., ge=0)
|
|
71
|
+
processing_time_ms: float = Field(..., ge=0)
|
sif/models/search.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Pydantic models for Search operations."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SearchType(str, Enum):
|
|
10
|
+
"""Type of search to perform."""
|
|
11
|
+
|
|
12
|
+
BM25 = "bm25"
|
|
13
|
+
VECTOR = "vector"
|
|
14
|
+
HYBRID = "hybrid"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SearchOptions(BaseModel):
|
|
18
|
+
"""Options for search queries."""
|
|
19
|
+
|
|
20
|
+
limit: int = Field(10, ge=1, le=100, description="Maximum results")
|
|
21
|
+
offset: int = Field(0, ge=0, description="Result offset")
|
|
22
|
+
threshold: float = Field(0.0, ge=0, le=1, description="Minimum score threshold")
|
|
23
|
+
include_chunks: bool = Field(True, description="Include matched chunks")
|
|
24
|
+
include_metadata: bool = Field(True, description="Include document metadata")
|
|
25
|
+
highlight_matches: bool = Field(True, description="Highlight matching terms")
|
|
26
|
+
|
|
27
|
+
# BM25 options
|
|
28
|
+
bm25_k1: float = Field(1.5, ge=0.1, le=3.0)
|
|
29
|
+
bm25_b: float = Field(0.75, ge=0.1, le=1.0)
|
|
30
|
+
|
|
31
|
+
# Vector options
|
|
32
|
+
vector_weight: float = Field(0.7, ge=0, le=1)
|
|
33
|
+
|
|
34
|
+
# Hybrid options
|
|
35
|
+
bm25_weight: float = Field(0.3, ge=0, le=1)
|
|
36
|
+
rrf_k: int = Field(60, ge=1, description="RRF fusion parameter")
|
|
37
|
+
|
|
38
|
+
# Query expansion
|
|
39
|
+
expand_query: bool = Field(False, description="Enable query expansion")
|
|
40
|
+
expansion_factor: int = Field(3, ge=1, le=10)
|
|
41
|
+
|
|
42
|
+
# Reranking
|
|
43
|
+
rerank: bool = Field(False, description="Enable reranking")
|
|
44
|
+
rerank_top_k: int = Field(20, ge=1, le=100)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SearchQuery(BaseModel):
|
|
48
|
+
"""Model for search query."""
|
|
49
|
+
|
|
50
|
+
query: str = Field(..., min_length=1, description="Search query")
|
|
51
|
+
collection_ids: list[str] | None = Field(None, description="Filter by collections")
|
|
52
|
+
search_type: SearchType = Field(SearchType.HYBRID, description="Search type")
|
|
53
|
+
options: SearchOptions = Field(default_factory=SearchOptions)
|
|
54
|
+
filters: dict[str, Any] = Field(default_factory=dict, description="Additional filters")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SearchResult(BaseModel):
|
|
58
|
+
"""Model for individual search result."""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(from_attributes=True)
|
|
61
|
+
|
|
62
|
+
document_id: str = Field(..., description="Document ID")
|
|
63
|
+
document_path: str = Field(..., description="Document path")
|
|
64
|
+
document_title: str | None = None
|
|
65
|
+
score: float = Field(..., ge=0, le=1)
|
|
66
|
+
|
|
67
|
+
# Component scores (for hybrid search)
|
|
68
|
+
bm25_score: float | None = None
|
|
69
|
+
vector_score: float | None = None
|
|
70
|
+
|
|
71
|
+
# Result data
|
|
72
|
+
content_preview: str | None = None
|
|
73
|
+
matched_chunks: list[dict[str, Any]] = Field(default_factory=list)
|
|
74
|
+
highlights: list[str] = Field(default_factory=list)
|
|
75
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
76
|
+
context_description: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SearchResponse(BaseModel):
|
|
80
|
+
"""Model for search response."""
|
|
81
|
+
|
|
82
|
+
query: str = Field(..., description="Original query")
|
|
83
|
+
search_type: SearchType = Field(...)
|
|
84
|
+
total: int = Field(..., ge=0, description="Total matching documents")
|
|
85
|
+
results: list[SearchResult] = Field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
# Performance metrics
|
|
88
|
+
search_time_ms: float = Field(..., ge=0)
|
|
89
|
+
|
|
90
|
+
# Query expansion info
|
|
91
|
+
expanded_query: str | None = None
|
|
92
|
+
expansion_terms: list[str] = Field(default_factory=list)
|
sif/search/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Search functionality for SIF.
|
|
2
|
+
|
|
3
|
+
This package implements various search strategies:
|
|
4
|
+
- BM25 full-text search via SQLite FTS5
|
|
5
|
+
- Vector semantic search via sqlite-vec
|
|
6
|
+
- Hybrid search combining multiple strategies
|
|
7
|
+
- RRF (Reciprocal Rank Fusion) for result merging
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sif.search.bm25 import BM25Searcher
|
|
11
|
+
from sif.search.hybrid import HybridSearcher, SearchPipeline
|
|
12
|
+
from sif.search.rrf import RRFFusion
|
|
13
|
+
from sif.search.vector import VectorSearcher
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BM25Searcher",
|
|
18
|
+
"HybridSearcher",
|
|
19
|
+
"RRFFusion",
|
|
20
|
+
"SearchPipeline",
|
|
21
|
+
"VectorSearcher",
|
|
22
|
+
]
|
sif/search/benchmark.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Benchmark evaluation for search quality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from sif.core.models import SearchResult
|
|
9
|
+
from sif.utils.logging import get_logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def precision_at_k(relevance: Sequence[int], k: int) -> float:
|
|
16
|
+
"""Precision@K: relevant items in top-K / K."""
|
|
17
|
+
if k == 0:
|
|
18
|
+
return 0.0
|
|
19
|
+
return sum(relevance[:k]) / k
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def recall_at_k(relevance: Sequence[int], total_relevant: int, k: int) -> float:
|
|
23
|
+
"""Recall@K: relevant items retrieved / total relevant."""
|
|
24
|
+
if total_relevant == 0:
|
|
25
|
+
return 0.0
|
|
26
|
+
return sum(relevance[:k]) / total_relevant
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def reciprocal_rank(relevance: Sequence[int]) -> float:
|
|
30
|
+
"""Reciprocal rank: 1 / rank of first relevant item."""
|
|
31
|
+
for i, rel in enumerate(relevance, start=1):
|
|
32
|
+
if rel:
|
|
33
|
+
return 1.0 / i
|
|
34
|
+
return 0.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def mean_reciprocal_rank(all_relevance: list[Sequence[int]]) -> float:
|
|
38
|
+
"""Mean Reciprocal Rank across queries."""
|
|
39
|
+
rr_scores = [reciprocal_rank(r) for r in all_relevance]
|
|
40
|
+
return sum(rr_scores) / len(rr_scores) if rr_scores else 0.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SearchEvaluator:
|
|
44
|
+
"""Evaluate search quality against a benchmark fixture."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, fixture: dict) -> None:
|
|
47
|
+
"""Initialize with fixture data.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
fixture: Dictionary with keys:
|
|
51
|
+
- queries: list of {query: str, relevant_docids: list[str], collections?: list[str]}
|
|
52
|
+
"""
|
|
53
|
+
self.fixture = fixture
|
|
54
|
+
self._validate_fixture()
|
|
55
|
+
|
|
56
|
+
def _validate_fixture(self) -> None:
|
|
57
|
+
"""Validate fixture format."""
|
|
58
|
+
if "queries" not in self.fixture:
|
|
59
|
+
raise ValueError("Fixture must contain 'queries' key")
|
|
60
|
+
for i, item in enumerate(self.fixture["queries"]):
|
|
61
|
+
if "query" not in item:
|
|
62
|
+
raise ValueError(f"Query {i} missing 'query' field")
|
|
63
|
+
if "relevant_docids" not in item:
|
|
64
|
+
raise ValueError(f"Query {i} missing 'relevant_docids' field")
|
|
65
|
+
|
|
66
|
+
def evaluate(
|
|
67
|
+
self,
|
|
68
|
+
search_fn: Callable[[str], list[SearchResult]],
|
|
69
|
+
k_values: list[int] | None = None,
|
|
70
|
+
) -> dict[str, float]:
|
|
71
|
+
"""Evaluate search quality.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
search_fn: Function that takes a query string and returns SearchResult list
|
|
75
|
+
k_values: List of k values for precision/recall@k. Default: [1, 5, 10]
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dictionary of averaged metrics
|
|
79
|
+
"""
|
|
80
|
+
k_values = k_values or [1, 5, 10]
|
|
81
|
+
metrics: dict[str, list[float]] = {}
|
|
82
|
+
all_relevance: list[Sequence[int]] = []
|
|
83
|
+
|
|
84
|
+
for query_item in self.fixture["queries"]:
|
|
85
|
+
query = query_item["query"]
|
|
86
|
+
relevant_ids = set(query_item["relevant_docids"])
|
|
87
|
+
|
|
88
|
+
# Run search
|
|
89
|
+
results = search_fn(query)
|
|
90
|
+
result_ids = [r.document_id for r in results]
|
|
91
|
+
|
|
92
|
+
# Build relevance vector
|
|
93
|
+
relevance = [1 if rid in relevant_ids else 0 for rid in result_ids]
|
|
94
|
+
all_relevance.append(relevance)
|
|
95
|
+
|
|
96
|
+
# Compute metrics for each k
|
|
97
|
+
for k in k_values:
|
|
98
|
+
metrics.setdefault(f"precision@{k}", []).append(precision_at_k(relevance, k))
|
|
99
|
+
metrics.setdefault(f"recall@{k}", []).append(
|
|
100
|
+
recall_at_k(relevance, len(relevant_ids), k)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Average across queries
|
|
104
|
+
averaged: dict[str, float] = {}
|
|
105
|
+
for key, values in metrics.items():
|
|
106
|
+
averaged[key] = sum(values) / len(values) if values else 0.0
|
|
107
|
+
averaged["mrr"] = mean_reciprocal_rank(all_relevance)
|
|
108
|
+
averaged["num_queries"] = float(len(self.fixture["queries"]))
|
|
109
|
+
|
|
110
|
+
return averaged
|
sif/search/bm25.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""BM25 full-text search implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
|
|
8
|
+
from sif.core.models import SearchOptions, SearchResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BM25Searcher:
|
|
12
|
+
"""BM25 full-text search using SQLite FTS5."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
15
|
+
self.db = db
|
|
16
|
+
|
|
17
|
+
def search(self, query: str, options: SearchOptions | None = None) -> list[SearchResult]:
|
|
18
|
+
"""Search documents using BM25."""
|
|
19
|
+
if options is None:
|
|
20
|
+
options = SearchOptions()
|
|
21
|
+
|
|
22
|
+
# Build the FTS query
|
|
23
|
+
fts_query = self._build_fts_query(query)
|
|
24
|
+
|
|
25
|
+
# Build collection filter
|
|
26
|
+
collection_filter = ""
|
|
27
|
+
params = [fts_query]
|
|
28
|
+
|
|
29
|
+
if options.collection_ids:
|
|
30
|
+
placeholders = ", ".join(["?"] * len(options.collection_ids))
|
|
31
|
+
collection_filter = f"AND d.collection_id IN ({placeholders})"
|
|
32
|
+
params.extend(options.collection_ids)
|
|
33
|
+
|
|
34
|
+
# Execute search
|
|
35
|
+
sql = f"""
|
|
36
|
+
SELECT
|
|
37
|
+
d.id as document_id,
|
|
38
|
+
d.title,
|
|
39
|
+
d.path,
|
|
40
|
+
c.name as collection_name,
|
|
41
|
+
rank as score
|
|
42
|
+
FROM documents_fts fts
|
|
43
|
+
JOIN documents d ON fts.rowid = d.rowid
|
|
44
|
+
JOIN collections c ON d.collection_id = c.id
|
|
45
|
+
WHERE documents_fts MATCH ? {collection_filter}
|
|
46
|
+
ORDER BY rank
|
|
47
|
+
LIMIT ? OFFSET ?
|
|
48
|
+
"""
|
|
49
|
+
params.extend([options.limit, options.offset])
|
|
50
|
+
|
|
51
|
+
cursor = self.db.execute(sql, params)
|
|
52
|
+
results = []
|
|
53
|
+
|
|
54
|
+
for rank, row in enumerate(cursor.fetchall(), 1):
|
|
55
|
+
# Convert rank to score (lower rank = higher score)
|
|
56
|
+
score = 1.0 / (1.0 + abs(row["score"]))
|
|
57
|
+
|
|
58
|
+
if score < options.min_score:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
result = SearchResult(
|
|
62
|
+
document_id=row["document_id"],
|
|
63
|
+
title=row["title"] or "",
|
|
64
|
+
path=row["path"],
|
|
65
|
+
collection_name=row["collection_name"],
|
|
66
|
+
score=score,
|
|
67
|
+
rank=rank,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if options.include_content:
|
|
71
|
+
result.content = self._get_document_content(row["document_id"])
|
|
72
|
+
|
|
73
|
+
if options.include_highlights:
|
|
74
|
+
result.highlights = self._get_highlights(
|
|
75
|
+
row["document_id"], query, options.max_highlights
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
results.append(result)
|
|
79
|
+
|
|
80
|
+
return self._attach_contexts(results)
|
|
81
|
+
|
|
82
|
+
def _attach_contexts(self, results: list[SearchResult]) -> list[SearchResult]:
|
|
83
|
+
"""Attach path context descriptions to search results via batch query."""
|
|
84
|
+
if not results:
|
|
85
|
+
return results
|
|
86
|
+
paths = list({r.path for r in results})
|
|
87
|
+
placeholders = ", ".join(["?"] * len(paths))
|
|
88
|
+
sql = f"""
|
|
89
|
+
SELECT target_id, content FROM contexts
|
|
90
|
+
WHERE context_type = 'path' AND target_id IN ({placeholders})
|
|
91
|
+
"""
|
|
92
|
+
cursor = self.db.execute(sql, paths)
|
|
93
|
+
# Normalize keys for cross-platform matching (macOS /private/tmp, etc.)
|
|
94
|
+
context_map = {
|
|
95
|
+
os.path.realpath(row["target_id"]): row["content"] for row in cursor.fetchall()
|
|
96
|
+
}
|
|
97
|
+
for result in results:
|
|
98
|
+
result.context_description = context_map.get(os.path.realpath(result.path))
|
|
99
|
+
return results
|
|
100
|
+
|
|
101
|
+
def search_chunks(
|
|
102
|
+
self, query: str, options: SearchOptions | None = None
|
|
103
|
+
) -> list[tuple[str, str, float]]:
|
|
104
|
+
"""Search document chunks using BM25.
|
|
105
|
+
|
|
106
|
+
Returns list of (document_id, chunk_content, score) tuples.
|
|
107
|
+
"""
|
|
108
|
+
if options is None:
|
|
109
|
+
options = SearchOptions()
|
|
110
|
+
|
|
111
|
+
fts_query = self._build_fts_query(query)
|
|
112
|
+
|
|
113
|
+
sql = """
|
|
114
|
+
SELECT
|
|
115
|
+
dc.document_id,
|
|
116
|
+
dc.content,
|
|
117
|
+
rank as score
|
|
118
|
+
FROM chunks_fts fts
|
|
119
|
+
JOIN document_chunks dc ON fts.rowid = dc.rowid
|
|
120
|
+
WHERE chunks_fts MATCH ?
|
|
121
|
+
ORDER BY rank
|
|
122
|
+
LIMIT ? OFFSET ?
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
cursor = self.db.execute(sql, [fts_query, options.limit, options.offset])
|
|
126
|
+
results = []
|
|
127
|
+
|
|
128
|
+
for row in cursor.fetchall():
|
|
129
|
+
score = 1.0 / (1.0 + abs(row["score"]))
|
|
130
|
+
if score >= options.min_score:
|
|
131
|
+
results.append((row["document_id"], row["content"], score))
|
|
132
|
+
|
|
133
|
+
return results
|
|
134
|
+
|
|
135
|
+
def _build_fts_query(self, query: str) -> str:
|
|
136
|
+
"""Build FTS5 query from user query.
|
|
137
|
+
|
|
138
|
+
Handles:
|
|
139
|
+
- Multiple terms (AND search)
|
|
140
|
+
- Phrases (quoted strings)
|
|
141
|
+
- Prefix search (term*)
|
|
142
|
+
"""
|
|
143
|
+
# Simple implementation: just join terms with AND
|
|
144
|
+
terms = query.split()
|
|
145
|
+
if not terms:
|
|
146
|
+
return "*"
|
|
147
|
+
|
|
148
|
+
# Use NEAR for better relevance
|
|
149
|
+
if len(terms) == 1:
|
|
150
|
+
return f"{terms[0]}*"
|
|
151
|
+
|
|
152
|
+
# For multiple terms, use AND
|
|
153
|
+
return " AND ".join(f"{term}*" for term in terms)
|
|
154
|
+
|
|
155
|
+
def _get_document_content(self, document_id: str) -> str | None:
|
|
156
|
+
"""Get document content."""
|
|
157
|
+
cursor = self.db.execute("SELECT content FROM documents WHERE id = ?", (document_id,))
|
|
158
|
+
row = cursor.fetchone()
|
|
159
|
+
return row[0] if row else None
|
|
160
|
+
|
|
161
|
+
def _get_highlights(self, document_id: str, query: str, max_highlights: int = 3) -> list[str]:
|
|
162
|
+
"""Get highlighted snippets for a document."""
|
|
163
|
+
# Get chunks for the document
|
|
164
|
+
cursor = self.db.execute(
|
|
165
|
+
"""
|
|
166
|
+
SELECT content FROM document_chunks
|
|
167
|
+
WHERE document_id = ?
|
|
168
|
+
ORDER BY sequence
|
|
169
|
+
""",
|
|
170
|
+
(document_id,),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
chunks = [row[0] for row in cursor.fetchall()]
|
|
174
|
+
if not chunks:
|
|
175
|
+
# Get full document content
|
|
176
|
+
content = self._get_document_content(document_id)
|
|
177
|
+
if content:
|
|
178
|
+
chunks = [content[:1000]] # First 1000 chars
|
|
179
|
+
|
|
180
|
+
# Find chunks containing query terms
|
|
181
|
+
query_terms = [t.lower() for t in query.split()]
|
|
182
|
+
highlights = []
|
|
183
|
+
|
|
184
|
+
for chunk in chunks:
|
|
185
|
+
chunk_lower = chunk.lower()
|
|
186
|
+
if any(term in chunk_lower for term in query_terms):
|
|
187
|
+
# Extract snippet around the match
|
|
188
|
+
snippet = self._extract_snippet(chunk, query_terms)
|
|
189
|
+
if snippet:
|
|
190
|
+
highlights.append(snippet)
|
|
191
|
+
if len(highlights) >= max_highlights:
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
return highlights
|
|
195
|
+
|
|
196
|
+
def _extract_snippet(self, text: str, query_terms: list[str], context: int = 50) -> str:
|
|
197
|
+
"""Extract a snippet around query matches."""
|
|
198
|
+
text_lower = text.lower()
|
|
199
|
+
|
|
200
|
+
for term in query_terms:
|
|
201
|
+
pos = text_lower.find(term)
|
|
202
|
+
if pos >= 0:
|
|
203
|
+
start = max(0, pos - context)
|
|
204
|
+
end = min(len(text), pos + len(term) + context)
|
|
205
|
+
snippet = text[start:end]
|
|
206
|
+
|
|
207
|
+
# Add ellipsis if truncated
|
|
208
|
+
if start > 0:
|
|
209
|
+
snippet = "..." + snippet
|
|
210
|
+
if end < len(text):
|
|
211
|
+
snippet = snippet + "..."
|
|
212
|
+
|
|
213
|
+
return snippet.strip()
|
|
214
|
+
|
|
215
|
+
# Return first part if no match found
|
|
216
|
+
return text[:200].strip() + "..." if len(text) > 200 else text.strip()
|
sif/search/expansion.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Query expansion for improving search recall."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from sif.utils.logging import get_logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from sif.embedding.manager import EmbeddingManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QueryExpansion:
|
|
19
|
+
"""Query expansion to improve search recall.
|
|
20
|
+
|
|
21
|
+
Query expansion adds related terms to the original query
|
|
22
|
+
to help find more relevant documents.
|
|
23
|
+
|
|
24
|
+
Methods:
|
|
25
|
+
- Synonym expansion: Add synonyms for query terms
|
|
26
|
+
- Embedding expansion: Find similar terms using embeddings
|
|
27
|
+
- LLM expansion: Use language model to generate related terms
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
embedding_manager: EmbeddingManager | None = None,
|
|
33
|
+
expansion_factor: int = 3,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Initialize query expansion.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
embedding_manager: Optional embedding manager for semantic expansion
|
|
39
|
+
expansion_factor: Number of expansion terms to add per query term
|
|
40
|
+
"""
|
|
41
|
+
self._embedding_manager = embedding_manager
|
|
42
|
+
self._expansion_factor = expansion_factor
|
|
43
|
+
|
|
44
|
+
def expand(self, query: str, intent: str | None = None) -> list[str]:
|
|
45
|
+
"""Expand a query with related terms.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
query: Original query
|
|
49
|
+
intent: Optional intent hint to prepend to each variant
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
List of query variants: [original_query, expanded_variant_1, ...]
|
|
53
|
+
"""
|
|
54
|
+
expansion_terms = self._get_expansion_terms(query)
|
|
55
|
+
|
|
56
|
+
variants = [query]
|
|
57
|
+
variants.extend(f"{query} {term}" for term in expansion_terms[: self._expansion_factor])
|
|
58
|
+
|
|
59
|
+
if intent:
|
|
60
|
+
variants = [f"{intent}: {v}" for v in variants]
|
|
61
|
+
|
|
62
|
+
if not variants:
|
|
63
|
+
return [query]
|
|
64
|
+
|
|
65
|
+
logger.debug(f"Expanded query: '{query}' -> {variants}")
|
|
66
|
+
return variants
|
|
67
|
+
|
|
68
|
+
def _get_expansion_terms(self, query: str) -> list[str]:
|
|
69
|
+
"""Get expansion terms using embedding-based pseudo-relevance feedback.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
query: Original query
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of expansion terms sorted by similarity
|
|
76
|
+
"""
|
|
77
|
+
if self._embedding_manager is None:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
# Embed the query
|
|
82
|
+
query_embedding = self._embedding_manager.embed_single(query)
|
|
83
|
+
|
|
84
|
+
# Generate candidate terms: individual words from query + common synonyms
|
|
85
|
+
words = query.split()
|
|
86
|
+
candidates = set(words)
|
|
87
|
+
|
|
88
|
+
# Simple synonym map for common terms (expandable)
|
|
89
|
+
synonym_map = {
|
|
90
|
+
"python": ["py", "cpython"],
|
|
91
|
+
"search": ["find", "lookup", "query"],
|
|
92
|
+
"index": ["catalog", "register"],
|
|
93
|
+
"document": ["doc", "file", "page"],
|
|
94
|
+
"config": ["configuration", "settings"],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for word in words:
|
|
98
|
+
word_lower = word.lower()
|
|
99
|
+
if word_lower in synonym_map:
|
|
100
|
+
candidates.update(synonym_map[word_lower])
|
|
101
|
+
|
|
102
|
+
# Remove query words from candidates (we only want expansion terms)
|
|
103
|
+
query_words_lower = {w.lower() for w in words}
|
|
104
|
+
candidates = candidates - query_words_lower
|
|
105
|
+
|
|
106
|
+
if not candidates:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
candidate_list = list(candidates)
|
|
110
|
+
candidate_embeddings = self._embedding_manager.embed(candidate_list)
|
|
111
|
+
|
|
112
|
+
# Compute cosine similarities
|
|
113
|
+
query_vec = np.array(query_embedding)
|
|
114
|
+
similarities = []
|
|
115
|
+
for emb in candidate_embeddings.embeddings:
|
|
116
|
+
cand_vec = np.array(emb)
|
|
117
|
+
sim = np.dot(query_vec, cand_vec) / (
|
|
118
|
+
np.linalg.norm(query_vec) * np.linalg.norm(cand_vec) + 1e-8
|
|
119
|
+
)
|
|
120
|
+
similarities.append(sim)
|
|
121
|
+
|
|
122
|
+
# Return top-k most similar terms that are NOT already in the query
|
|
123
|
+
scored = [
|
|
124
|
+
(term, sim)
|
|
125
|
+
for term, sim in zip(candidate_list, similarities)
|
|
126
|
+
if term.lower() not in query_words_lower
|
|
127
|
+
]
|
|
128
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
129
|
+
|
|
130
|
+
return [term for term, _ in scored[: self._expansion_factor]]
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.warning(f"Query expansion failed: {e}")
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
def expand_batch(self, queries: list[str]) -> list[str]:
|
|
136
|
+
"""Expand multiple queries and return a flat list of unique variants.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
queries: List of queries
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Flat list of unique expanded query variants
|
|
143
|
+
"""
|
|
144
|
+
all_variants: list[str] = []
|
|
145
|
+
for query in queries:
|
|
146
|
+
variants = self.expand(query)
|
|
147
|
+
all_variants.extend(variants)
|
|
148
|
+
|
|
149
|
+
# Deduplicate while preserving order
|
|
150
|
+
seen: set[str] = set()
|
|
151
|
+
unique_variants: list[str] = []
|
|
152
|
+
for v in all_variants:
|
|
153
|
+
if v not in seen:
|
|
154
|
+
seen.add(v)
|
|
155
|
+
unique_variants.append(v)
|
|
156
|
+
|
|
157
|
+
return unique_variants
|