From 7814b3b4ac16f1c7bc54c7f79ba72504afa8527b Mon Sep 17 00:00:00 2001 From: kelmith Date: Mon, 20 Jul 2026 11:54:55 +0530 Subject: [PATCH 1/3] fix: over-fetch vector KNN before post-filtering to prevent recall collapse --- mcp/src/graph/neo4j.ts | 3 +++ mcp/src/graph/queries.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mcp/src/graph/neo4j.ts b/mcp/src/graph/neo4j.ts index fd535f2f4..e2d4b025c 100644 --- a/mcp/src/graph/neo4j.ts +++ b/mcp/src/graph/neo4j.ts @@ -505,6 +505,9 @@ class Db { const result = await session.run(Q.VECTOR_SEARCH_QUERY, { embeddings, limit, + // KNN k must exceed the requested limit: post-filters (score >= 0.4, + // labels, paths) consume neighbor slots and there is no refetch. + knn_k: Math.max(limit * 10, 100), node_types, skip_node_types, extensions, diff --git a/mcp/src/graph/queries.ts b/mcp/src/graph/queries.ts index e84f20f31..213383cb6 100644 --- a/mcp/src/graph/queries.ts +++ b/mcp/src/graph/queries.ts @@ -547,7 +547,7 @@ ${NODE_TYPES} `; export const VECTOR_SEARCH_QUERY = ` -CALL db.index.vector.queryNodes('${VECTOR_INDEX}', toInteger($limit), $embeddings) +CALL db.index.vector.queryNodes('${VECTOR_INDEX}', toInteger($knn_k), $embeddings) YIELD node, score WITH node, score WHERE score >= 0.4 @@ -578,6 +578,7 @@ WHERE score >= 0.4 END RETURN node, score ORDER BY score DESC +LIMIT toInteger($limit) `; export const FIND_NODE_QUERY = ` From 4227699c8b1e56f080c72754ab032119fead0384 Mon Sep 17 00:00:00 2001 From: kelmith Date: Mon, 20 Jul 2026 12:01:48 +0530 Subject: [PATCH 2/3] refactor: replace score-weighted RRF k=5 fusion with standard RRF k=60 --- mcp/src/graph/graph.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/mcp/src/graph/graph.ts b/mcp/src/graph/graph.ts index 677b2e2f3..db1a7f704 100644 --- a/mcp/src/graph/graph.ts +++ b/mcp/src/graph/graph.ts @@ -75,7 +75,9 @@ export async function get_edges( export type OutputFormat = "snippet" | "json"; -const RRF_K = 5; +// Standard reciprocal-rank-fusion constant (Cormack et al.); high k keeps +// contributions flat across ranks so neither retriever's head dominates. +const RRF_K = 60; function sortByPagerank(nodes: Neo4jNode[]): Neo4jNode[] { return [...nodes].sort((a, b) => { @@ -167,21 +169,17 @@ export async function searchWithProvenance( }); const merged = new Map(); - const ftMaxScore = fulltextResults.length > 0 ? Math.max(...fulltextResults.map(n => n.score || 1)) : 1; - const vecMaxScore = vectorResults.length > 0 ? Math.max(...vectorResults.map(n => n.score || 1)) : 1; fulltextResults.forEach((node, i) => { const key = node.properties.ref_id || node.properties.node_key; - const norm = (node.score || 0) / ftMaxScore; - merged.set(key, { node, score: (1 / (RRF_K + i + 1)) * (0.5 + 0.5 * norm) }); + merged.set(key, { node, score: 1 / (RRF_K + i + 1) }); }); vectorResults.forEach((node, i) => { const key = node.properties.ref_id || node.properties.node_key; - const norm = (node.score || 0) / vecMaxScore; - const rrfScore = (1 / (RRF_K + i + 1)) * (0.5 + 0.5 * norm); + const rrfScore = 1 / (RRF_K + i + 1); const existing = merged.get(key); if (existing) { - existing.score += rrfScore * 1.5; + existing.score += rrfScore; } else { merged.set(key, { node, score: rrfScore }); } From 2525ea218b5f39aaf4f3562ebba4ea5b184e7855 Mon Sep 17 00:00:00 2001 From: kelmith Date: Mon, 20 Jul 2026 12:31:30 +0530 Subject: [PATCH 3/3] feat: prepend node name to embedded text for richer vectors --- mcp/src/graph/neo4j.ts | 3 ++- mcp/src/graph/queries.ts | 2 +- mcp/src/repo/descriptions.ts | 10 ++++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/mcp/src/graph/neo4j.ts b/mcp/src/graph/neo4j.ts index e2d4b025c..ba58a77b9 100644 --- a/mcp/src/graph/neo4j.ts +++ b/mcp/src/graph/neo4j.ts @@ -1311,7 +1311,7 @@ class Db { limit: number, repo_paths: string[] | null, file_paths: string[], - ): Promise<{ ref_id: string; description: string }[]> { + ): Promise<{ ref_id: string; name: string; description: string }[]> { const session = this.resilientSession(); try { const result = await session.run( @@ -1324,6 +1324,7 @@ class Db { ); return result.records.map((record) => ({ ref_id: record.get("ref_id"), + name: record.get("name"), description: record.get("description"), })); } finally { diff --git a/mcp/src/graph/queries.ts b/mcp/src/graph/queries.ts index 213383cb6..e8d777c9d 100644 --- a/mcp/src/graph/queries.ts +++ b/mcp/src/graph/queries.ts @@ -980,7 +980,7 @@ WHERE (n:Class OR n:Endpoint OR n:Request OR n:Function OR n:Datamodel OR n:Page AND (n.embeddings IS NULL) AND ($repo_paths IS NULL OR size($repo_paths) = 0 OR ANY(repo IN $repo_paths WHERE n.file STARTS WITH repo)) AND ($file_paths IS NULL OR size($file_paths) = 0 OR ANY(path IN $file_paths WHERE n.file ENDS WITH path)) -RETURN n.ref_id as ref_id, n.description as description +RETURN n.ref_id as ref_id, n.name as name, n.description as description LIMIT toInteger($limit) `; diff --git a/mcp/src/repo/descriptions.ts b/mcp/src/repo/descriptions.ts index 65fd2876b..82c89ed6d 100644 --- a/mcp/src/repo/descriptions.ts +++ b/mcp/src/repo/descriptions.ts @@ -32,6 +32,12 @@ function usageTotals(usage: AiUsageWithLegacy) { +// Text that gets embedded for a node: prepend the identifier so the vector +// carries the symbol name's semantics, not just the prose description. +function embedText(name: string | undefined, description: string): string { + return name ? `${name}: ${description}` : description; +} + function extractRepoPaths(repo_url?: string): string[] | null { if (!repo_url || repo_url.trim() === "") return null; @@ -221,7 +227,7 @@ ${content.slice(0, 2000)}`; // Bulk write to Neo4j if (results.length > 0) { if (do_embed) { - const texts = results.map((r) => r.text); + const texts = results.map((r) => embedText(r.name, r.text)); const embeddings = await vectorizeBatch(texts); await db.bulk_update_descriptions_and_embeddings( results.map((r, i) => ({ @@ -315,7 +321,7 @@ export const embed_nodes_agent = async (req: Request, res: Response) => { current_batch_size: nodes.length, }); - const texts = nodes.map((n) => n.description); + const texts = nodes.map((n) => embedText(n.name, n.description)); const embeddings = await vectorizeBatch(texts); const batch = nodes.map((n, i) => ({