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,372 @@
|
|
|
1
|
+
"""Context assembly: Budget-aware formatting of search results for LLM consumption.
|
|
2
|
+
|
|
3
|
+
Takes search results and assembles them into a context string that fits within
|
|
4
|
+
a token budget. Implements:
|
|
5
|
+
- Token counting (using tiktoken cl100k_base)
|
|
6
|
+
- Budget allocation (15% structural, 85% content per TECHNICAL_SPEC §5)
|
|
7
|
+
- Progressive inclusion with truncation
|
|
8
|
+
- Rich metadata formatting
|
|
9
|
+
|
|
10
|
+
The assembler ensures that:
|
|
11
|
+
1. Structural context (file paths, imports, class hierarchy) gets 15% of budget
|
|
12
|
+
2. Actual code chunks get 85% of budget
|
|
13
|
+
3. Higher-ranked results are prioritized
|
|
14
|
+
4. Snippets are truncated gracefully if budget is tight
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import tiktoken
|
|
23
|
+
|
|
24
|
+
from contextual.observability.logging import get_logger
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from contextual.retrieval.search import SearchResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
# ============================================================================
|
|
34
|
+
# CONSTANTS (from TECHNICAL_SPEC §5)
|
|
35
|
+
# ============================================================================
|
|
36
|
+
|
|
37
|
+
STRUCTURAL_BUDGET_RATIO = 0.15
|
|
38
|
+
"""Percentage of budget allocated to structural context."""
|
|
39
|
+
|
|
40
|
+
CONTENT_BUDGET_RATIO = 0.85
|
|
41
|
+
"""Percentage of budget allocated to code chunks."""
|
|
42
|
+
|
|
43
|
+
DEFAULT_TOKEN_BUDGET = 100_000
|
|
44
|
+
"""Default maximum tokens for assembled context (Claude 3.7 window)."""
|
|
45
|
+
|
|
46
|
+
# Encoding for token counting (matches Claude's tokenizer)
|
|
47
|
+
TOKENIZER = tiktoken.get_encoding("cl100k_base")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ============================================================================
|
|
51
|
+
# DATA MODELS
|
|
52
|
+
# ============================================================================
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class AssembledContext:
|
|
57
|
+
"""Assembled context ready for LLM consumption.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
content: Formatted context string.
|
|
61
|
+
total_tokens: Total token count.
|
|
62
|
+
structural_tokens: Tokens used for structural metadata.
|
|
63
|
+
content_tokens: Tokens used for code chunks.
|
|
64
|
+
results_included: Number of search results included.
|
|
65
|
+
results_truncated: Number of results truncated due to budget.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
content: str
|
|
69
|
+
total_tokens: int
|
|
70
|
+
structural_tokens: int
|
|
71
|
+
content_tokens: int
|
|
72
|
+
results_included: int
|
|
73
|
+
results_truncated: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ============================================================================
|
|
77
|
+
# TOKEN COUNTING
|
|
78
|
+
# ============================================================================
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def count_tokens(text: str) -> int:
|
|
82
|
+
"""Count tokens in a string using cl100k_base encoding.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
text: String to count tokens for.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Number of tokens.
|
|
89
|
+
"""
|
|
90
|
+
return len(TOKENIZER.encode(text))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ============================================================================
|
|
94
|
+
# STRUCTURAL CONTEXT FORMATTING
|
|
95
|
+
# ============================================================================
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _build_structural_context(results: list[SearchResult]) -> str:
|
|
99
|
+
"""Build structural metadata from search results.
|
|
100
|
+
|
|
101
|
+
Extracts:
|
|
102
|
+
- Unique file paths
|
|
103
|
+
- Language distribution
|
|
104
|
+
- Symbol types present
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
results: Search results.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Formatted structural context string.
|
|
111
|
+
"""
|
|
112
|
+
if not results:
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
# Collect unique metadata
|
|
116
|
+
files = sorted(set(r.path for r in results))
|
|
117
|
+
languages = sorted(set(r.path.split(".")[-1] for r in results if "." in r.path))
|
|
118
|
+
symbol_types = sorted(set(r.chunk_type for r in results if r.chunk_type))
|
|
119
|
+
|
|
120
|
+
# Format structural context
|
|
121
|
+
parts = ["# Search Result Context\n"]
|
|
122
|
+
|
|
123
|
+
parts.append(f"\n## Files ({len(files)} total)")
|
|
124
|
+
for file_path in files:
|
|
125
|
+
parts.append(f"- {file_path}")
|
|
126
|
+
|
|
127
|
+
if languages:
|
|
128
|
+
parts.append("\n## Languages")
|
|
129
|
+
parts.append(f"{', '.join(languages)}")
|
|
130
|
+
|
|
131
|
+
if symbol_types:
|
|
132
|
+
parts.append("\n## Symbol Types")
|
|
133
|
+
parts.append(f"{', '.join(symbol_types)}")
|
|
134
|
+
|
|
135
|
+
parts.append("\n---\n")
|
|
136
|
+
|
|
137
|
+
return "\n".join(parts)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ============================================================================
|
|
141
|
+
# CONTENT FORMATTING
|
|
142
|
+
# ============================================================================
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _format_result(result: SearchResult, index: int) -> str:
|
|
146
|
+
"""Format a single search result as a markdown section.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
result: Search result to format.
|
|
150
|
+
index: Result index (1-based).
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Formatted result string.
|
|
154
|
+
"""
|
|
155
|
+
parts = [f"\n## Result {index} (score: {result.score:.3f})"]
|
|
156
|
+
|
|
157
|
+
# Metadata header
|
|
158
|
+
meta_parts = [
|
|
159
|
+
f"**File:** `{result.path}:{result.start_line}-{result.end_line}`",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
if result.symbol_name:
|
|
163
|
+
meta_parts.append(f"**Symbol:** `{result.symbol_name}` ({result.chunk_type})")
|
|
164
|
+
|
|
165
|
+
if result.bm25_rank is not None and result.dense_rank is not None:
|
|
166
|
+
meta_parts.append(
|
|
167
|
+
f"**Ranking:** BM25 rank {result.bm25_rank}, "
|
|
168
|
+
f"Vector rank {result.dense_rank}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
parts.append("\n".join(meta_parts))
|
|
172
|
+
|
|
173
|
+
# Code snippet
|
|
174
|
+
parts.append("\n```")
|
|
175
|
+
parts.append(result.snippet)
|
|
176
|
+
parts.append("```\n")
|
|
177
|
+
|
|
178
|
+
return "\n".join(parts)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _format_result_truncated(result: SearchResult, index: int, max_tokens: int) -> str:
|
|
182
|
+
"""Format a result with snippet truncated to fit token budget.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
result: Search result to format.
|
|
186
|
+
index: Result index (1-based).
|
|
187
|
+
max_tokens: Maximum tokens allowed for this result.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Formatted result string, potentially with truncated snippet.
|
|
191
|
+
"""
|
|
192
|
+
# Start with full format
|
|
193
|
+
full_format = _format_result(result, index)
|
|
194
|
+
full_tokens = count_tokens(full_format)
|
|
195
|
+
|
|
196
|
+
if full_tokens <= max_tokens:
|
|
197
|
+
return full_format
|
|
198
|
+
|
|
199
|
+
# Need to truncate snippet
|
|
200
|
+
# Calculate overhead from metadata (everything except snippet)
|
|
201
|
+
metadata_format = full_format.replace(result.snippet, "")
|
|
202
|
+
metadata_tokens = count_tokens(metadata_format)
|
|
203
|
+
|
|
204
|
+
# Tokens available for snippet
|
|
205
|
+
available_for_snippet = max_tokens - metadata_tokens
|
|
206
|
+
|
|
207
|
+
if available_for_snippet < 50:
|
|
208
|
+
# Not enough room even for truncated snippet - return just metadata
|
|
209
|
+
return metadata_format.replace("```\n```", "```\n[truncated]\n```")
|
|
210
|
+
|
|
211
|
+
# Binary search to find max snippet length that fits
|
|
212
|
+
snippet = result.snippet
|
|
213
|
+
left, right = 0, len(snippet)
|
|
214
|
+
best_length = 0
|
|
215
|
+
|
|
216
|
+
while left <= right:
|
|
217
|
+
mid = (left + right) // 2
|
|
218
|
+
truncated_snippet = snippet[:mid] + "..."
|
|
219
|
+
test_format = metadata_format.replace("```\n```", f"```\n{truncated_snippet}\n```")
|
|
220
|
+
test_tokens = count_tokens(test_format)
|
|
221
|
+
|
|
222
|
+
if test_tokens <= max_tokens:
|
|
223
|
+
best_length = mid
|
|
224
|
+
left = mid + 1
|
|
225
|
+
else:
|
|
226
|
+
right = mid - 1
|
|
227
|
+
|
|
228
|
+
if best_length == 0:
|
|
229
|
+
# Even minimal truncation doesn't fit
|
|
230
|
+
return metadata_format.replace("```\n```", "```\n[truncated]\n```")
|
|
231
|
+
|
|
232
|
+
truncated_snippet = snippet[:best_length] + "..."
|
|
233
|
+
return metadata_format.replace("```\n```", f"```\n{truncated_snippet}\n```")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ============================================================================
|
|
237
|
+
# PUBLIC API
|
|
238
|
+
# ============================================================================
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def assemble_context(
|
|
242
|
+
results: list[SearchResult],
|
|
243
|
+
token_budget: int = DEFAULT_TOKEN_BUDGET,
|
|
244
|
+
) -> AssembledContext:
|
|
245
|
+
"""Assemble search results into context string with token budget management.
|
|
246
|
+
|
|
247
|
+
Process:
|
|
248
|
+
1. Build structural context (15% of budget)
|
|
249
|
+
2. Format results in rank order (85% of budget)
|
|
250
|
+
3. Truncate snippets if needed to fit budget
|
|
251
|
+
4. Track what was included vs truncated
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
results: Search results ordered by relevance.
|
|
255
|
+
token_budget: Maximum tokens for assembled context.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
AssembledContext with formatted content and metadata.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
RetrievalError: If budget is too small to include any results.
|
|
262
|
+
|
|
263
|
+
Example:
|
|
264
|
+
>>> results = hybrid_search(conn, query, embedding, top_k=10)
|
|
265
|
+
>>> context = assemble_context(results, token_budget=50_000)
|
|
266
|
+
>>> print(context.content) # Ready for LLM
|
|
267
|
+
>>> print(f"Used {context.total_tokens} tokens")
|
|
268
|
+
"""
|
|
269
|
+
if not results:
|
|
270
|
+
return AssembledContext(
|
|
271
|
+
content="No results found.",
|
|
272
|
+
total_tokens=count_tokens("No results found."),
|
|
273
|
+
structural_tokens=0,
|
|
274
|
+
content_tokens=0,
|
|
275
|
+
results_included=0,
|
|
276
|
+
results_truncated=0,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Allocate budgets
|
|
280
|
+
structural_budget = int(token_budget * STRUCTURAL_BUDGET_RATIO)
|
|
281
|
+
content_budget = int(token_budget * CONTENT_BUDGET_RATIO)
|
|
282
|
+
|
|
283
|
+
logger.debug(
|
|
284
|
+
"Assembling context",
|
|
285
|
+
total_budget=token_budget,
|
|
286
|
+
structural_budget=structural_budget,
|
|
287
|
+
content_budget=content_budget,
|
|
288
|
+
result_count=len(results),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Build structural context
|
|
292
|
+
structural_context = _build_structural_context(results)
|
|
293
|
+
structural_tokens = count_tokens(structural_context)
|
|
294
|
+
|
|
295
|
+
if structural_tokens > structural_budget:
|
|
296
|
+
# Structural context is too large - truncate file list
|
|
297
|
+
logger.warning(
|
|
298
|
+
"Structural context exceeds budget",
|
|
299
|
+
structural_tokens=structural_tokens,
|
|
300
|
+
structural_budget=structural_budget,
|
|
301
|
+
)
|
|
302
|
+
# Fallback: just include counts
|
|
303
|
+
structural_context = (
|
|
304
|
+
f"# Search Result Context\n\n"
|
|
305
|
+
f"{len(results)} results found across "
|
|
306
|
+
f"{len(set(r.path for r in results))} files\n\n---\n"
|
|
307
|
+
)
|
|
308
|
+
structural_tokens = count_tokens(structural_context)
|
|
309
|
+
|
|
310
|
+
# Format results with remaining budget
|
|
311
|
+
remaining_budget = content_budget
|
|
312
|
+
formatted_results = []
|
|
313
|
+
results_included = 0
|
|
314
|
+
results_truncated = 0
|
|
315
|
+
|
|
316
|
+
for index, result in enumerate(results, start=1):
|
|
317
|
+
# Try to format this result
|
|
318
|
+
formatted = _format_result(result, index)
|
|
319
|
+
formatted_tokens = count_tokens(formatted)
|
|
320
|
+
|
|
321
|
+
if formatted_tokens <= remaining_budget:
|
|
322
|
+
# Fits fully
|
|
323
|
+
formatted_results.append(formatted)
|
|
324
|
+
remaining_budget -= formatted_tokens
|
|
325
|
+
results_included += 1
|
|
326
|
+
elif remaining_budget > 100:
|
|
327
|
+
# Try truncated version
|
|
328
|
+
truncated = _format_result_truncated(result, index, remaining_budget)
|
|
329
|
+
truncated_tokens = count_tokens(truncated)
|
|
330
|
+
|
|
331
|
+
if truncated_tokens <= remaining_budget:
|
|
332
|
+
formatted_results.append(truncated)
|
|
333
|
+
remaining_budget -= truncated_tokens
|
|
334
|
+
results_included += 1
|
|
335
|
+
results_truncated += 1
|
|
336
|
+
else:
|
|
337
|
+
# Even truncated doesn't fit - stop here
|
|
338
|
+
break
|
|
339
|
+
else:
|
|
340
|
+
# Budget exhausted
|
|
341
|
+
break
|
|
342
|
+
|
|
343
|
+
# Assemble final content
|
|
344
|
+
content_parts = [structural_context]
|
|
345
|
+
content_parts.extend(formatted_results)
|
|
346
|
+
|
|
347
|
+
if results_included < len(results):
|
|
348
|
+
omitted = len(results) - results_included
|
|
349
|
+
content_parts.append(
|
|
350
|
+
f"\n_({omitted} additional results omitted due to token budget)_\n"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
final_content = "\n".join(content_parts)
|
|
354
|
+
total_tokens = count_tokens(final_content)
|
|
355
|
+
|
|
356
|
+
logger.info(
|
|
357
|
+
"Context assembled",
|
|
358
|
+
total_tokens=total_tokens,
|
|
359
|
+
structural_tokens=structural_tokens,
|
|
360
|
+
content_tokens=total_tokens - structural_tokens,
|
|
361
|
+
results_included=results_included,
|
|
362
|
+
results_truncated=results_truncated,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return AssembledContext(
|
|
366
|
+
content=final_content,
|
|
367
|
+
total_tokens=total_tokens,
|
|
368
|
+
structural_tokens=structural_tokens,
|
|
369
|
+
content_tokens=total_tokens - structural_tokens,
|
|
370
|
+
results_included=results_included,
|
|
371
|
+
results_truncated=results_truncated,
|
|
372
|
+
)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Result ranking and diversity filtering.
|
|
2
|
+
|
|
3
|
+
Provides post-processing for search results:
|
|
4
|
+
- deduplicate_results: Content deduplication
|
|
5
|
+
- apply_mmr: Maximal Marginal Relevance diversity
|
|
6
|
+
- rerank_with_cross_encoder: Cross-encoder reranking (Phase 2)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from contextual.observability.logging import get_logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from contextual.retrieval.search import SearchResult
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
# ============================================================================
|
|
23
|
+
# CONSTANTS
|
|
24
|
+
# ============================================================================
|
|
25
|
+
|
|
26
|
+
MMR_LAMBDA = 0.7
|
|
27
|
+
"""Default MMR relevance/diversity tradeoff parameter."""
|
|
28
|
+
|
|
29
|
+
MMR_MAX_PER_FILE = 2
|
|
30
|
+
"""Default maximum number of results per file path."""
|
|
31
|
+
|
|
32
|
+
MMR_MAX_PER_SYMBOL = 1
|
|
33
|
+
"""Default maximum number of results per symbol name."""
|
|
34
|
+
|
|
35
|
+
RERANK_MAX_PAIRS = 30
|
|
36
|
+
"""Maximum query-document pairs for cross-encoder reranking."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def deduplicate_results(results: list[SearchResult]) -> list[SearchResult]:
|
|
40
|
+
"""Remove duplicate snippets while preserving overall ordering.
|
|
41
|
+
|
|
42
|
+
Duplicates are detected by exact snippet string match. For each unique snippet,
|
|
43
|
+
the highest-scoring result is retained. The final output follows the input order
|
|
44
|
+
(stable order) of chosen representatives to avoid introducing rank jitter.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
results: Ranked retrieval results.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Deduplicated result list.
|
|
51
|
+
"""
|
|
52
|
+
if not results:
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
best_score_by_snippet: dict[str, float] = {}
|
|
56
|
+
best_chunk_id_by_snippet: dict[str, int] = {}
|
|
57
|
+
|
|
58
|
+
# Pass 1: determine representative result for each snippet.
|
|
59
|
+
for result in results:
|
|
60
|
+
snippet_key = result.snippet.strip()
|
|
61
|
+
if snippet_key not in best_score_by_snippet:
|
|
62
|
+
best_score_by_snippet[snippet_key] = result.score
|
|
63
|
+
best_chunk_id_by_snippet[snippet_key] = result.chunk_id
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if result.score > best_score_by_snippet[snippet_key]:
|
|
67
|
+
best_score_by_snippet[snippet_key] = result.score
|
|
68
|
+
best_chunk_id_by_snippet[snippet_key] = result.chunk_id
|
|
69
|
+
|
|
70
|
+
# Pass 2: keep only chosen representatives in original order.
|
|
71
|
+
deduplicated: list[SearchResult] = []
|
|
72
|
+
emitted_snippets: set[str] = set()
|
|
73
|
+
for result in results:
|
|
74
|
+
snippet_key = result.snippet.strip()
|
|
75
|
+
if snippet_key in emitted_snippets:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
chosen_chunk_id = best_chunk_id_by_snippet.get(snippet_key)
|
|
79
|
+
if chosen_chunk_id == result.chunk_id:
|
|
80
|
+
deduplicated.append(result)
|
|
81
|
+
emitted_snippets.add(snippet_key)
|
|
82
|
+
|
|
83
|
+
logger.debug(
|
|
84
|
+
"Deduplicated retrieval results",
|
|
85
|
+
input_count=len(results),
|
|
86
|
+
output_count=len(deduplicated),
|
|
87
|
+
duplicates_removed=len(results) - len(deduplicated),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return deduplicated
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def apply_mmr(
|
|
94
|
+
results: list[SearchResult],
|
|
95
|
+
lambda_param: float = MMR_LAMBDA,
|
|
96
|
+
max_per_file: int = MMR_MAX_PER_FILE,
|
|
97
|
+
max_per_symbol: int = MMR_MAX_PER_SYMBOL,
|
|
98
|
+
) -> list[SearchResult]:
|
|
99
|
+
"""Apply diversity filtering constraints over ranked results.
|
|
100
|
+
|
|
101
|
+
Phase 1 uses deterministic quota-based filtering:
|
|
102
|
+
- At most `max_per_file` results per file path.
|
|
103
|
+
- At most `max_per_symbol` results per symbol name.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
results: Ranked retrieval results.
|
|
107
|
+
lambda_param: Reserved MMR tradeoff parameter for future semantic MMR.
|
|
108
|
+
max_per_file: Max results allowed for the same file path.
|
|
109
|
+
max_per_symbol: Max results allowed for the same symbol name.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Diversity-filtered results, preserving input ordering.
|
|
113
|
+
"""
|
|
114
|
+
if not results:
|
|
115
|
+
return []
|
|
116
|
+
if max_per_file <= 0:
|
|
117
|
+
logger.warning("max_per_file must be positive; returning empty result set", value=max_per_file)
|
|
118
|
+
return []
|
|
119
|
+
if max_per_symbol <= 0:
|
|
120
|
+
logger.warning(
|
|
121
|
+
"max_per_symbol must be positive; returning empty result set",
|
|
122
|
+
value=max_per_symbol,
|
|
123
|
+
)
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
# Phase 1 doesn't yet compute pairwise similarity; keep parameter visible.
|
|
127
|
+
if not 0.0 <= lambda_param <= 1.0:
|
|
128
|
+
logger.warning(
|
|
129
|
+
"lambda_param out of range for MMR; clamping",
|
|
130
|
+
original=lambda_param,
|
|
131
|
+
)
|
|
132
|
+
lambda_param = max(0.0, min(1.0, lambda_param))
|
|
133
|
+
|
|
134
|
+
_ = lambda_param # Reserved for true MMR scoring in Phase 2.
|
|
135
|
+
|
|
136
|
+
file_counts: defaultdict[str, int] = defaultdict(int)
|
|
137
|
+
symbol_counts: defaultdict[str, int] = defaultdict(int)
|
|
138
|
+
filtered: list[SearchResult] = []
|
|
139
|
+
|
|
140
|
+
skipped_file_quota = 0
|
|
141
|
+
skipped_symbol_quota = 0
|
|
142
|
+
|
|
143
|
+
for result in results:
|
|
144
|
+
file_key = result.path
|
|
145
|
+
symbol_key = result.symbol_name.strip() if result.symbol_name else ""
|
|
146
|
+
|
|
147
|
+
if file_counts[file_key] >= max_per_file:
|
|
148
|
+
skipped_file_quota += 1
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
if symbol_key and symbol_counts[symbol_key] >= max_per_symbol:
|
|
152
|
+
skipped_symbol_quota += 1
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
filtered.append(result)
|
|
156
|
+
file_counts[file_key] += 1
|
|
157
|
+
if symbol_key:
|
|
158
|
+
symbol_counts[symbol_key] += 1
|
|
159
|
+
|
|
160
|
+
logger.debug(
|
|
161
|
+
"Applied diversity filtering",
|
|
162
|
+
input_count=len(results),
|
|
163
|
+
output_count=len(filtered),
|
|
164
|
+
skipped_file_quota=skipped_file_quota,
|
|
165
|
+
skipped_symbol_quota=skipped_symbol_quota,
|
|
166
|
+
max_per_file=max_per_file,
|
|
167
|
+
max_per_symbol=max_per_symbol,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return filtered
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def rerank_with_cross_encoder(
|
|
174
|
+
query: str,
|
|
175
|
+
results: list[SearchResult],
|
|
176
|
+
top_k: int = RERANK_MAX_PAIRS,
|
|
177
|
+
) -> list[SearchResult]:
|
|
178
|
+
"""Rerank top candidates with a cross-encoder model.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
query: User search query text.
|
|
182
|
+
results: Candidate retrieval results.
|
|
183
|
+
top_k: Maximum candidates to rerank with the cross-encoder.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Reranked results (Phase 2).
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
NotImplementedError: Always raised in Phase 1.
|
|
190
|
+
"""
|
|
191
|
+
_ = (query, results, top_k)
|
|
192
|
+
# TODO(Phase 2): Integrate jina-reranker-v2-base-multilingual over query/chunk pairs.
|
|
193
|
+
raise NotImplementedError("Cross-encoder reranking coming in Phase 2")
|