diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts index ae433ad28..d7294772b 100644 --- a/gitnexus/src/core/search/bm25-index.ts +++ b/gitnexus/src/core/search/bm25-index.ts @@ -11,6 +11,7 @@ export interface BM25SearchResult { filePath: string; score: number; rank: number; + nodeIds?: string[]; } /** @@ -23,7 +24,7 @@ async function queryFTSViaExecutor( indexName: string, query: string, limit: number, -): Promise> { +): Promise> { // Escape single quotes and backslashes to prevent Cypher injection const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''"); const cypher = ` @@ -40,6 +41,7 @@ async function queryFTSViaExecutor( return { filePath: node.filePath || '', score: typeof score === 'number' ? score : parseFloat(score) || 0, + nodeId: node.id || '', }; }); } catch { @@ -99,17 +101,13 @@ export const searchFTSFromLbug = async ( ); } - // Merge results by filePath, summing scores for same file - const merged = new Map(); + // Collect all node scores per filePath to track which nodes actually matched + const fileNodeScores = new Map>(); const addResults = (results: any[]) => { for (const r of results) { - const existing = merged.get(r.filePath); - if (existing) { - existing.score += r.score; - } else { - merged.set(r.filePath, { filePath: r.filePath, score: r.score }); - } + if (!fileNodeScores.has(r.filePath)) fileNodeScores.set(r.filePath, []); + fileNodeScores.get(r.filePath)!.push({ score: r.score, nodeId: r.nodeId }); } }; @@ -119,6 +117,19 @@ export const searchFTSFromLbug = async ( addResults(methodResults); addResults(interfaceResults); + // Sum the top-3 highest-scoring nodes per file and collect their nodeIds. + // Summing all nodes naively inflates scores for files with many mediocre + // matches (e.g. test files) over files with a single highly-relevant symbol. + const merged = new Map(); + for (const [filePath, entries] of fileNodeScores) { + const top3 = entries.sort((a, b) => b.score - a.score).slice(0, 3); + merged.set(filePath, { + filePath, + score: top3.reduce((acc, e) => acc + e.score, 0), + nodeIds: top3.map(e => e.nodeId).filter(id => id), + }); + } + // Sort by score descending and add rank const sorted = Array.from(merged.values()) .sort((a, b) => b.score - a.score) @@ -128,5 +139,6 @@ export const searchFTSFromLbug = async ( filePath: r.filePath, score: r.score, rank: index + 1, + nodeIds: r.nodeIds, })); }; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 041bd27eb..23d2c75cd 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -775,16 +775,30 @@ export class LocalBackend { for (const bm25Result of bm25Results) { const fullPath = bm25Result.filePath; try { - const symbols = await executeParameterized( - repo.id, - ` - MATCH (n) - WHERE n.filePath = $filePath - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine - LIMIT 3 - `, - { filePath: fullPath }, - ); + // Prefer direct nodeId lookup (exact FTS-matched nodes) over filePath fallback. + // Without this, LIMIT 3 on filePath returns arbitrary symbols rather than + // the nodes that actually scored highest in the BM25 index. + const nodeIds = bm25Result.nodeIds?.length ? bm25Result.nodeIds : null; + const symbols = nodeIds + ? await executeParameterized( + repo.id, + ` + MATCH (n) + WHERE n.id IN $nodeIds + RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine + `, + { nodeIds }, + ) + : await executeParameterized( + repo.id, + ` + MATCH (n) + WHERE n.filePath = $filePath + RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine + LIMIT 3 + `, + { filePath: fullPath }, + ); if (symbols.length > 0) { for (const sym of symbols) {