contextual-engine 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.
- contextual/__init__.py +18 -0
- contextual/__main__.py +11 -0
- contextual/cli.py +339 -0
- contextual/cli_docs.py +685 -0
- contextual/config.py +7 -0
- contextual/core/__init__.py +11 -0
- contextual/core/errors.py +470 -0
- contextual/core/models.py +590 -0
- contextual/docs/__init__.py +66 -0
- contextual/docs/chunker.py +550 -0
- contextual/docs/pipeline.py +513 -0
- contextual/docs/retrieval.py +654 -0
- contextual/docs/watcher.py +265 -0
- contextual/embedding/__init__.py +87 -0
- contextual/embedding/cache.py +455 -0
- contextual/embedding/embedder.py +414 -0
- contextual/embedding/helpers.py +252 -0
- contextual/git/__init__.py +22 -0
- contextual/git/blame.py +334 -0
- contextual/indexing/__init__.py +20 -0
- contextual/indexing/bug_sweep.py +119 -0
- contextual/indexing/chunker.py +691 -0
- contextual/indexing/embedder.py +271 -0
- contextual/indexing/file_watcher.py +154 -0
- contextual/indexing/incremental.py +260 -0
- contextual/indexing/index_writer.py +442 -0
- contextual/indexing/pipeline.py +438 -0
- contextual/indexing/processor.py +436 -0
- contextual/indexing/queries/readme.md +22 -0
- contextual/indexing/symbol_extractor.py +426 -0
- contextual/indexing/tokenizer.py +203 -0
- contextual/integrations/__init__.py +10 -0
- contextual/mcp/__init__.py +15 -0
- contextual/mcp/__main__.py +24 -0
- contextual/mcp/docs_tools.py +286 -0
- contextual/mcp/server.py +118 -0
- contextual/mcp/tools.py +443 -0
- contextual/observability/__init__.py +21 -0
- contextual/observability/logging.py +115 -0
- contextual/py.typed +0 -0
- contextual/retrieval/__init__.py +24 -0
- contextual/retrieval/context_assembler.py +372 -0
- contextual/retrieval/ranker.py +193 -0
- contextual/retrieval/search.py +548 -0
- contextual/security/__init__.py +52 -0
- contextual/security/paths.py +347 -0
- contextual/security/sanitize.py +349 -0
- contextual/security/workspace.py +348 -0
- contextual/storage/__init__.py +36 -0
- contextual/storage/fts_manager.py +273 -0
- contextual/storage/migration_v2.py +289 -0
- contextual/storage/migrations.py +316 -0
- contextual/storage/schema.py +210 -0
- contextual/storage/sqlite_pool.py +468 -0
- contextual/storage/vec0_manager.py +421 -0
- contextual_engine-0.1.0.dist-info/METADATA +297 -0
- contextual_engine-0.1.0.dist-info/RECORD +60 -0
- contextual_engine-0.1.0.dist-info/WHEEL +4 -0
- contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
- contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Hybrid search engine: BM25 + vector similarity with Reciprocal Rank Fusion.
|
|
2
|
+
|
|
3
|
+
Implements the search pipeline per TECHNICAL_SPEC §5:
|
|
4
|
+
- BM25 via FTS5 (top-100 candidates)
|
|
5
|
+
- Vector cosine similarity via vec0 (top-100 candidates)
|
|
6
|
+
- RRF fusion with configurable weights (BM25=0.6, Dense=0.4)
|
|
7
|
+
- Returns ranked results with scores
|
|
8
|
+
|
|
9
|
+
This module is the retrieval workhorse - all MCP search queries flow through here.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sqlite3
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from contextual.core.errors import ErrorCode, RetrievalError, retrieval_context
|
|
19
|
+
from contextual.observability.logging import get_logger
|
|
20
|
+
from contextual.storage.vec0_manager import search_vectors
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from sqlite3 import Connection
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
# ============================================================================
|
|
30
|
+
# CONSTANTS (from TECHNICAL_SPEC §5)
|
|
31
|
+
# ============================================================================
|
|
32
|
+
|
|
33
|
+
BM25_CANDIDATES = 100
|
|
34
|
+
"""Number of candidates to fetch from BM25 index."""
|
|
35
|
+
|
|
36
|
+
DENSE_CANDIDATES = 100
|
|
37
|
+
"""Number of candidates to fetch from vector search."""
|
|
38
|
+
|
|
39
|
+
RRF_K = 60
|
|
40
|
+
"""Reciprocal Rank Fusion constant (Cormack et al. 2009)."""
|
|
41
|
+
|
|
42
|
+
RRF_BM25_WEIGHT = 0.6
|
|
43
|
+
"""Weight for BM25 results in RRF scoring (identifiers dominate code queries)."""
|
|
44
|
+
|
|
45
|
+
RRF_DENSE_WEIGHT = 0.4
|
|
46
|
+
"""Weight for dense vector results in RRF scoring."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ============================================================================
|
|
50
|
+
# DATA MODELS
|
|
51
|
+
# ============================================================================
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class SearchResult:
|
|
56
|
+
"""A single search result with metadata and score.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
chunk_id: Database chunk ID.
|
|
60
|
+
path: File path relative to project root.
|
|
61
|
+
symbol_name: Function/class name (or None for module chunks).
|
|
62
|
+
chunk_type: Type of code structure.
|
|
63
|
+
start_line: First line of chunk.
|
|
64
|
+
end_line: Last line of chunk.
|
|
65
|
+
score: Combined relevance score (0.0-1.0, higher = more relevant).
|
|
66
|
+
bm25_rank: Rank in BM25 results (None if not in BM25 top-100).
|
|
67
|
+
dense_rank: Rank in vector results (None if not in vector top-100).
|
|
68
|
+
snippet: Preview of chunk content (first 200 chars).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
chunk_id: int
|
|
72
|
+
path: str
|
|
73
|
+
symbol_name: str | None
|
|
74
|
+
chunk_type: str
|
|
75
|
+
start_line: int
|
|
76
|
+
end_line: int
|
|
77
|
+
score: float
|
|
78
|
+
bm25_rank: int | None
|
|
79
|
+
dense_rank: int | None
|
|
80
|
+
snippet: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ============================================================================
|
|
84
|
+
# BM25 SEARCH (FTS5)
|
|
85
|
+
# ============================================================================
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _bm25_search(
|
|
89
|
+
conn: Connection,
|
|
90
|
+
query: str,
|
|
91
|
+
k: int = BM25_CANDIDATES,
|
|
92
|
+
language: str | None = None,
|
|
93
|
+
) -> list[tuple[int, float, int]]:
|
|
94
|
+
"""Execute BM25 full-text search via FTS5.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
conn: SQLite connection.
|
|
98
|
+
query: Search query string (supports FTS5 syntax).
|
|
99
|
+
k: Number of results to return.
|
|
100
|
+
language: Optional language filter (e.g., 'python', 'typescript').
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of (chunk_id, bm25_score, rank) tuples ordered by descending score.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
RetrievalError: If FTS5 query fails.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
cursor = conn.cursor()
|
|
110
|
+
|
|
111
|
+
# Extract keywords from natural language query
|
|
112
|
+
# Remove common stop words and keep only meaningful terms
|
|
113
|
+
stop_words = {
|
|
114
|
+
'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
115
|
+
'how', 'what', 'when', 'where', 'why', 'which', 'who',
|
|
116
|
+
'does', 'do', 'did', 'can', 'could', 'should', 'would',
|
|
117
|
+
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'from', 'by',
|
|
118
|
+
'i', 'you', 'we', 'they', 'it', 'this', 'that', 'these', 'those',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Tokenize and filter
|
|
122
|
+
tokens = query.lower().split()
|
|
123
|
+
keywords = [t for t in tokens if t not in stop_words and len(t) > 2]
|
|
124
|
+
|
|
125
|
+
# Join keywords with OR operator for FTS5
|
|
126
|
+
fts_query = ' OR '.join(keywords) if keywords else query.strip()
|
|
127
|
+
|
|
128
|
+
# Preserve exact symbols with underscores (snake_case identifiers)
|
|
129
|
+
# For single-word queries containing underscores, force phrase match
|
|
130
|
+
if '_' in query and len(query.split()) == 1:
|
|
131
|
+
fts_query = f'"{query}"' # Force phrase match for snake_case identifiers
|
|
132
|
+
|
|
133
|
+
logger.debug("BM25 query processing", original=query, keywords=keywords, fts_query=fts_query)
|
|
134
|
+
|
|
135
|
+
# Build query parameters
|
|
136
|
+
params: list[str | int] = [fts_query]
|
|
137
|
+
|
|
138
|
+
# Add language filter if specified
|
|
139
|
+
language_filter = ""
|
|
140
|
+
if language:
|
|
141
|
+
language_filter = "AND f.language = ?"
|
|
142
|
+
params.append(language)
|
|
143
|
+
|
|
144
|
+
# Query FTS5 with BM25 ranking (C4: Boost symbol_name with 1.0, 2.5, 0.0)
|
|
145
|
+
# Note: Use subquery to compute bm25() scores before window function
|
|
146
|
+
# (FTS5 bm25() cannot be used directly inside window function context)
|
|
147
|
+
sql = f"""
|
|
148
|
+
SELECT
|
|
149
|
+
chunk_id,
|
|
150
|
+
bm25_score,
|
|
151
|
+
ROW_NUMBER() OVER (ORDER BY bm25_score DESC) AS rank
|
|
152
|
+
FROM (
|
|
153
|
+
SELECT
|
|
154
|
+
c.id as chunk_id,
|
|
155
|
+
-bm25(fts_chunks) AS bm25_score
|
|
156
|
+
FROM fts_chunks fts
|
|
157
|
+
JOIN chunks c ON fts.rowid = c.id
|
|
158
|
+
JOIN files f ON c.file_id = f.id
|
|
159
|
+
WHERE fts_chunks MATCH ?
|
|
160
|
+
{language_filter}
|
|
161
|
+
)
|
|
162
|
+
ORDER BY bm25_score DESC
|
|
163
|
+
LIMIT ?
|
|
164
|
+
"""
|
|
165
|
+
params.append(k)
|
|
166
|
+
|
|
167
|
+
cursor.execute(sql, tuple(params))
|
|
168
|
+
results = cursor.fetchall()
|
|
169
|
+
|
|
170
|
+
logger.debug(
|
|
171
|
+
"BM25 search completed",
|
|
172
|
+
query=query,
|
|
173
|
+
results_count=len(results),
|
|
174
|
+
language=language,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return results
|
|
178
|
+
|
|
179
|
+
except sqlite3.Error as e:
|
|
180
|
+
msg = f"BM25 search failed: {e}"
|
|
181
|
+
raise RetrievalError(
|
|
182
|
+
code=ErrorCode.RETRIEVAL_BM25_FAILED,
|
|
183
|
+
message=msg,
|
|
184
|
+
context=retrieval_context(
|
|
185
|
+
query=query,
|
|
186
|
+
),
|
|
187
|
+
) from e
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ============================================================================
|
|
191
|
+
# VECTOR SEARCH (vec0)
|
|
192
|
+
# ============================================================================
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _vector_search(
|
|
196
|
+
conn: Connection,
|
|
197
|
+
query_embedding: list[float],
|
|
198
|
+
k: int = DENSE_CANDIDATES,
|
|
199
|
+
language: str | None = None,
|
|
200
|
+
) -> list[tuple[int, float, int]]:
|
|
201
|
+
"""Execute vector similarity search via vec0.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
conn: SQLite connection.
|
|
205
|
+
query_embedding: Query vector (768-dim).
|
|
206
|
+
k: Number of results to return.
|
|
207
|
+
language: Optional language filter.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
List of (chunk_id, similarity_score, rank) tuples ordered by descending score.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
RetrievalError: If vector search fails.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
# Get raw vector results (chunk_id, score)
|
|
217
|
+
raw_results = search_vectors(conn, query_embedding, k=k)
|
|
218
|
+
|
|
219
|
+
# If language filter specified, fetch chunk metadata and filter
|
|
220
|
+
if language:
|
|
221
|
+
cursor = conn.cursor()
|
|
222
|
+
chunk_ids = [chunk_id for chunk_id, _ in raw_results]
|
|
223
|
+
|
|
224
|
+
if not chunk_ids:
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
# Fetch chunks with language filter
|
|
228
|
+
placeholders = ",".join("?" * len(chunk_ids))
|
|
229
|
+
sql = f"""
|
|
230
|
+
SELECT id
|
|
231
|
+
FROM chunks
|
|
232
|
+
WHERE id IN ({placeholders})
|
|
233
|
+
AND language = ?
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
cursor.execute(sql, (*chunk_ids, language))
|
|
237
|
+
valid_ids = {row[0] for row in cursor.fetchall()}
|
|
238
|
+
|
|
239
|
+
# Filter results to only valid chunk IDs
|
|
240
|
+
filtered_results = [
|
|
241
|
+
(chunk_id, score)
|
|
242
|
+
for chunk_id, score in raw_results
|
|
243
|
+
if chunk_id in valid_ids
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
results = filtered_results[:k]
|
|
247
|
+
else:
|
|
248
|
+
results = raw_results
|
|
249
|
+
|
|
250
|
+
# Add rank (1-indexed)
|
|
251
|
+
ranked_results = [
|
|
252
|
+
(chunk_id, score, rank + 1)
|
|
253
|
+
for rank, (chunk_id, score) in enumerate(results)
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
logger.debug(
|
|
257
|
+
"Vector search completed",
|
|
258
|
+
results_count=len(ranked_results),
|
|
259
|
+
language=language,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return ranked_results
|
|
263
|
+
|
|
264
|
+
except Exception as e:
|
|
265
|
+
msg = f"Vector search failed: {e}"
|
|
266
|
+
raise RetrievalError(
|
|
267
|
+
code=ErrorCode.RETRIEVAL_DENSE_SEARCH_FAILED,
|
|
268
|
+
message=msg,
|
|
269
|
+
context=retrieval_context(),
|
|
270
|
+
) from e
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ============================================================================
|
|
274
|
+
# RECIPROCAL RANK FUSION
|
|
275
|
+
# ============================================================================
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _reciprocal_rank_fusion(
|
|
279
|
+
bm25_results: list[tuple[int, float, int]],
|
|
280
|
+
vector_results: list[tuple[int, float, int]],
|
|
281
|
+
k: int = RRF_K,
|
|
282
|
+
bm25_weight: float = RRF_BM25_WEIGHT,
|
|
283
|
+
dense_weight: float = RRF_DENSE_WEIGHT,
|
|
284
|
+
) -> list[tuple[int, float, int | None, int | None]]:
|
|
285
|
+
"""Fuse BM25 and vector results using Reciprocal Rank Fusion.
|
|
286
|
+
|
|
287
|
+
RRF formula: score(d) = Σ_s [ weight_s / (k + rank_s(d)) ]
|
|
288
|
+
where s ∈ {bm25, dense}, weight_s is the source weight, rank_s(d)
|
|
289
|
+
is the rank of document d in source s.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
bm25_results: BM25 results as (chunk_id, score, rank).
|
|
293
|
+
vector_results: Vector results as (chunk_id, score, rank).
|
|
294
|
+
k: RRF constant (higher = less emphasis on top ranks).
|
|
295
|
+
bm25_weight: Weight for BM25 results.
|
|
296
|
+
dense_weight: Weight for vector results.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
List of (chunk_id, rrf_score, bm25_rank, dense_rank) tuples
|
|
300
|
+
ordered by descending RRF score.
|
|
301
|
+
"""
|
|
302
|
+
# Build rank maps: chunk_id -> rank
|
|
303
|
+
bm25_ranks = {chunk_id: rank for chunk_id, _, rank in bm25_results}
|
|
304
|
+
dense_ranks = {chunk_id: rank for chunk_id, _, rank in vector_results}
|
|
305
|
+
|
|
306
|
+
# Collect all unique chunk IDs from both sources
|
|
307
|
+
all_chunk_ids = set(bm25_ranks.keys()) | set(dense_ranks.keys())
|
|
308
|
+
|
|
309
|
+
# Compute RRF scores
|
|
310
|
+
rrf_scores: dict[int, float] = {}
|
|
311
|
+
for chunk_id in all_chunk_ids:
|
|
312
|
+
score = 0.0
|
|
313
|
+
|
|
314
|
+
if chunk_id in bm25_ranks:
|
|
315
|
+
score += bm25_weight / (k + bm25_ranks[chunk_id])
|
|
316
|
+
|
|
317
|
+
if chunk_id in dense_ranks:
|
|
318
|
+
score += dense_weight / (k + dense_ranks[chunk_id])
|
|
319
|
+
|
|
320
|
+
rrf_scores[chunk_id] = score
|
|
321
|
+
|
|
322
|
+
# Sort by descending RRF score
|
|
323
|
+
sorted_results = sorted(
|
|
324
|
+
rrf_scores.items(),
|
|
325
|
+
key=lambda x: x[1],
|
|
326
|
+
reverse=True,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Build final result list with source ranks
|
|
330
|
+
fused_results = [
|
|
331
|
+
(
|
|
332
|
+
chunk_id,
|
|
333
|
+
score,
|
|
334
|
+
bm25_ranks.get(chunk_id),
|
|
335
|
+
dense_ranks.get(chunk_id),
|
|
336
|
+
)
|
|
337
|
+
for chunk_id, score in sorted_results
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
logger.debug(
|
|
341
|
+
"RRF fusion completed",
|
|
342
|
+
total_candidates=len(all_chunk_ids),
|
|
343
|
+
bm25_only=len(bm25_ranks - dense_ranks.keys()),
|
|
344
|
+
dense_only=len(dense_ranks - bm25_ranks.keys()),
|
|
345
|
+
overlap=len(bm25_ranks.keys() & dense_ranks.keys()),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return fused_results
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ============================================================================
|
|
352
|
+
# RESULT HYDRATION
|
|
353
|
+
# ============================================================================
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _hydrate_results(
|
|
357
|
+
conn: Connection,
|
|
358
|
+
fused_results: list[tuple[int, float, int | None, int | None]],
|
|
359
|
+
top_k: int,
|
|
360
|
+
) -> list[SearchResult]:
|
|
361
|
+
"""Fetch chunk metadata and build SearchResult objects.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
conn: SQLite connection.
|
|
365
|
+
fused_results: RRF results as (chunk_id, score, bm25_rank, dense_rank).
|
|
366
|
+
top_k: Number of results to return.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
List of SearchResult objects with full metadata.
|
|
370
|
+
|
|
371
|
+
Raises:
|
|
372
|
+
RetrievalError: If metadata fetch fails.
|
|
373
|
+
"""
|
|
374
|
+
if not fused_results:
|
|
375
|
+
return []
|
|
376
|
+
|
|
377
|
+
# Take top-k
|
|
378
|
+
top_results = fused_results[:top_k]
|
|
379
|
+
chunk_ids = [chunk_id for chunk_id, _, _, _ in top_results]
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
cursor = conn.cursor()
|
|
383
|
+
|
|
384
|
+
# Fetch chunk metadata
|
|
385
|
+
placeholders = ",".join("?" * len(chunk_ids))
|
|
386
|
+
sql = f"""
|
|
387
|
+
SELECT
|
|
388
|
+
c.id,
|
|
389
|
+
f.path,
|
|
390
|
+
c.symbol_name,
|
|
391
|
+
c.symbol_type,
|
|
392
|
+
c.start_line,
|
|
393
|
+
c.end_line,
|
|
394
|
+
c.content
|
|
395
|
+
FROM chunks c
|
|
396
|
+
JOIN files f ON c.file_id = f.id
|
|
397
|
+
WHERE c.id IN ({placeholders})
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
cursor.execute(sql, chunk_ids)
|
|
401
|
+
rows = cursor.fetchall()
|
|
402
|
+
|
|
403
|
+
# Build chunk metadata map
|
|
404
|
+
chunk_meta = {
|
|
405
|
+
row[0]: {
|
|
406
|
+
"path": row[1],
|
|
407
|
+
"symbol_name": row[2],
|
|
408
|
+
"chunk_type": row[3],
|
|
409
|
+
"start_line": row[4],
|
|
410
|
+
"end_line": row[5],
|
|
411
|
+
"content": row[6],
|
|
412
|
+
}
|
|
413
|
+
for row in rows
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
# Build SearchResult objects preserving RRF order
|
|
417
|
+
results = []
|
|
418
|
+
for chunk_id, score, bm25_rank, dense_rank in top_results:
|
|
419
|
+
if chunk_id not in chunk_meta:
|
|
420
|
+
# Chunk was deleted between search and hydration - skip it
|
|
421
|
+
logger.warning("Chunk not found during hydration", chunk_id=chunk_id)
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
meta = chunk_meta[chunk_id]
|
|
425
|
+
|
|
426
|
+
# Generate snippet (first 200 chars)
|
|
427
|
+
content = meta["content"]
|
|
428
|
+
snippet = content[:200] + "..." if len(content) > 200 else content
|
|
429
|
+
|
|
430
|
+
result = SearchResult(
|
|
431
|
+
chunk_id=chunk_id,
|
|
432
|
+
path=meta["path"],
|
|
433
|
+
symbol_name=meta["symbol_name"],
|
|
434
|
+
chunk_type=meta["chunk_type"],
|
|
435
|
+
start_line=meta["start_line"],
|
|
436
|
+
end_line=meta["end_line"],
|
|
437
|
+
score=score,
|
|
438
|
+
bm25_rank=bm25_rank,
|
|
439
|
+
dense_rank=dense_rank,
|
|
440
|
+
snippet=snippet,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
results.append(result)
|
|
444
|
+
|
|
445
|
+
logger.debug(
|
|
446
|
+
"Results hydrated",
|
|
447
|
+
requested=top_k,
|
|
448
|
+
returned=len(results),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return results
|
|
452
|
+
|
|
453
|
+
except sqlite3.Error as e:
|
|
454
|
+
msg = f"Failed to hydrate search results: {e}"
|
|
455
|
+
raise RetrievalError(
|
|
456
|
+
code=ErrorCode.RETRIEVAL_RERANK_FAILED, # Using existing code for now
|
|
457
|
+
message=msg,
|
|
458
|
+
context=retrieval_context(),
|
|
459
|
+
) from e
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ============================================================================
|
|
463
|
+
# PUBLIC API
|
|
464
|
+
# ============================================================================
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def hybrid_search(
|
|
468
|
+
conn: Connection,
|
|
469
|
+
query: str,
|
|
470
|
+
query_embedding: list[float],
|
|
471
|
+
top_k: int = 10,
|
|
472
|
+
language: str | None = None,
|
|
473
|
+
bm25_weight: float | None = None,
|
|
474
|
+
dense_weight: float | None = None,
|
|
475
|
+
) -> list[SearchResult]:
|
|
476
|
+
"""Execute hybrid BM25 + vector search with RRF fusion.
|
|
477
|
+
|
|
478
|
+
This is the main search entry point. It:
|
|
479
|
+
1. Runs BM25 search (top-100)
|
|
480
|
+
2. Runs vector search (top-100)
|
|
481
|
+
3. Fuses results with RRF
|
|
482
|
+
4. Hydrates metadata
|
|
483
|
+
5. Returns top-k results
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
conn: SQLite connection (must have vec0 loaded).
|
|
487
|
+
query: Natural language query string.
|
|
488
|
+
query_embedding: Query embedding vector (768-dim).
|
|
489
|
+
top_k: Number of results to return (1-100).
|
|
490
|
+
language: Optional language filter.
|
|
491
|
+
bm25_weight: Optional BM25 weight for RRF (default: RRF_BM25_WEIGHT).
|
|
492
|
+
Source-type aware: pass 0.55 for code, 0.4 for docs.
|
|
493
|
+
dense_weight: Optional dense weight for RRF (default: RRF_DENSE_WEIGHT).
|
|
494
|
+
Source-type aware: pass 0.45 for code, 0.6 for docs.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
List of SearchResult objects ordered by relevance.
|
|
498
|
+
|
|
499
|
+
Raises:
|
|
500
|
+
RetrievalError: If search fails at any stage.
|
|
501
|
+
|
|
502
|
+
Example:
|
|
503
|
+
>>> from contextual.embedding import get_embedder
|
|
504
|
+
>>> embedder = get_embedder()
|
|
505
|
+
>>> query = "parse JSON configuration"
|
|
506
|
+
>>> embedding = embedder.embed([query])[0]
|
|
507
|
+
>>> results = hybrid_search(conn, query, embedding, top_k=10)
|
|
508
|
+
>>> for r in results:
|
|
509
|
+
... print(f"{r.path}:{r.start_line} - {r.score:.3f}")
|
|
510
|
+
"""
|
|
511
|
+
# Use provided weights or fall back to defaults
|
|
512
|
+
_bm25_weight = bm25_weight if bm25_weight is not None else RRF_BM25_WEIGHT
|
|
513
|
+
_dense_weight = dense_weight if dense_weight is not None else RRF_DENSE_WEIGHT
|
|
514
|
+
|
|
515
|
+
logger.info(
|
|
516
|
+
"Starting hybrid search",
|
|
517
|
+
query=query,
|
|
518
|
+
top_k=top_k,
|
|
519
|
+
language=language,
|
|
520
|
+
bm25_weight=_bm25_weight,
|
|
521
|
+
dense_weight=_dense_weight,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# Execute both searches in parallel (SQLite WAL allows concurrent reads)
|
|
525
|
+
bm25_results = _bm25_search(conn, query, k=BM25_CANDIDATES, language=language)
|
|
526
|
+
vector_results = _vector_search(conn, query_embedding, k=DENSE_CANDIDATES, language=language)
|
|
527
|
+
|
|
528
|
+
# Fuse with RRF using source-type-aware weights
|
|
529
|
+
fused_results = _reciprocal_rank_fusion(
|
|
530
|
+
bm25_results,
|
|
531
|
+
vector_results,
|
|
532
|
+
k=RRF_K,
|
|
533
|
+
bm25_weight=_bm25_weight,
|
|
534
|
+
dense_weight=_dense_weight,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Hydrate and return
|
|
538
|
+
results = _hydrate_results(conn, fused_results, top_k)
|
|
539
|
+
|
|
540
|
+
logger.info(
|
|
541
|
+
"Hybrid search completed",
|
|
542
|
+
query=query,
|
|
543
|
+
results_count=len(results),
|
|
544
|
+
bm25_candidates=len(bm25_results),
|
|
545
|
+
vector_candidates=len(vector_results),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
return results
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Security hardening: path safety, input sanitization, and workspace isolation.
|
|
2
|
+
|
|
3
|
+
Prevents path traversal, SQL/FTS5 injection, Unicode-based attacks, and prompt
|
|
4
|
+
injection via indexed content. Implements per-project workspace isolation.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from contextual.security.paths import (
|
|
9
|
+
ensure_parent_exists,
|
|
10
|
+
is_safe_path,
|
|
11
|
+
safe_join,
|
|
12
|
+
safe_path,
|
|
13
|
+
validate_filename,
|
|
14
|
+
)
|
|
15
|
+
from contextual.security.sanitize import (
|
|
16
|
+
detect_prompt_injection_patterns,
|
|
17
|
+
escape_for_logging,
|
|
18
|
+
escape_fts5_match_query,
|
|
19
|
+
sanitize_file_content,
|
|
20
|
+
sanitize_user_input,
|
|
21
|
+
strip_unicode_control_chars,
|
|
22
|
+
validate_sql_identifier,
|
|
23
|
+
wrap_untrusted_content,
|
|
24
|
+
)
|
|
25
|
+
from contextual.security.workspace import (
|
|
26
|
+
WorkspaceManager,
|
|
27
|
+
get_workspace_manager,
|
|
28
|
+
init_workspace,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Path safety
|
|
34
|
+
"safe_path",
|
|
35
|
+
"is_safe_path",
|
|
36
|
+
"safe_join",
|
|
37
|
+
"validate_filename",
|
|
38
|
+
"ensure_parent_exists",
|
|
39
|
+
# Input sanitization
|
|
40
|
+
"strip_unicode_control_chars",
|
|
41
|
+
"escape_fts5_match_query",
|
|
42
|
+
"validate_sql_identifier",
|
|
43
|
+
"sanitize_user_input",
|
|
44
|
+
"sanitize_file_content",
|
|
45
|
+
"detect_prompt_injection_patterns",
|
|
46
|
+
"wrap_untrusted_content",
|
|
47
|
+
"escape_for_logging",
|
|
48
|
+
# Workspace management
|
|
49
|
+
"WorkspaceManager",
|
|
50
|
+
"init_workspace",
|
|
51
|
+
"get_workspace_manager",
|
|
52
|
+
]
|