codepulse-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.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Sequence
|
|
4
|
+
from codepulse.models import Symbol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SearchResultItem:
|
|
8
|
+
"""A scored result item returned from the hybrid search engine."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
doc_id: str,
|
|
13
|
+
symbol_name: str,
|
|
14
|
+
file_path: str,
|
|
15
|
+
symbol_type: str,
|
|
16
|
+
start_line: int,
|
|
17
|
+
end_line: int,
|
|
18
|
+
signature: str = "",
|
|
19
|
+
docstring: str | None = None,
|
|
20
|
+
snippet: str = "",
|
|
21
|
+
language: str = "",
|
|
22
|
+
final_score: float = 0.0,
|
|
23
|
+
keyword_score: float = 0.0,
|
|
24
|
+
vector_score: float = 0.0,
|
|
25
|
+
embedding_distance: float = 1.0,
|
|
26
|
+
graph_score: float = 0.0,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.doc_id = doc_id
|
|
29
|
+
self.symbol_name = symbol_name
|
|
30
|
+
self.file_path = file_path
|
|
31
|
+
self.symbol_type = symbol_type
|
|
32
|
+
self.start_line = start_line
|
|
33
|
+
self.end_line = end_line
|
|
34
|
+
self.signature = signature
|
|
35
|
+
self.docstring = docstring
|
|
36
|
+
self.snippet = snippet
|
|
37
|
+
self.language = language
|
|
38
|
+
self.final_score = final_score
|
|
39
|
+
self.keyword_score = keyword_score
|
|
40
|
+
self.vector_score = vector_score
|
|
41
|
+
self.embedding_distance = embedding_distance
|
|
42
|
+
self.graph_score = graph_score
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"doc_id": self.doc_id,
|
|
47
|
+
"symbol_name": self.symbol_name,
|
|
48
|
+
"file_path": self.file_path,
|
|
49
|
+
"type": self.symbol_type,
|
|
50
|
+
"start_line": self.start_line,
|
|
51
|
+
"end_line": self.end_line,
|
|
52
|
+
"signature": self.signature,
|
|
53
|
+
"docstring": self.docstring,
|
|
54
|
+
"snippet": self.snippet,
|
|
55
|
+
"language": self.language,
|
|
56
|
+
"relevance_score": round(self.final_score, 4),
|
|
57
|
+
"keyword_score": round(self.keyword_score, 4),
|
|
58
|
+
"vector_score": round(self.vector_score, 4),
|
|
59
|
+
"embedding_distance": round(self.embedding_distance, 4),
|
|
60
|
+
"graph_score": round(self.graph_score, 4),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HybridRanker:
|
|
65
|
+
"""
|
|
66
|
+
Merges and ranks candidate results from BM25, Dense Vector Search, and the Call Graph.
|
|
67
|
+
|
|
68
|
+
Supports:
|
|
69
|
+
1. Weighted Linear Fusion (40% keyword + 40% vector + 20% graph + exact match boost)
|
|
70
|
+
2. Reciprocal Rank Fusion (RRF)
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
weight_keyword: float | None = None,
|
|
76
|
+
weight_vector: float | None = None,
|
|
77
|
+
weight_graph: float | None = None,
|
|
78
|
+
exact_match_boost: float | None = None,
|
|
79
|
+
rrf_k: int | None = None,
|
|
80
|
+
vector_floor_rank: int | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
from codepulse.config import get_settings
|
|
83
|
+
settings = get_settings()
|
|
84
|
+
self.weight_keyword = weight_keyword if weight_keyword is not None else settings.weight_keyword
|
|
85
|
+
self.weight_vector = weight_vector if weight_vector is not None else settings.weight_vector
|
|
86
|
+
self.weight_graph = weight_graph if weight_graph is not None else settings.weight_graph
|
|
87
|
+
self.exact_match_boost = (
|
|
88
|
+
exact_match_boost if exact_match_boost is not None else settings.exact_match_boost
|
|
89
|
+
)
|
|
90
|
+
# Phase 2.3 (roadmap §37 item 2.3: "...selectable via API
|
|
91
|
+
# search_type/config") -- resolved once at construction, matching
|
|
92
|
+
# every other tunable ranking parameter above, rather than read
|
|
93
|
+
# fresh from settings on every fuse_rrf() call.
|
|
94
|
+
self.rrf_k = rrf_k if rrf_k is not None else settings.rrf_k
|
|
95
|
+
self.vector_floor_rank = (
|
|
96
|
+
vector_floor_rank if vector_floor_rank is not None else settings.vector_floor_rank
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _vector_scores_for_fusion(self, vector_results: list[dict[str, Any]]) -> dict[str, float]:
|
|
100
|
+
"""
|
|
101
|
+
The semantic score each vector candidate contributes to weighted fusion
|
|
102
|
+
(FUTURE_CHANGES item 20, part 2).
|
|
103
|
+
|
|
104
|
+
The lexical score is relative to the query's best keyword hit; a raw
|
|
105
|
+
cosine similarity is absolute and, for real code, sits in a narrow band
|
|
106
|
+
(the old `(cosine + 1) / 2` lived in roughly 0.5..0.9), so weighting the
|
|
107
|
+
two 0.4 / 0.4 compressed the semantic signal. Here the score is
|
|
108
|
+
`(sim - floor) / (top - floor)` clipped to 0..1: `top` is the best
|
|
109
|
+
similarity for this query, `floor` the similarity of the hit at
|
|
110
|
+
`vector_floor_rank`. Hits ranked below the floor therefore never change
|
|
111
|
+
anyone's score, so a result scores the same whatever `limit` was asked
|
|
112
|
+
for, and input order is irrelevant. With no spread (a single candidate,
|
|
113
|
+
or all equal) every candidate with positive similarity scores 1.0
|
|
114
|
+
rather than being zeroed. `vector_floor_rank == 0` is the old absolute
|
|
115
|
+
scaling. The value REPORTED on a result (`vector_score`) is always the
|
|
116
|
+
real, unscaled one; only the fusion input is scaled.
|
|
117
|
+
"""
|
|
118
|
+
if self.vector_floor_rank <= 0:
|
|
119
|
+
return {it["doc_id"]: it.get("relevance_score", 0.0) for it in vector_results}
|
|
120
|
+
|
|
121
|
+
def sim_of(it: dict[str, Any]) -> float:
|
|
122
|
+
sim = it.get("similarity")
|
|
123
|
+
if sim is None:
|
|
124
|
+
sim = 2.0 * it.get("relevance_score", 0.5) - 1.0
|
|
125
|
+
return max(0.0, sim)
|
|
126
|
+
|
|
127
|
+
sims = sorted((sim_of(it) for it in vector_results), reverse=True)
|
|
128
|
+
if not sims:
|
|
129
|
+
return {}
|
|
130
|
+
top = sims[0]
|
|
131
|
+
floor = sims[min(self.vector_floor_rank, len(sims)) - 1]
|
|
132
|
+
span = top - floor
|
|
133
|
+
scaled: dict[str, float] = {}
|
|
134
|
+
for it in vector_results:
|
|
135
|
+
sim = sim_of(it)
|
|
136
|
+
if top <= 0.0:
|
|
137
|
+
scaled[it["doc_id"]] = 0.0
|
|
138
|
+
elif span <= 1e-12:
|
|
139
|
+
scaled[it["doc_id"]] = 1.0
|
|
140
|
+
else:
|
|
141
|
+
scaled[it["doc_id"]] = min(1.0, max(0.0, (sim - floor) / span))
|
|
142
|
+
return scaled
|
|
143
|
+
|
|
144
|
+
def _build_result_item(
|
|
145
|
+
self,
|
|
146
|
+
doc_id: str,
|
|
147
|
+
metadata: dict[str, Any],
|
|
148
|
+
*,
|
|
149
|
+
final_score: float,
|
|
150
|
+
keyword_score: float = 0.0,
|
|
151
|
+
vector_score: float = 0.0,
|
|
152
|
+
embedding_distance: float = 1.0,
|
|
153
|
+
graph_score: float = 0.0,
|
|
154
|
+
) -> SearchResultItem:
|
|
155
|
+
"""
|
|
156
|
+
Reconstructs a `SearchResultItem` from a candidate's metadata dict
|
|
157
|
+
(the same shape `BM25Index`/`VectorIndex` attach to every result --
|
|
158
|
+
`symbol_name`, `file_path`, `type`, `start_line`, `end_line`,
|
|
159
|
+
`signature`, `docstring`, `snippet`, `language`) plus whatever
|
|
160
|
+
score fields the calling fusion strategy already computed.
|
|
161
|
+
|
|
162
|
+
Phase 2.3 (roadmap §36/§37 item 2.3: "wire the already-implemented
|
|
163
|
+
(but unused) RRF fusion path to be selectable via the API") --
|
|
164
|
+
shared by both `fuse()` (weighted linear fusion) and `fuse_rrf()`
|
|
165
|
+
(Reciprocal Rank Fusion) so this metadata-to-result-object mapping
|
|
166
|
+
is never duplicated between the two fusion strategies. `fuse()`'s
|
|
167
|
+
own metadata-reconstruction logic is unchanged in behavior; it now
|
|
168
|
+
simply calls this shared helper instead of inlining the same
|
|
169
|
+
field-by-field extraction itself.
|
|
170
|
+
"""
|
|
171
|
+
meta = metadata
|
|
172
|
+
return SearchResultItem(
|
|
173
|
+
doc_id=doc_id,
|
|
174
|
+
symbol_name=meta.get("symbol_name", ""),
|
|
175
|
+
file_path=meta.get("file_path", ""),
|
|
176
|
+
symbol_type=meta.get("type", "function"),
|
|
177
|
+
start_line=meta.get("start_line", 1),
|
|
178
|
+
end_line=meta.get("end_line", 1),
|
|
179
|
+
signature=meta.get("signature", ""),
|
|
180
|
+
docstring=meta.get("docstring"),
|
|
181
|
+
snippet=meta.get("snippet", meta.get("signature", "") or meta.get("symbol_name", "")),
|
|
182
|
+
language=meta.get("language", ""),
|
|
183
|
+
final_score=final_score,
|
|
184
|
+
keyword_score=keyword_score,
|
|
185
|
+
vector_score=vector_score,
|
|
186
|
+
embedding_distance=embedding_distance,
|
|
187
|
+
graph_score=graph_score,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def rank_by_similarity(self, vector_results: list[dict[str, Any]], top_k: int = 20) -> list[SearchResultItem]:
|
|
191
|
+
"""
|
|
192
|
+
Semantic-only search: results ordered purely by vector similarity.
|
|
193
|
+
|
|
194
|
+
Nothing is fused here, so there is no keyword/graph term and no
|
|
195
|
+
exact-name boost, and the score reported is the real relevance of the
|
|
196
|
+
match. (It used to go through `fuse()`: the score became
|
|
197
|
+
`0.4 x relevance + 0.15 boost`, a narrow 0.2-0.36 band in which a
|
|
198
|
+
name that merely appeared inside the query text jumped past much closer
|
|
199
|
+
matches; on a second codebase MRR was 0.272 against 0.551 without the
|
|
200
|
+
boost.) Ties break on `doc_id`, so the order is deterministic.
|
|
201
|
+
"""
|
|
202
|
+
ordered = sorted(vector_results, key=lambda it: (-it.get("relevance_score", 0.0), it["doc_id"]))
|
|
203
|
+
return [
|
|
204
|
+
self._build_result_item(
|
|
205
|
+
it["doc_id"],
|
|
206
|
+
it.get("metadata", {}),
|
|
207
|
+
final_score=it.get("relevance_score", 0.0),
|
|
208
|
+
vector_score=it.get("relevance_score", 0.0),
|
|
209
|
+
embedding_distance=it.get("embedding_distance", 1.0),
|
|
210
|
+
)
|
|
211
|
+
for it in ordered[:top_k]
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
def fuse(
|
|
215
|
+
self,
|
|
216
|
+
query: str,
|
|
217
|
+
bm25_results: list[dict[str, Any]],
|
|
218
|
+
vector_results: list[dict[str, Any]],
|
|
219
|
+
graph_scores: dict[str, float] | None = None, # doc_id -> score in [0, 1]
|
|
220
|
+
top_k: int = 20,
|
|
221
|
+
) -> list[SearchResultItem]:
|
|
222
|
+
"""
|
|
223
|
+
Fuses BM25 and Vector search results using weighted linear combination.
|
|
224
|
+
The semantic score is scaled relative to the query's own results before
|
|
225
|
+
it is weighted against the lexical score (`_vector_scores_for_fusion`).
|
|
226
|
+
"""
|
|
227
|
+
graph_scores = graph_scores or {}
|
|
228
|
+
query_lower = query.lower().strip()
|
|
229
|
+
fusion_vec_scores = self._vector_scores_for_fusion(vector_results)
|
|
230
|
+
|
|
231
|
+
# Collect all unique candidate doc_ids
|
|
232
|
+
candidates: dict[str, dict[str, Any]] = {}
|
|
233
|
+
|
|
234
|
+
# 1. Process BM25 matches
|
|
235
|
+
for item in bm25_results:
|
|
236
|
+
doc_id = item["doc_id"]
|
|
237
|
+
candidates[doc_id] = {
|
|
238
|
+
"metadata": item.get("metadata", {}),
|
|
239
|
+
"kw_score": item.get("normalized_score", 0.0),
|
|
240
|
+
"vec_score": 0.0,
|
|
241
|
+
"emb_dist": 1.0,
|
|
242
|
+
"graph_score": graph_scores.get(doc_id, 0.0),
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# 2. Process Vector matches
|
|
246
|
+
for item in vector_results:
|
|
247
|
+
doc_id = item["doc_id"]
|
|
248
|
+
if doc_id not in candidates:
|
|
249
|
+
candidates[doc_id] = {
|
|
250
|
+
"metadata": item.get("metadata", {}),
|
|
251
|
+
"kw_score": 0.0,
|
|
252
|
+
"vec_score": item.get("relevance_score", 0.0),
|
|
253
|
+
"emb_dist": item.get("embedding_distance", 1.0),
|
|
254
|
+
"graph_score": graph_scores.get(doc_id, 0.0),
|
|
255
|
+
}
|
|
256
|
+
else:
|
|
257
|
+
candidates[doc_id]["vec_score"] = item.get("relevance_score", 0.0)
|
|
258
|
+
candidates[doc_id]["emb_dist"] = item.get("embedding_distance", 1.0)
|
|
259
|
+
if not candidates[doc_id]["metadata"] and item.get("metadata"):
|
|
260
|
+
candidates[doc_id]["metadata"] = item["metadata"]
|
|
261
|
+
|
|
262
|
+
scored_items: list[SearchResultItem] = []
|
|
263
|
+
|
|
264
|
+
for doc_id, data in candidates.items():
|
|
265
|
+
meta = data["metadata"]
|
|
266
|
+
sym_name = meta.get("symbol_name", "")
|
|
267
|
+
|
|
268
|
+
kw_score = data["kw_score"]
|
|
269
|
+
vec_score = data["vec_score"]
|
|
270
|
+
emb_dist = data["emb_dist"]
|
|
271
|
+
g_score = data["graph_score"]
|
|
272
|
+
|
|
273
|
+
# Compute weighted combination
|
|
274
|
+
combined = (
|
|
275
|
+
self.weight_keyword * kw_score
|
|
276
|
+
+ self.weight_vector * fusion_vec_scores.get(doc_id, 0.0)
|
|
277
|
+
+ self.weight_graph * g_score
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Apply exact match boost if the query explicitly names the symbol
|
|
281
|
+
if sym_name and sym_name.lower() in query_lower:
|
|
282
|
+
combined += self.exact_match_boost
|
|
283
|
+
elif query_lower in sym_name.lower():
|
|
284
|
+
combined += self.exact_match_boost * 0.5
|
|
285
|
+
|
|
286
|
+
final_score = min(1.0, combined)
|
|
287
|
+
|
|
288
|
+
scored_items.append(
|
|
289
|
+
self._build_result_item(
|
|
290
|
+
doc_id,
|
|
291
|
+
meta,
|
|
292
|
+
final_score=final_score,
|
|
293
|
+
keyword_score=kw_score,
|
|
294
|
+
vector_score=vec_score,
|
|
295
|
+
embedding_distance=emb_dist,
|
|
296
|
+
graph_score=g_score,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Sort descending by final score
|
|
301
|
+
scored_items.sort(key=lambda x: x.final_score, reverse=True)
|
|
302
|
+
return scored_items[:top_k]
|
|
303
|
+
|
|
304
|
+
def fuse_rrf(
|
|
305
|
+
self,
|
|
306
|
+
bm25_results: list[dict[str, Any]],
|
|
307
|
+
vector_results: list[dict[str, Any]],
|
|
308
|
+
k: int | None = None,
|
|
309
|
+
top_k: int = 20,
|
|
310
|
+
) -> list[SearchResultItem]:
|
|
311
|
+
"""
|
|
312
|
+
Reciprocal Rank Fusion (RRF) combiner.
|
|
313
|
+
Formula: RRF_score(d) = sum(1 / (k + rank_i(d)))
|
|
314
|
+
|
|
315
|
+
`k` defaults to `self.rrf_k` (itself resolved from
|
|
316
|
+
`settings.rrf_k`, default 60 -- the conventional constant from the
|
|
317
|
+
original RRF paper) when not explicitly overridden by the caller.
|
|
318
|
+
|
|
319
|
+
Phase 2.3 (roadmap §36/§37 item 2.3): returns `list[SearchResultItem]`
|
|
320
|
+
-- the same return type `fuse()` already produces, and the shape
|
|
321
|
+
`IndexingEngine.search()`/`/v1/search`'s `[r.to_dict() for r in
|
|
322
|
+
results]` already expect -- rather than the raw
|
|
323
|
+
`{"doc_id", "rrf_score", "metadata"}` dicts this method returned
|
|
324
|
+
before this milestone wired it up to the API. The fusion algorithm
|
|
325
|
+
itself (the RRF formula/ranking below) is unchanged; only the
|
|
326
|
+
final step -- turning a ranked (doc_id, score) list into full
|
|
327
|
+
result objects -- was added, reusing `_build_result_item` so this
|
|
328
|
+
mapping is never duplicated against `fuse()`'s own.
|
|
329
|
+
|
|
330
|
+
`final_score`/`relevance_score` on the returned items is the real,
|
|
331
|
+
computed RRF score (unbounded above 0, not rescaled/clamped to
|
|
332
|
+
[0, 1] the way `fuse()`'s weighted-linear score is) -- reporting
|
|
333
|
+
the genuine computed value rather than inventing a normalization
|
|
334
|
+
the roadmap does not ask for (CLAUDE.md: no fabricated metrics).
|
|
335
|
+
`keyword_score`/`vector_score` are still populated from each
|
|
336
|
+
candidate's own real, already-computed per-source score (the same
|
|
337
|
+
values `fuse()` reads), even though RRF itself combines by rank,
|
|
338
|
+
not by these scores -- for transparency, not because RRF's own
|
|
339
|
+
formula uses them. `graph_score` is always 0.0: `fuse_rrf` does
|
|
340
|
+
not accept a `graph_scores` argument (unchanged from before this
|
|
341
|
+
milestone -- adding call-graph fusion to RRF would be a ranking
|
|
342
|
+
algorithm change, out of this milestone's scope, which only wires
|
|
343
|
+
up the already-implemented, unmodified RRF formula).
|
|
344
|
+
"""
|
|
345
|
+
effective_k = k if k is not None else self.rrf_k
|
|
346
|
+
|
|
347
|
+
rrf_scores: dict[str, float] = {}
|
|
348
|
+
metadata_map: dict[str, dict[str, Any]] = {}
|
|
349
|
+
kw_score_map: dict[str, float] = {}
|
|
350
|
+
vec_score_map: dict[str, float] = {}
|
|
351
|
+
emb_dist_map: dict[str, float] = {}
|
|
352
|
+
|
|
353
|
+
for rank, item in enumerate(bm25_results, start=1):
|
|
354
|
+
doc_id = item["doc_id"]
|
|
355
|
+
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + (1.0 / (effective_k + rank))
|
|
356
|
+
metadata_map[doc_id] = item.get("metadata", {})
|
|
357
|
+
kw_score_map[doc_id] = item.get("normalized_score", 0.0)
|
|
358
|
+
|
|
359
|
+
for rank, item in enumerate(vector_results, start=1):
|
|
360
|
+
doc_id = item["doc_id"]
|
|
361
|
+
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + (1.0 / (effective_k + rank))
|
|
362
|
+
if doc_id not in metadata_map:
|
|
363
|
+
metadata_map[doc_id] = item.get("metadata", {})
|
|
364
|
+
vec_score_map[doc_id] = item.get("relevance_score", 0.0)
|
|
365
|
+
emb_dist_map[doc_id] = item.get("embedding_distance", 1.0)
|
|
366
|
+
|
|
367
|
+
sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
|
|
368
|
+
|
|
369
|
+
results: list[SearchResultItem] = []
|
|
370
|
+
for doc_id, score in sorted_docs[:top_k]:
|
|
371
|
+
results.append(
|
|
372
|
+
self._build_result_item(
|
|
373
|
+
doc_id,
|
|
374
|
+
metadata_map.get(doc_id, {}),
|
|
375
|
+
final_score=score,
|
|
376
|
+
keyword_score=kw_score_map.get(doc_id, 0.0),
|
|
377
|
+
vector_score=vec_score_map.get(doc_id, 0.0),
|
|
378
|
+
embedding_distance=emb_dist_map.get(doc_id, 1.0),
|
|
379
|
+
graph_score=0.0,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
return results
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
# Regex to split camelCase / PascalCase and snake_case tokens
|
|
7
|
+
_CAMEL_SPLIT_REGEX = re.compile(r"([a-z0-9])([A-Z])|([A-Z]+)([A-Z][a-z])")
|
|
8
|
+
_NON_ALPHANUM_REGEX = re.compile(r"[^a-zA-Z0-9_]")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def split_identifier(identifier: str) -> list[str]:
|
|
12
|
+
"""
|
|
13
|
+
Splits a programming identifier into its constituent words.
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
'createDbConnection' -> ['create', 'Db', 'Connection']
|
|
17
|
+
'POSTGRES_POOL_SIZE' -> ['POSTGRES', 'POOL', 'SIZE']
|
|
18
|
+
'ASTVisitorV2' -> ['AST', 'Visitor', 'V2']
|
|
19
|
+
'get_user_by_id' -> ['get', 'user', 'by', 'id']
|
|
20
|
+
"""
|
|
21
|
+
if not identifier:
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
# First split by underscores
|
|
25
|
+
parts = identifier.split("_")
|
|
26
|
+
sub_words: list[str] = []
|
|
27
|
+
|
|
28
|
+
for part in parts:
|
|
29
|
+
if not part:
|
|
30
|
+
continue
|
|
31
|
+
# Split camelCase / PascalCase
|
|
32
|
+
# e.g., 'createDbConnection' -> 'create Db Connection'
|
|
33
|
+
s = _CAMEL_SPLIT_REGEX.sub(r"\1\3 \2\4", part)
|
|
34
|
+
for w in s.split():
|
|
35
|
+
if w:
|
|
36
|
+
sub_words.append(w)
|
|
37
|
+
|
|
38
|
+
return sub_words
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tokenize_code(text: str, preserve_case: bool = False) -> list[str]:
|
|
42
|
+
"""
|
|
43
|
+
Code-aware tokenizer for BM25 and lexical search.
|
|
44
|
+
|
|
45
|
+
Produces both:
|
|
46
|
+
1. Full raw tokens (e.g., 'createDbConnection')
|
|
47
|
+
2. Split sub-tokens (e.g., 'create', 'db', 'connection')
|
|
48
|
+
|
|
49
|
+
This ensures that searches for both exact identifiers ('createDbConnection')
|
|
50
|
+
and sub-components ('connection') match with high relevance.
|
|
51
|
+
"""
|
|
52
|
+
if not text:
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
# Replace non-alphanumeric (except underscores) with spaces
|
|
56
|
+
cleaned = _NON_ALPHANUM_REGEX.sub(" ", text)
|
|
57
|
+
raw_tokens = cleaned.split()
|
|
58
|
+
|
|
59
|
+
tokens: list[str] = []
|
|
60
|
+
seen: set[str] = set()
|
|
61
|
+
|
|
62
|
+
for raw in raw_tokens:
|
|
63
|
+
# Filter out purely numeric or single-character noisy tokens
|
|
64
|
+
if len(raw) == 1 and not raw.isalpha():
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
base = raw if preserve_case else raw.lower()
|
|
68
|
+
if base not in seen:
|
|
69
|
+
tokens.append(base)
|
|
70
|
+
seen.add(base)
|
|
71
|
+
|
|
72
|
+
# Generate sub-tokens
|
|
73
|
+
sub_tokens = split_identifier(raw)
|
|
74
|
+
for st in sub_tokens:
|
|
75
|
+
sub = st if preserve_case else st.lower()
|
|
76
|
+
if sub and len(sub) > 1 and sub not in seen:
|
|
77
|
+
tokens.append(sub)
|
|
78
|
+
seen.add(sub)
|
|
79
|
+
|
|
80
|
+
return tokens
|