fix(bm25): return FTS-matched symbols instead of arbitrary LIMIT 3 nodes

Previously, bm25Search fetched up to 3 arbitrary symbols from the matched
file using MATCH (n) WHERE n.filePath = $filePath LIMIT 3 (no ORDER BY).
This meant the specific function or class that actually scored highest in
the BM25 index could be completely absent from the results.

Fix: propagate nodeId from each FTS hit through searchFTSFromLbug, then
use those nodeIds in bm25Search to look up the exact matched nodes via
WHERE n.id IN $nodeIds. Falls back to the old filePath-based lookup when
nodeIds are unavailable.

Also switches the per-file score aggregation from naive sum-of-all to
sum-of-top-3, which prevents files with many mediocre matches (e.g. test
files) from outranking files with a single highly-relevant symbol.
This commit is contained in:
jisue0224 2026-04-13 18:42:28 +09:00
parent 9f4109a33f
commit aac7c36918
2 changed files with 45 additions and 19 deletions

View file

@ -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<Array<{ filePath: string; score: number }>> {
): Promise<Array<{ filePath: string; score: number; nodeId: string }>> {
// 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<string, { filePath: string; score: number }>();
// Collect all node scores per filePath to track which nodes actually matched
const fileNodeScores = new Map<string, Array<{ score: number; nodeId: string }>>();
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<string, { filePath: string; score: number; nodeIds: string[] }>();
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,
}));
};

View file

@ -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) {