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,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
+ ]
@@ -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()
@@ -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