codegraph-voyage 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.
@@ -0,0 +1,448 @@
1
+ """Ranking: weighted reciprocal-rank fusion (RRF) with pinned candidates.
2
+
3
+ Pinned candidates (exact path matches, exact identifier matches) are always
4
+ included and never displaced by vector or lexical ranking.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ from typing import Any
12
+
13
+
14
+ class RankingResult:
15
+ """A single ranked result with provenance tracking."""
16
+
17
+ def __init__(
18
+ self,
19
+ node_id: str,
20
+ score: float = 0.0,
21
+ *,
22
+ is_pinned: bool = False,
23
+ lexical_score: float | None = None,
24
+ vector_score: float | None = None,
25
+ exact_score: float | None = None,
26
+ path_score: float | None = None,
27
+ name: str = "",
28
+ qualified_name: str = "",
29
+ file_path: str = "",
30
+ node_kind: str = "",
31
+ language: str = "",
32
+ start_line: int | None = None,
33
+ end_line: int | None = None,
34
+ provenance: str | None = None,
35
+ ):
36
+ self.node_id = node_id
37
+ self.score = score
38
+ self.is_pinned = is_pinned
39
+ self.lexical_score = lexical_score
40
+ self.vector_score = vector_score
41
+ self.exact_score = exact_score
42
+ self.path_score = path_score
43
+ self.name = name
44
+ self.qualified_name = qualified_name
45
+ self.file_path = file_path
46
+ self.node_kind = node_kind
47
+ self.language = language
48
+ self.start_line = start_line
49
+ self.end_line = end_line
50
+ self.provenance = provenance or ""
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ """Serialize to a dict for JSON output."""
54
+ return {
55
+ "node_id": self.node_id,
56
+ "score": round(self.score, 6),
57
+ "is_pinned": self.is_pinned,
58
+ "lexical_score": round(self.lexical_score, 6) if self.lexical_score is not None else None,
59
+ "vector_score": round(self.vector_score, 6) if self.vector_score is not None else None,
60
+ "exact_score": round(self.exact_score, 6) if self.exact_score is not None else None,
61
+ "path_score": round(self.path_score, 6) if self.path_score is not None else None,
62
+ "name": self.name,
63
+ "qualified_name": self.qualified_name,
64
+ "file_path": self.file_path,
65
+ "node_kind": self.node_kind,
66
+ "language": self.language,
67
+ "start_line": self.start_line,
68
+ "end_line": self.end_line,
69
+ "provenance": self.provenance,
70
+ }
71
+
72
+
73
+ def reciprocal_rank_fusion(
74
+ ranked_lists: list[list[RankingResult]],
75
+ *,
76
+ weights: list[float] | None = None,
77
+ k: int = 60,
78
+ ) -> list[RankingResult]:
79
+ """Combine multiple ranked lists using weighted reciprocal-rank fusion.
80
+
81
+ Each list contributes its own RRF score per item:
82
+ rrf_score(item, list_i) = weight_i / (k + rank_i(item))
83
+
84
+ where rank_i is 1-based position in list_i. Items not present in a list
85
+ get 0 from that list.
86
+
87
+ Args:
88
+ ranked_lists: List of ranked result lists (each list is of RankingResult).
89
+ weights: Per-list weight. If None, equal weight (1/N).
90
+ k: RRF constant (default 60, the standard value).
91
+
92
+ Returns:
93
+ A single ranked list, sorted by fused score descending.
94
+ """
95
+ n = len(ranked_lists)
96
+ if n == 0:
97
+ return []
98
+
99
+ if weights is None:
100
+ weights = [1.0 / n] * n
101
+ else:
102
+ # Normalize weights
103
+ total = sum(abs(w) for w in weights)
104
+ if total > 0:
105
+ weights = [w / total for w in weights]
106
+ else:
107
+ weights = [1.0 / n] * n
108
+
109
+ # Accumulate fused scores
110
+ fused_map: dict[str, tuple[RankingResult, float]] = {}
111
+
112
+ for lst_idx, lst in enumerate(ranked_lists):
113
+ w = weights[lst_idx]
114
+ for rank, item in enumerate(lst, start=1):
115
+ node_id = item.node_id
116
+ rrf = w / (k + rank)
117
+ if node_id in fused_map:
118
+ existing, score = fused_map[node_id]
119
+ # Merge provenance
120
+ existing.provenance = _merge_provenance(existing.provenance, item.provenance)
121
+ existing.score = score + rrf
122
+ # Track sub-scores
123
+ if item.lexical_score is not None:
124
+ existing.lexical_score = (existing.lexical_score or 0) + item.lexical_score
125
+ if item.vector_score is not None:
126
+ existing.vector_score = (existing.vector_score or 0) + item.vector_score
127
+ if item.exact_score is not None:
128
+ existing.exact_score = (existing.exact_score or 0) + item.exact_score
129
+ if item.path_score is not None:
130
+ existing.path_score = (existing.path_score or 0) + item.path_score
131
+ if item.is_pinned:
132
+ existing.is_pinned = True
133
+ fused_map[node_id] = (existing, existing.score)
134
+ else:
135
+ item.score = rrf
136
+ fused_map[node_id] = (item, rrf)
137
+
138
+ # Sort by fused score descending
139
+ results = sorted(fused_map.values(), key=lambda x: -x[1])
140
+ return [r for r, _ in results]
141
+
142
+
143
+ def _merge_provenance(p1: str, p2: str) -> str:
144
+ """Merge two provenance strings with dedup."""
145
+ parts = set()
146
+ if p1:
147
+ parts.update(p1.split("+"))
148
+ if p2:
149
+ parts.update(p2.split("+"))
150
+ # Filter empty
151
+ parts = {p.strip() for p in parts if p.strip()}
152
+ if not parts:
153
+ return ""
154
+ return "+".join(sorted(parts))
155
+
156
+
157
+ def find_pinned_candidates(
158
+ query: str,
159
+ candidates: list[dict[str, Any]],
160
+ ) -> list[RankingResult]:
161
+ """Find exactly matching identifiers and paths; return as pinned candidates.
162
+
163
+ Pinned candidates match the query as an exact identifier:
164
+ - query matches name exactly (case-insensitive)
165
+ - query matches qualified_name exactly (case-insensitive)
166
+ - query matches file_path exactly (case-insensitive)
167
+ - query matches the file_path basename exactly (case-insensitive)
168
+
169
+ These are always included in the final result set and never displaced.
170
+ """
171
+ query_lower = query.lower().strip()
172
+ if not query_lower:
173
+ return []
174
+ pinned: list[RankingResult] = []
175
+ seen_ids: set[str] = set()
176
+
177
+ for c in candidates:
178
+ node_id = c.get("node_id", "")
179
+ if not node_id or node_id in seen_ids:
180
+ continue
181
+ name = (c.get("name") or "").lower()
182
+ qname = (c.get("qualified_name") or "").lower()
183
+ fpath = (c.get("file_path") or "").replace("\\", "/").lower()
184
+ basename = fpath.rsplit("/", 1)[-1]
185
+ score = 0.0
186
+ provenance_parts: list[str] = []
187
+
188
+ # Exact name match
189
+ if name == query_lower:
190
+ score += 10.0
191
+ provenance_parts.append("exact_name")
192
+
193
+ # Exact qualified name match
194
+ if qname == query_lower:
195
+ score += 10.0
196
+ provenance_parts.append("exact_qname")
197
+
198
+ if fpath == query_lower:
199
+ score += 5.0
200
+ provenance_parts.append("exact_path")
201
+
202
+ if basename == query_lower:
203
+ score += 5.0
204
+ provenance_parts.append("exact_basename")
205
+
206
+ if score > 0:
207
+ seen_ids.add(node_id)
208
+ pinned.append(
209
+ RankingResult(
210
+ node_id=node_id,
211
+ score=score,
212
+ is_pinned=True,
213
+ exact_score=score,
214
+ name=c.get("name", ""),
215
+ qualified_name=c.get("qualified_name", ""),
216
+ file_path=c.get("file_path", ""),
217
+ node_kind=c.get("node_kind", ""),
218
+ language=c.get("language", ""),
219
+ start_line=c.get("start_line"),
220
+ end_line=c.get("end_line"),
221
+ provenance="+".join(provenance_parts),
222
+ )
223
+ )
224
+
225
+ return pinned
226
+
227
+
228
+ def merge_pinned_into_results(
229
+ pinned: list[RankingResult],
230
+ fused: list[RankingResult],
231
+ ) -> list[RankingResult]:
232
+ """Merge pinned candidates into the fused result list.
233
+
234
+ Pinned items are placed at the top (sorted by their exact_score within
235
+ the pinned group), and duplicates are removed from the fused list.
236
+ """
237
+ fused_by_id = {r.node_id: r for r in fused}
238
+ for item in pinned:
239
+ fused_item = fused_by_id.get(item.node_id)
240
+ if fused_item is None:
241
+ continue
242
+ item.provenance = _merge_provenance(item.provenance, fused_item.provenance)
243
+ item.lexical_score = fused_item.lexical_score
244
+ item.vector_score = fused_item.vector_score
245
+ item.path_score = fused_item.path_score if item.path_score is None else item.path_score
246
+ item.score += fused_item.score
247
+ for attr in ("name", "qualified_name", "file_path", "node_kind", "language"):
248
+ if not getattr(item, attr):
249
+ setattr(item, attr, getattr(fused_item, attr))
250
+ if item.start_line is None:
251
+ item.start_line = fused_item.start_line
252
+ if item.end_line is None:
253
+ item.end_line = fused_item.end_line
254
+ pinned_ids = {p.node_id for p in pinned}
255
+ # Deduplicate fused list
256
+ fused_deduped = [r for r in fused if r.node_id not in pinned_ids]
257
+ # Sort pinned by their exact score descending
258
+ pinned_sorted = sorted(pinned, key=lambda p: -(p.exact_score or 0))
259
+ return pinned_sorted + fused_deduped
260
+
261
+
262
+ def cosine_similarity(a: list[float], b: list[float]) -> float:
263
+ """Compute cosine similarity between two vectors."""
264
+ if len(a) != len(b):
265
+ return 0.0
266
+ dot = sum(x * y for x, y in zip(a, b))
267
+ norm_a = math.sqrt(sum(x * x for x in a))
268
+ norm_b = math.sqrt(sum(y * y for y in b))
269
+ if norm_a == 0 or norm_b == 0:
270
+ return 0.0
271
+ return dot / (norm_a * norm_b)
272
+
273
+
274
+ def rank_by_vector_similarity(
275
+ query_vector: list[float],
276
+ candidates: list[dict[str, Any]],
277
+ *,
278
+ top_k: int = 30,
279
+ ) -> list[RankingResult]:
280
+ """Rank candidates by cosine similarity to the query vector.
281
+
282
+ Args:
283
+ query_vector: The query embedding vector.
284
+ candidates: List of dicts with 'node_id', 'embedding', etc.
285
+ top_k: Max results to return.
286
+
287
+ Returns:
288
+ Ranked list of RankingResult with vector_score populated.
289
+ """
290
+ scored: list[tuple[float, dict[str, Any]]] = []
291
+ for c in candidates:
292
+ emb = c.get("embedding")
293
+ if emb is None:
294
+ continue
295
+ sim = cosine_similarity(query_vector, emb)
296
+ scored.append((sim, c))
297
+
298
+ scored.sort(key=lambda x: -x[0])
299
+
300
+ results: list[RankingResult] = []
301
+ for sim, c in scored[:top_k]:
302
+ results.append(
303
+ RankingResult(
304
+ node_id=c.get("node_id", ""),
305
+ score=sim,
306
+ vector_score=sim,
307
+ name=c.get("name", ""),
308
+ qualified_name=c.get("qualified_name", ""),
309
+ file_path=c.get("file_path", ""),
310
+ node_kind=c.get("node_kind", ""),
311
+ language=c.get("language", ""),
312
+ start_line=c.get("start_line"),
313
+ end_line=c.get("end_line"),
314
+ provenance="vector",
315
+ )
316
+ )
317
+ return results
318
+
319
+
320
+ def rank_by_lexical_similarity(
321
+ query: str,
322
+ candidates: list[dict[str, Any]],
323
+ *,
324
+ top_k: int = 30,
325
+ ) -> list[RankingResult]:
326
+ """Rank candidates by lexical (BM25-like) text overlap with the query.
327
+
328
+ Uses a simple TF-IDF-like scoring on the document text for a lightweight
329
+ lexical ranking without external dependencies.
330
+ """
331
+ query_lower = query.lower().strip()
332
+ query_terms = re.findall(r"[a-zA-Z0-9_]+", query_lower)
333
+ if not query_terms:
334
+ # Fall back to per-char matching
335
+ query_terms = [query_lower]
336
+
337
+ scored: list[tuple[float, dict[str, Any]]] = []
338
+ for c in candidates:
339
+ doc_text = (c.get("document_text") or "").lower()
340
+ if not doc_text:
341
+ scored.append((0.0, c))
342
+ continue
343
+
344
+ score = 0.0
345
+ for term in query_terms:
346
+ # Count occurrences
347
+ count = doc_text.count(term)
348
+ if count > 0:
349
+ # Simple TF: log(1 + count)
350
+ tf = math.log(1.0 + count)
351
+ # IDF-like: fewer terms in the query = higher weight
352
+ idf = math.log(1.0 + len(query_terms) / len(set(query_terms)))
353
+ score += tf * idf
354
+
355
+ # Bonus for term in name or qualified_name
356
+ name = (c.get("name") or "").lower()
357
+ qname = (c.get("qualified_name") or "").lower()
358
+ for term in query_terms:
359
+ if term in name:
360
+ score += 2.0
361
+ if term in qname:
362
+ score += 1.0
363
+
364
+ scored.append((score, c))
365
+
366
+ scored.sort(key=lambda x: -x[0])
367
+
368
+ results: list[RankingResult] = []
369
+ for score, c in scored[:top_k]:
370
+ results.append(
371
+ RankingResult(
372
+ node_id=c.get("node_id", ""),
373
+ score=score,
374
+ lexical_score=score,
375
+ name=c.get("name", ""),
376
+ qualified_name=c.get("qualified_name", ""),
377
+ file_path=c.get("file_path", ""),
378
+ node_kind=c.get("node_kind", ""),
379
+ language=c.get("language", ""),
380
+ start_line=c.get("start_line"),
381
+ end_line=c.get("end_line"),
382
+ provenance="lexical",
383
+ )
384
+ )
385
+ return results
386
+
387
+
388
+ def hybrid_search(
389
+ query: str,
390
+ query_vector: list[float] | None,
391
+ vector_candidates: list[dict[str, Any]],
392
+ lexical_candidates: list[dict[str, Any]],
393
+ pinned_candidates: list[RankingResult] | None = None,
394
+ *,
395
+ top_k: int = 20,
396
+ lexical_weight: float = 0.5,
397
+ vector_weight: float = 0.5,
398
+ rrf_k: int = 60,
399
+ ) -> list[RankingResult]:
400
+ """Perform hybrid search with weighted RRF and pinned candidate merging.
401
+
402
+ Args:
403
+ query: The search query string.
404
+ query_vector: Query embedding vector (None for vector-only search).
405
+ vector_candidates: Full list of candidates with embeddings.
406
+ lexical_candidates: Full list of candidates for lexical scoring.
407
+ pinned_candidates: Pre-computed pinned candidates (or None to compute).
408
+ top_k: Max final results.
409
+ lexical_weight: RRF weight for lexical rank list.
410
+ vector_weight: RRF weight for vector rank list.
411
+ rrf_k: RRF constant.
412
+
413
+ Returns:
414
+ Ranked list of RankingResult.
415
+ """
416
+ # Compute pinned if not provided
417
+ if pinned_candidates is None:
418
+ pinned_candidates = find_pinned_candidates(query, lexical_candidates)
419
+
420
+ # Build ranked lists
421
+ ranked_lists: list[list[RankingResult]] = []
422
+ weights: list[float] = []
423
+
424
+ if query_vector is not None:
425
+ vec_results = rank_by_vector_similarity(
426
+ query_vector, vector_candidates, top_k=top_k * 2
427
+ )
428
+ if vec_results:
429
+ ranked_lists.append(vec_results)
430
+ weights.append(vector_weight)
431
+
432
+ lex_results = rank_by_lexical_similarity(
433
+ query, lexical_candidates, top_k=top_k * 2
434
+ )
435
+ if lex_results:
436
+ ranked_lists.append(lex_results)
437
+ weights.append(lexical_weight)
438
+
439
+ # Fuse
440
+ if ranked_lists:
441
+ fused = reciprocal_rank_fusion(ranked_lists, weights=weights, k=rrf_k)
442
+ else:
443
+ fused = []
444
+
445
+ # Merge pinned
446
+ final_results = merge_pinned_into_results(pinned_candidates, fused)
447
+
448
+ return final_results[:top_k]
@@ -0,0 +1,116 @@
1
+ """Path and content sanitization — exclude sensitive paths before remote transmission."""
2
+
3
+ import re
4
+
5
+ # Patterns that match sensitive paths to exclude from embedding content.
6
+ # Each pattern is compiled and checked against the relative file path.
7
+ SENSITIVE_PATH_PATTERNS: list[re.Pattern] = [
8
+ re.compile(r"(?:^|/)\.env(?:\..*)?$", re.IGNORECASE),
9
+ re.compile(r"(?:^|/)\.aws/(?:credentials|config)$", re.IGNORECASE),
10
+ re.compile(r"(?:^|/)\.ssh/", re.IGNORECASE),
11
+ re.compile(r"(?:^|/)\.docker/config\.json$", re.IGNORECASE),
12
+ re.compile(r"(?:^|/)\.(?:kube|gnupg)/", re.IGNORECASE),
13
+ re.compile(r"(?:^|/)(?:credentials|secrets)(?:[^/]*)?(?:/|$)", re.IGNORECASE),
14
+ re.compile(r"(?:^|/)(?:private_key[^/]*|id_[^/]+|[^/]*_(?:rsa|dsa|ecdsa|ed25519))$", re.IGNORECASE),
15
+ re.compile(r"(?:^|/)\.(?:pgpass|netrc|npmrc|pypirc|git-credentials)$", re.IGNORECASE),
16
+ re.compile(r"(?:^|/)(?:tokens\.json|service-account[^/]*\.json|vault-token)$", re.IGNORECASE),
17
+ re.compile(r"(?:^|/)(?:secret|password|private_key|token|api_key|keyring)[^/]*$", re.IGNORECASE),
18
+ re.compile(r"\.(?:pem|key|p12|pfx|crt|cer|jks|keystore)$", re.IGNORECASE),
19
+ re.compile(r"(?:^|/)\.git/"),
20
+ re.compile(r"(?:^|/)__pycache__/"),
21
+ re.compile(r"(?:^|/)node_modules/"),
22
+ re.compile(r"(?:^|/)\.venv/"),
23
+ re.compile(r"(?:^|/)venv/"),
24
+ re.compile(r"(?:^|/)\.codegraph/"),
25
+ re.compile(r"(?:^|/)\.hermes/"),
26
+ re.compile(r"(?:^|/)runtime/"),
27
+ re.compile(r"(?:^|/)target/"),
28
+ re.compile(r"(?:^|/)dist/"),
29
+ re.compile(r"(?:^|/)build/"),
30
+ ]
31
+
32
+ # Line-level patterns: skip embedding of lines that match these.
33
+ SENSITIVE_LINE_PATTERNS: list[re.Pattern] = [
34
+ re.compile(
35
+ r"(?:^|[,{\s])['\"]?(?:password|passwd|secret|api_key|apikey|auth_token|"
36
+ r"access_token|refresh_token|private_key|client_secret|aws_secret_access_key|token)"
37
+ r"['\"]?\s*(?:=>|->|=|:)\s*(?P<value>.+?)\s*$",
38
+ re.IGNORECASE,
39
+ ),
40
+ re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", re.IGNORECASE),
41
+ ]
42
+
43
+ _PRIVATE_KEY_END = re.compile(r"-----END [A-Z0-9 ]*PRIVATE KEY-----", re.IGNORECASE)
44
+ _PLACEHOLDER_VALUES = re.compile(
45
+ r"^(?:['\"])?(?:|placeholder|changeme|change[-_ ]?me|example|dummy|sample|"
46
+ r"redacted|masked|none|null|nil|x+|\*+|your[-_ ].*|<[^>]+>|\$\{[^}]+\})"
47
+ r"(?:['\"])?$",
48
+ re.IGNORECASE,
49
+ )
50
+
51
+
52
+ def _is_obvious_placeholder(value: str) -> bool:
53
+ normalized = value.strip().rstrip(",").rstrip()
54
+ # A JSON object contributes its closing brace after the quoted value.
55
+ if normalized.endswith("}") and normalized[:1] in {"'", '"'}:
56
+ normalized = normalized[:-1].rstrip()
57
+ return bool(_PLACEHOLDER_VALUES.fullmatch(normalized))
58
+
59
+
60
+ def is_sensitive_path(rel_path: str) -> bool:
61
+ """Return True if rel_path matches any sensitive-path pattern."""
62
+ str_path = rel_path.replace("\\", "/")
63
+ for pat in SENSITIVE_PATH_PATTERNS:
64
+ if pat.search(str_path):
65
+ return True
66
+ return False
67
+
68
+
69
+ def sanitize_content(content: str, rel_path: str = "") -> str:
70
+ """Remove lines that match sensitive patterns from content.
71
+
72
+ Returns the sanitized content. Lines are replaced with a comment marker.
73
+ If the entire document is consumed (all lines removed), a placeholder is
74
+ returned so the embedding is not empty.
75
+ """
76
+ if is_sensitive_path(rel_path):
77
+ return "[content excluded: sensitive path]"
78
+
79
+ lines = content.splitlines(keepends=True)
80
+ kept: list[str] = []
81
+ in_private_key = False
82
+ for line in lines:
83
+ stripped = line.strip()
84
+ if not stripped:
85
+ kept.append(line)
86
+ continue
87
+ if in_private_key:
88
+ kept.append("# [redacted by codegraph-voyage sanitizer]\n")
89
+ if _PRIVATE_KEY_END.search(stripped):
90
+ in_private_key = False
91
+ continue
92
+
93
+ is_sensitive = False
94
+ for pat in SENSITIVE_LINE_PATTERNS:
95
+ match = pat.search(stripped)
96
+ if not match:
97
+ continue
98
+ if "BEGIN" in stripped.upper() and "PRIVATE KEY" in stripped.upper():
99
+ in_private_key = True
100
+ is_sensitive = True
101
+ break
102
+ value = match.groupdict().get("value")
103
+ if value is None or not _is_obvious_placeholder(value):
104
+ is_sensitive = True
105
+ break
106
+ if is_sensitive:
107
+ kept.append(f"# [redacted by codegraph-voyage sanitizer]\n")
108
+ else:
109
+ kept.append(line)
110
+
111
+ result = "".join(kept).strip()
112
+ return result if result else "[content excluded: all lines redacted]"
113
+
114
+
115
+ # Exported list of path patterns (for documentation / debugging).
116
+ EXCLUDED_PATH_PATTERNS = [p.pattern for p in SENSITIVE_PATH_PATTERNS]