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.
- codegraph_voyage/__init__.py +8 -0
- codegraph_voyage/__main__.py +5 -0
- codegraph_voyage/cli.py +691 -0
- codegraph_voyage/document.py +238 -0
- codegraph_voyage/explore.py +148 -0
- codegraph_voyage/mcp_server.py +78 -0
- codegraph_voyage/providers.py +275 -0
- codegraph_voyage/ranking.py +448 -0
- codegraph_voyage/sanitize.py +116 -0
- codegraph_voyage/sidecar.py +325 -0
- codegraph_voyage/tests/__init__.py +1 -0
- codegraph_voyage/tests/benchmark.py +278 -0
- codegraph_voyage/tests/test_all.py +1114 -0
- codegraph_voyage-0.1.0.dist-info/METADATA +196 -0
- codegraph_voyage-0.1.0.dist-info/RECORD +17 -0
- codegraph_voyage-0.1.0.dist-info/WHEEL +4 -0
- codegraph_voyage-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Sidecar SQLite database for embedding storage.
|
|
2
|
+
|
|
3
|
+
The sidecar stores embeddings keyed by node identity, source content hash,
|
|
4
|
+
model, dimensions, and dtype. It supports incremental indexing (skip unchanged
|
|
5
|
+
records, remove stale records) and segregated indices for incompatible metadata.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .providers import EmbeddingProvider
|
|
16
|
+
|
|
17
|
+
SCHEMA_VERSION = 2
|
|
18
|
+
|
|
19
|
+
SIDECAR_SCHEMA = """
|
|
20
|
+
CREATE TABLE IF NOT EXISTS sidecar_meta (
|
|
21
|
+
key TEXT PRIMARY KEY,
|
|
22
|
+
value TEXT NOT NULL
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
26
|
+
node_id TEXT NOT NULL,
|
|
27
|
+
source_content_hash TEXT NOT NULL,
|
|
28
|
+
model TEXT NOT NULL,
|
|
29
|
+
dimensions INTEGER NOT NULL,
|
|
30
|
+
dtype TEXT NOT NULL DEFAULT 'float32',
|
|
31
|
+
embedding BLOB NOT NULL,
|
|
32
|
+
node_kind TEXT,
|
|
33
|
+
name TEXT,
|
|
34
|
+
qualified_name TEXT,
|
|
35
|
+
file_path TEXT,
|
|
36
|
+
language TEXT,
|
|
37
|
+
start_line INTEGER,
|
|
38
|
+
end_line INTEGER,
|
|
39
|
+
document_text TEXT,
|
|
40
|
+
created_at INTEGER NOT NULL,
|
|
41
|
+
PRIMARY KEY (node_id, source_content_hash, model, dimensions, dtype)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_embeddings_file_path
|
|
45
|
+
ON embeddings(file_path);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_embeddings_name
|
|
47
|
+
ON embeddings(name);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_embeddings_content_hash
|
|
49
|
+
ON embeddings(source_content_hash);
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SidecarError(Exception):
|
|
54
|
+
"""Raised on sidecar DB operations."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SidecarDB:
|
|
58
|
+
"""Manages the sidecar SQLite database for embedding storage.
|
|
59
|
+
|
|
60
|
+
The sidecar is read/write. The CodeGraph DB is always opened read-only.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, path: str | Path):
|
|
64
|
+
self._path = Path(path)
|
|
65
|
+
self._conn: sqlite3.Connection | None = None
|
|
66
|
+
|
|
67
|
+
def open(self) -> None:
|
|
68
|
+
"""Open (or create) the sidecar database and apply schema."""
|
|
69
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self._conn = sqlite3.connect(str(self._path))
|
|
71
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
72
|
+
self._conn.execute("PRAGMA synchronous = NORMAL")
|
|
73
|
+
self._migrate_legacy_primary_key()
|
|
74
|
+
self._conn.executescript(SIDECAR_SCHEMA)
|
|
75
|
+
# Track schema version
|
|
76
|
+
cur = self._conn.execute(
|
|
77
|
+
"SELECT value FROM sidecar_meta WHERE key = 'schema_version'"
|
|
78
|
+
)
|
|
79
|
+
row = cur.fetchone()
|
|
80
|
+
if row is None:
|
|
81
|
+
self._conn.execute(
|
|
82
|
+
"INSERT INTO sidecar_meta (key, value) VALUES ('schema_version', ?)",
|
|
83
|
+
(str(SCHEMA_VERSION),),
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
self._conn.execute(
|
|
87
|
+
"UPDATE sidecar_meta SET value = ? WHERE key = 'schema_version'",
|
|
88
|
+
(str(SCHEMA_VERSION),),
|
|
89
|
+
)
|
|
90
|
+
self._conn.commit()
|
|
91
|
+
|
|
92
|
+
def _migrate_legacy_primary_key(self) -> None:
|
|
93
|
+
"""Upgrade the v1 key without discarding existing embeddings."""
|
|
94
|
+
exists = self.conn.execute(
|
|
95
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'embeddings'"
|
|
96
|
+
).fetchone()
|
|
97
|
+
if not exists:
|
|
98
|
+
return
|
|
99
|
+
pk_columns = [
|
|
100
|
+
row[1]
|
|
101
|
+
for row in sorted(
|
|
102
|
+
(row for row in self.conn.execute("PRAGMA table_info(embeddings)") if row[5]),
|
|
103
|
+
key=lambda row: row[5],
|
|
104
|
+
)
|
|
105
|
+
]
|
|
106
|
+
expected = ["node_id", "source_content_hash", "model", "dimensions", "dtype"]
|
|
107
|
+
if pk_columns == expected:
|
|
108
|
+
return
|
|
109
|
+
with self.conn:
|
|
110
|
+
self.conn.execute("DROP INDEX IF EXISTS idx_embeddings_file_path")
|
|
111
|
+
self.conn.execute("DROP INDEX IF EXISTS idx_embeddings_name")
|
|
112
|
+
self.conn.execute("DROP INDEX IF EXISTS idx_embeddings_content_hash")
|
|
113
|
+
self.conn.execute("ALTER TABLE embeddings RENAME TO embeddings_legacy")
|
|
114
|
+
self.conn.executescript(SIDECAR_SCHEMA)
|
|
115
|
+
self.conn.execute(
|
|
116
|
+
"""INSERT OR REPLACE INTO embeddings
|
|
117
|
+
SELECT node_id, source_content_hash, model, dimensions, dtype,
|
|
118
|
+
embedding, node_kind, name, qualified_name, file_path,
|
|
119
|
+
language, start_line, end_line, document_text, created_at
|
|
120
|
+
FROM embeddings_legacy"""
|
|
121
|
+
)
|
|
122
|
+
self.conn.execute("DROP TABLE embeddings_legacy")
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
if self._conn:
|
|
126
|
+
self._conn.close()
|
|
127
|
+
self._conn = None
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def conn(self) -> sqlite3.Connection:
|
|
131
|
+
if self._conn is None:
|
|
132
|
+
raise SidecarError("SidecarDB not opened. Call open() first.")
|
|
133
|
+
return self._conn
|
|
134
|
+
|
|
135
|
+
def store_embeddings(
|
|
136
|
+
self,
|
|
137
|
+
records: list[dict[str, Any]],
|
|
138
|
+
provider: EmbeddingProvider,
|
|
139
|
+
) -> int:
|
|
140
|
+
"""Store embedding records, replacing existing ones.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
records: List of dicts with keys:
|
|
144
|
+
node_id, content_hash, embedding (list[float]), node_kind,
|
|
145
|
+
name, qualified_name, file_path, language, start_line,
|
|
146
|
+
end_line, document_text
|
|
147
|
+
provider: The embedding provider (for model/dimensions metadata).
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Number of records stored.
|
|
151
|
+
"""
|
|
152
|
+
now = int(time.time())
|
|
153
|
+
model = provider.model_name
|
|
154
|
+
dims = provider.dimensions
|
|
155
|
+
raw_bytes = provider.raw_bytes
|
|
156
|
+
|
|
157
|
+
count = 0
|
|
158
|
+
# Validate the complete batch before entering one atomic transaction.
|
|
159
|
+
for rec in records:
|
|
160
|
+
if len(rec["embedding"]) != dims:
|
|
161
|
+
raise SidecarError(
|
|
162
|
+
f"Embedding dimension mismatch for node {rec['node_id']}: "
|
|
163
|
+
f"expected {dims}, got {len(rec['embedding'])}"
|
|
164
|
+
)
|
|
165
|
+
with self.conn:
|
|
166
|
+
for rec in records:
|
|
167
|
+
emb_bytes = raw_bytes(rec["embedding"])
|
|
168
|
+
self.conn.execute(
|
|
169
|
+
"""INSERT OR REPLACE INTO embeddings
|
|
170
|
+
(node_id, source_content_hash, model, dimensions, dtype,
|
|
171
|
+
embedding, node_kind, name, qualified_name, file_path,
|
|
172
|
+
language, start_line, end_line, document_text, created_at)
|
|
173
|
+
VALUES (?, ?, ?, ?, 'float32', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
174
|
+
(
|
|
175
|
+
rec["node_id"], rec["content_hash"], model, dims, emb_bytes,
|
|
176
|
+
rec.get("node_kind", ""), rec.get("name", ""),
|
|
177
|
+
rec.get("qualified_name", ""), rec.get("file_path", ""),
|
|
178
|
+
rec.get("language", ""), rec.get("start_line"),
|
|
179
|
+
rec.get("end_line"), rec.get("document_text", ""), now,
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
self.conn.execute(
|
|
183
|
+
"""DELETE FROM embeddings
|
|
184
|
+
WHERE node_id = ? AND model = ? AND dimensions = ?
|
|
185
|
+
AND dtype = 'float32' AND source_content_hash <> ?""",
|
|
186
|
+
(rec["node_id"], model, dims, rec["content_hash"]),
|
|
187
|
+
)
|
|
188
|
+
count += 1
|
|
189
|
+
return count
|
|
190
|
+
|
|
191
|
+
def remove_stale_records(
|
|
192
|
+
self,
|
|
193
|
+
current_ids: set[str],
|
|
194
|
+
provider: EmbeddingProvider,
|
|
195
|
+
) -> int:
|
|
196
|
+
"""Remove embeddings for nodes no longer in the index.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
current_ids: Set of node IDs that are current.
|
|
200
|
+
provider: The embedding provider (for model/dimensions filter).
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Number of records removed.
|
|
204
|
+
"""
|
|
205
|
+
if not current_ids:
|
|
206
|
+
result = self.conn.execute(
|
|
207
|
+
"""DELETE FROM embeddings
|
|
208
|
+
WHERE model = ? AND dimensions = ? AND dtype = 'float32'""",
|
|
209
|
+
(provider.model_name, provider.dimensions),
|
|
210
|
+
)
|
|
211
|
+
self.conn.commit()
|
|
212
|
+
return result.rowcount
|
|
213
|
+
placeholders = ",".join("?" for _ in current_ids)
|
|
214
|
+
result = self.conn.execute(
|
|
215
|
+
f"""DELETE FROM embeddings
|
|
216
|
+
WHERE model = ? AND dimensions = ? AND dtype = 'float32'
|
|
217
|
+
AND node_id NOT IN ({placeholders})""",
|
|
218
|
+
(provider.model_name, provider.dimensions, *current_ids),
|
|
219
|
+
)
|
|
220
|
+
self.conn.commit()
|
|
221
|
+
return result.rowcount
|
|
222
|
+
|
|
223
|
+
def find_changed_nodes(
|
|
224
|
+
self,
|
|
225
|
+
documents: list[dict[str, Any]],
|
|
226
|
+
provider: EmbeddingProvider,
|
|
227
|
+
) -> list[dict[str, Any]]:
|
|
228
|
+
"""Return only documents whose content hash has changed or are new.
|
|
229
|
+
|
|
230
|
+
Compares against existing embeddings for the same model/dimensions.
|
|
231
|
+
"""
|
|
232
|
+
model = provider.model_name
|
|
233
|
+
dims = provider.dimensions
|
|
234
|
+
result: list[dict[str, Any]] = []
|
|
235
|
+
for doc in documents:
|
|
236
|
+
row = self.conn.execute(
|
|
237
|
+
"""SELECT 1 FROM embeddings
|
|
238
|
+
WHERE node_id = ? AND source_content_hash = ?
|
|
239
|
+
AND model = ? AND dimensions = ? AND dtype = 'float32'""",
|
|
240
|
+
(doc["node_id"], doc["content_hash"], model, dims),
|
|
241
|
+
).fetchone()
|
|
242
|
+
if row is None:
|
|
243
|
+
result.append(doc)
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
def get_embedding(
|
|
247
|
+
self, node_id: str, provider: EmbeddingProvider
|
|
248
|
+
) -> list[float] | None:
|
|
249
|
+
"""Retrieve a single embedding by node ID."""
|
|
250
|
+
row = self.conn.execute(
|
|
251
|
+
"""SELECT embedding FROM embeddings
|
|
252
|
+
WHERE node_id = ? AND model = ? AND dimensions = ? AND dtype = 'float32'""",
|
|
253
|
+
(node_id, provider.model_name, provider.dimensions),
|
|
254
|
+
).fetchone()
|
|
255
|
+
if row is None:
|
|
256
|
+
return None
|
|
257
|
+
return provider.from_bytes(row[0])
|
|
258
|
+
|
|
259
|
+
def get_all_embeddings(
|
|
260
|
+
self, provider: EmbeddingProvider
|
|
261
|
+
) -> list[dict[str, Any]]:
|
|
262
|
+
"""Retrieve all embeddings for the given provider."""
|
|
263
|
+
rows = self.conn.execute(
|
|
264
|
+
"""SELECT node_id, embedding, name, qualified_name, file_path,
|
|
265
|
+
start_line, end_line, node_kind, language, document_text
|
|
266
|
+
FROM embeddings
|
|
267
|
+
WHERE model = ? AND dimensions = ? AND dtype = 'float32'""",
|
|
268
|
+
(provider.model_name, provider.dimensions),
|
|
269
|
+
).fetchall()
|
|
270
|
+
results: list[dict[str, Any]] = []
|
|
271
|
+
for row in rows:
|
|
272
|
+
results.append({
|
|
273
|
+
"node_id": row[0],
|
|
274
|
+
"embedding": provider.from_bytes(row[1]),
|
|
275
|
+
"name": row[2] or "",
|
|
276
|
+
"qualified_name": row[3] or "",
|
|
277
|
+
"file_path": row[4] or "",
|
|
278
|
+
"start_line": row[5],
|
|
279
|
+
"end_line": row[6],
|
|
280
|
+
"node_kind": row[7] or "",
|
|
281
|
+
"language": row[8] or "",
|
|
282
|
+
"document_text": row[9] or "",
|
|
283
|
+
})
|
|
284
|
+
return results
|
|
285
|
+
|
|
286
|
+
def get_status(self) -> dict[str, Any]:
|
|
287
|
+
"""Return status information about the sidecar."""
|
|
288
|
+
if self._conn is None:
|
|
289
|
+
return {"connected": False}
|
|
290
|
+
try:
|
|
291
|
+
total = self.conn.execute(
|
|
292
|
+
"SELECT COUNT(*) FROM embeddings"
|
|
293
|
+
).fetchone()[0]
|
|
294
|
+
models = [
|
|
295
|
+
list(r)
|
|
296
|
+
for r in self.conn.execute(
|
|
297
|
+
"SELECT model, dimensions, dtype, COUNT(*) FROM embeddings "
|
|
298
|
+
"GROUP BY model, dimensions, dtype"
|
|
299
|
+
).fetchall()
|
|
300
|
+
]
|
|
301
|
+
schema_v = self.conn.execute(
|
|
302
|
+
"SELECT value FROM sidecar_meta WHERE key = 'schema_version'"
|
|
303
|
+
).fetchone()
|
|
304
|
+
return {
|
|
305
|
+
"connected": True,
|
|
306
|
+
"path": str(self._path),
|
|
307
|
+
"total_embeddings": total,
|
|
308
|
+
"model_groups": models,
|
|
309
|
+
"schema_version": int(schema_v[0]) if schema_v else None,
|
|
310
|
+
}
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
return {"connected": True, "error": str(exc)}
|
|
313
|
+
|
|
314
|
+
def clear(self, provider: EmbeddingProvider | None = None) -> int:
|
|
315
|
+
"""Clear embeddings, optionally filtered by provider."""
|
|
316
|
+
if provider:
|
|
317
|
+
result = self.conn.execute(
|
|
318
|
+
"""DELETE FROM embeddings
|
|
319
|
+
WHERE model = ? AND dimensions = ? AND dtype = 'float32'""",
|
|
320
|
+
(provider.model_name, provider.dimensions),
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
result = self.conn.execute("DELETE FROM embeddings")
|
|
324
|
+
self.conn.commit()
|
|
325
|
+
return result.rowcount
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tests for codegraph-voyage."""
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Benchmark for codegraph-voyage retrieval quality.
|
|
3
|
+
|
|
4
|
+
This is a **smoke benchmark** — the corpus is small and synthetic, so no
|
|
5
|
+
quality-lift claims are made. It tests that the three retrieval strategies
|
|
6
|
+
(lexical, vector, fused) produce measurable output and that fused can
|
|
7
|
+
improve over the worst single-strategy for at least one conceptual case.
|
|
8
|
+
|
|
9
|
+
Metrics reported:
|
|
10
|
+
- Recall@5
|
|
11
|
+
- MRR (Mean Reciprocal Rank)
|
|
12
|
+
- NDCG@10
|
|
13
|
+
|
|
14
|
+
Run:
|
|
15
|
+
python -m tools.codegraph_voyage.tests.benchmark [--verbose]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import math
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
# Make sure the project root is importable
|
|
28
|
+
# This script is at tools/codegraph_voyage/tests/benchmark.py
|
|
29
|
+
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
|
30
|
+
if str(_PROJECT_ROOT) not in sys.path:
|
|
31
|
+
sys.path.insert(0, str(_PROJECT_ROOT))
|
|
32
|
+
|
|
33
|
+
from tools.codegraph_voyage.providers import FakeEmbeddingProvider
|
|
34
|
+
from tools.codegraph_voyage.ranking import (
|
|
35
|
+
RankingResult,
|
|
36
|
+
reciprocal_rank_fusion,
|
|
37
|
+
rank_by_lexical_similarity,
|
|
38
|
+
rank_by_vector_similarity,
|
|
39
|
+
find_pinned_candidates,
|
|
40
|
+
merge_pinned_into_results,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Fixture: a small synthetic corpus of symbol documents with ground-truth
|
|
46
|
+
# relevance judgments per query.
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
FIXTURE_DOCS: list[dict[str, Any]] = [
|
|
50
|
+
{
|
|
51
|
+
"node_id": "d1", "name": "AuthService", "qualified_name": "app.auth.AuthService",
|
|
52
|
+
"file_path": "src/auth/service.py", "node_kind": "class", "language": "python",
|
|
53
|
+
"start_line": 1, "end_line": 50,
|
|
54
|
+
"document_text": "AuthService handles user authentication login logout password hashing JWT token generation and session management",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"node_id": "d2", "name": "UserModel", "qualified_name": "app.models.UserModel",
|
|
58
|
+
"file_path": "src/models/user.py", "node_kind": "class", "language": "python",
|
|
59
|
+
"start_line": 1, "end_line": 80,
|
|
60
|
+
"document_text": "UserModel represents a user account with fields for username email password_hash and profile data",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"node_id": "d3", "name": "PaymentGateway", "qualified_name": "app.payments.PaymentGateway",
|
|
64
|
+
"file_path": "src/payments/gateway.py", "node_kind": "class", "language": "python",
|
|
65
|
+
"start_line": 1, "end_line": 120,
|
|
66
|
+
"document_text": "PaymentGateway processes credit card payments refunds and subscription billing via Stripe API",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"node_id": "d4", "name": "InvoiceGenerator", "qualified_name": "app.billing.InvoiceGenerator",
|
|
70
|
+
"file_path": "src/billing/invoice.py", "node_kind": "class", "language": "python",
|
|
71
|
+
"start_line": 1, "end_line": 60,
|
|
72
|
+
"document_text": "InvoiceGenerator creates PDF invoices for completed payments and sends them via email",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"node_id": "d5", "name": "login", "qualified_name": "app.auth.login",
|
|
76
|
+
"file_path": "src/auth/views.py", "node_kind": "function", "language": "python",
|
|
77
|
+
"start_line": 10, "end_line": 25,
|
|
78
|
+
"document_text": "login view function authenticates user credentials and returns a JWT token",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"node_id": "d6", "name": "hash_password", "qualified_name": "app.auth.hash_password",
|
|
82
|
+
"file_path": "src/auth/utils.py", "node_kind": "function", "language": "python",
|
|
83
|
+
"start_line": 5, "end_line": 15,
|
|
84
|
+
"document_text": "hash_password takes a plaintext password and returns a bcrypt hash for secure storage",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"node_id": "d7", "name": "send_email", "qualified_name": "app.notifications.send_email",
|
|
88
|
+
"file_path": "src/notifications/email.py", "node_kind": "function", "language": "python",
|
|
89
|
+
"start_line": 1, "end_line": 30,
|
|
90
|
+
"document_text": "send_email dispatches transactional emails via SMTP for invoices and notifications",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"node_id": "d8", "name": "generate_report", "qualified_name": "app.reports.generate_report",
|
|
94
|
+
"file_path": "src/reports/generator.py", "node_kind": "function", "language": "python",
|
|
95
|
+
"start_line": 1, "end_line": 45,
|
|
96
|
+
"document_text": "generate_report produces CSV and JSON reports from database query results for business analytics",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"node_id": "d9", "name": "DatabaseConnection", "qualified_name": "app.db.DatabaseConnection",
|
|
100
|
+
"file_path": "src/db/connection.py", "node_kind": "class", "language": "python",
|
|
101
|
+
"start_line": 1, "end_line": 90,
|
|
102
|
+
"document_text": "DatabaseConnection manages connection pooling SQL query execution and transaction management for PostgreSQL",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"node_id": "d10", "name": "CacheManager", "qualified_name": "app.cache.CacheManager",
|
|
106
|
+
"file_path": "src/cache/manager.py", "node_kind": "class", "language": "python",
|
|
107
|
+
"start_line": 1, "end_line": 60,
|
|
108
|
+
"document_text": "CacheManager provides Redis-backed caching for frequently accessed database queries and API responses",
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
# Queries with ground-truth relevant node IDs (ordered by decreasing relevance)
|
|
113
|
+
QUERIES: list[tuple[str, list[str], str]] = [
|
|
114
|
+
("user authentication login", ["d1", "d5", "d2", "d6"], "auth-related symbols"),
|
|
115
|
+
("payment processing billing", ["d3", "d4", "d7", "d1"], "payment/billing"),
|
|
116
|
+
("database query caching", ["d9", "d10", "d8"], "data layer"),
|
|
117
|
+
("email notification invoice", ["d7", "d4", "d2"], "email/billing"),
|
|
118
|
+
("AuthService", ["d1", "d5", "d6"], "exact identifier AuthService"),
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def dcg(relevances: list[float]) -> float:
|
|
123
|
+
"""Discounted cumulative gain."""
|
|
124
|
+
return sum(
|
|
125
|
+
rel / math.log2(i + 2) if i > 0 else rel
|
|
126
|
+
for i, rel in enumerate(relevances)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def ndcg(ranked_ids: list[str], relevant: set[str], k: int = 10) -> float:
|
|
131
|
+
"""NDCG@k."""
|
|
132
|
+
ranked = ranked_ids[:k]
|
|
133
|
+
# Binary relevance: 1 if in relevant set
|
|
134
|
+
relevances = [1.0 if rid in relevant else 0.0 for rid in ranked]
|
|
135
|
+
ideal = sorted(relevances, reverse=True)
|
|
136
|
+
dcg_val = dcg(relevances)
|
|
137
|
+
idcg_val = dcg(ideal)
|
|
138
|
+
return dcg_val / idcg_val if idcg_val > 0 else 0.0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def recall_at_k(ranked_ids: list[str], relevant: set[str], k: int = 5) -> float:
|
|
142
|
+
"""Recall@k."""
|
|
143
|
+
if not relevant:
|
|
144
|
+
return 0.0
|
|
145
|
+
found = sum(1 for rid in ranked_ids[:k] if rid in relevant)
|
|
146
|
+
return found / len(relevant)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def mrr(ranked_ids: list[str], relevant: set[str]) -> float:
|
|
150
|
+
"""Mean Reciprocal Rank."""
|
|
151
|
+
for i, rid in enumerate(ranked_ids):
|
|
152
|
+
if rid in relevant:
|
|
153
|
+
return 1.0 / (i + 1)
|
|
154
|
+
return 0.0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def run_benchmark(verbose: bool = False) -> dict[str, Any]:
|
|
158
|
+
"""Run benchmark and return metrics."""
|
|
159
|
+
provider = FakeEmbeddingProvider(dimensions=64)
|
|
160
|
+
|
|
161
|
+
# Pre-compute embeddings for all docs
|
|
162
|
+
texts = [d["document_text"] for d in FIXTURE_DOCS]
|
|
163
|
+
embeddings = provider.embed_documents(texts, input_type="document")
|
|
164
|
+
|
|
165
|
+
# Build candidate dicts for ranking functions
|
|
166
|
+
vector_candidates: list[dict[str, Any]] = []
|
|
167
|
+
lexical_candidates: list[dict[str, Any]] = []
|
|
168
|
+
for doc, emb in zip(FIXTURE_DOCS, embeddings):
|
|
169
|
+
entry = {
|
|
170
|
+
"node_id": doc["node_id"],
|
|
171
|
+
"name": doc["name"],
|
|
172
|
+
"qualified_name": doc["qualified_name"],
|
|
173
|
+
"file_path": doc["file_path"],
|
|
174
|
+
"node_kind": doc["node_kind"],
|
|
175
|
+
"language": doc["language"],
|
|
176
|
+
"start_line": doc["start_line"],
|
|
177
|
+
"end_line": doc["end_line"],
|
|
178
|
+
"document_text": doc["document_text"],
|
|
179
|
+
"embedding": emb,
|
|
180
|
+
}
|
|
181
|
+
vector_candidates.append(entry)
|
|
182
|
+
lexical_candidates.append({k: v for k, v in entry.items() if k != "embedding"})
|
|
183
|
+
|
|
184
|
+
all_metrics: dict[str, dict[str, float]] = {
|
|
185
|
+
"lexical": {"recall@5": 0.0, "mrr": 0.0, "ndcg@10": 0.0},
|
|
186
|
+
"vector": {"recall@5": 0.0, "mrr": 0.0, "ndcg@10": 0.0},
|
|
187
|
+
"fused": {"recall@5": 0.0, "mrr": 0.0, "ndcg@10": 0.0},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for query_str, relevant_ids, label in QUERIES:
|
|
191
|
+
relevant = set(relevant_ids)
|
|
192
|
+
query_vector = provider.embed_query(query_str, input_type="query")
|
|
193
|
+
|
|
194
|
+
# Lexical
|
|
195
|
+
lex_results = rank_by_lexical_similarity(query_str, lexical_candidates, top_k=10)
|
|
196
|
+
lex_ids = [r.node_id for r in lex_results]
|
|
197
|
+
|
|
198
|
+
# Vector
|
|
199
|
+
vec_results = rank_by_vector_similarity(query_vector, vector_candidates, top_k=10)
|
|
200
|
+
vec_ids = [r.node_id for r in vec_results]
|
|
201
|
+
|
|
202
|
+
# Fused (RRF)
|
|
203
|
+
fused = reciprocal_rank_fusion([vec_results, lex_results], weights=[0.5, 0.5], k=60)
|
|
204
|
+
# Merge pinned
|
|
205
|
+
pinned = find_pinned_candidates(query_str, lexical_candidates)
|
|
206
|
+
if pinned:
|
|
207
|
+
fused = merge_pinned_into_results(pinned, fused)
|
|
208
|
+
fused_ids = [r.node_id for r in fused]
|
|
209
|
+
|
|
210
|
+
if verbose:
|
|
211
|
+
print(f"\nQuery: {query_str!r} ({label})")
|
|
212
|
+
print(f" Lexical top-5: {lex_ids[:5]}")
|
|
213
|
+
print(f" Vector top-5: {vec_ids[:5]}")
|
|
214
|
+
print(f" Fused top-5: {fused_ids[:5]}")
|
|
215
|
+
print(f" Relevant: {relevant_ids}")
|
|
216
|
+
|
|
217
|
+
for strategy_name, ids in [("lexical", lex_ids), ("vector", vec_ids), ("fused", fused_ids)]:
|
|
218
|
+
all_metrics[strategy_name]["recall@5"] += recall_at_k(ids, relevant, k=5)
|
|
219
|
+
all_metrics[strategy_name]["mrr"] += mrr(ids, relevant)
|
|
220
|
+
all_metrics[strategy_name]["ndcg@10"] += ndcg(ids, relevant, k=10)
|
|
221
|
+
|
|
222
|
+
n = len(QUERIES)
|
|
223
|
+
for strategy in all_metrics:
|
|
224
|
+
for metric in all_metrics[strategy]:
|
|
225
|
+
all_metrics[strategy][metric] = round(all_metrics[strategy][metric] / n, 4)
|
|
226
|
+
|
|
227
|
+
return all_metrics
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main() -> int:
|
|
231
|
+
from pathlib import Path # noqa: F811
|
|
232
|
+
|
|
233
|
+
ap = argparse.ArgumentParser(description="codegraph-voyage benchmark")
|
|
234
|
+
ap.add_argument("--verbose", "-v", action="store_true", help="Show per-query details")
|
|
235
|
+
args = ap.parse_args()
|
|
236
|
+
|
|
237
|
+
print("=" * 60)
|
|
238
|
+
print("codegraph-voyage retrieval benchmark (smoke)")
|
|
239
|
+
print("=" * 60)
|
|
240
|
+
print(f"Corpus: {len(FIXTURE_DOCS)} documents")
|
|
241
|
+
print(f"Queries: {len(QUERIES)}")
|
|
242
|
+
print(f"Provider: FakeEmbeddingProvider(dimensions=64)")
|
|
243
|
+
print()
|
|
244
|
+
|
|
245
|
+
t0 = time.time()
|
|
246
|
+
metrics = run_benchmark(verbose=args.verbose)
|
|
247
|
+
elapsed = time.time() - t0
|
|
248
|
+
|
|
249
|
+
print(f"\nResults ({elapsed:.2f}s):")
|
|
250
|
+
print(f"{'Strategy':<10} {'Recall@5':>10} {'MRR':>10} {'NDCG@10':>10}")
|
|
251
|
+
print("-" * 42)
|
|
252
|
+
for strategy in ["lexical", "vector", "fused"]:
|
|
253
|
+
m = metrics[strategy]
|
|
254
|
+
print(f"{strategy:<10} {m['recall@5']:>10.4f} {m['mrr']:>10.4f} {m['ndcg@10']:>10.4f}")
|
|
255
|
+
|
|
256
|
+
# Compare like-for-like metrics against the stronger single strategy.
|
|
257
|
+
comparisons = [
|
|
258
|
+
metrics["fused"][metric] >= max(metrics["lexical"][metric], metrics["vector"][metric])
|
|
259
|
+
for metric in ("recall@5", "mrr", "ndcg@10")
|
|
260
|
+
]
|
|
261
|
+
strict_improvement = any(
|
|
262
|
+
metrics["fused"][metric] > max(metrics["lexical"][metric], metrics["vector"][metric])
|
|
263
|
+
for metric in ("recall@5", "mrr", "ndcg@10")
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if all(comparisons) and strict_improvement:
|
|
267
|
+
print("\n✓ Fused improves like-for-like over the single strategies.")
|
|
268
|
+
else:
|
|
269
|
+
print("\n⚠ Fused did not improve like-for-like on all metrics (smoke corpus limitation).")
|
|
270
|
+
|
|
271
|
+
print("\n⚠ Caveat: This is a SMOKE benchmark with a tiny synthetic corpus.")
|
|
272
|
+
print(" No quality-lift claims should be made from these results.")
|
|
273
|
+
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
raise SystemExit(main())
|