fix(augment): recover symbols missed by FTS file ranking

This commit is contained in:
kigland 2026-09-30 09:10:15 +08:00
parent acb65b95b6
commit 4f520ea18d
2 changed files with 34 additions and 17 deletions

View file

@ -129,7 +129,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
// Step 1: BM25 search (fast, no embeddings)
const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId);
const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId);
// Step 2: Map BM25 file results to symbols
const symbolMatches: Array<{
@ -167,16 +167,18 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
}
// When FTS indexes are unavailable (read-only DB, first run before indexes are built),
// fall back to a direct name CONTAINS query so enrichment still works.
if (symbolMatches.length === 0 && !ftsAvailable) {
// FTS ranks files by mentions, so a widely referenced symbol's definition
// may not be present in the top file results. Fall back to graph names
// whenever those files produce no symbol match, regardless of FTS health.
if (symbolMatches.length === 0) {
const fallbackRows = await executeQuery(
repoId,
`
MATCH (n)
WHERE n.name CONTAINS '${patternFirstWord}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
ORDER BY id
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath,
CASE WHEN n.name = '${patternFirstWord}' THEN 0 ELSE 1 END AS exactRank
ORDER BY exactRank, id
LIMIT 5
`,
).catch(() => []);

View file

@ -130,23 +130,38 @@ withTestLbugDB(
expect(result).toBe('');
});
// ─── Negative-safety: fallback must stay gated on !ftsAvailable ───
//
// When FTS is available but happens to return zero BM25 hits, the
// CONTAINS fallback must NOT fire — preserving the original early-return
// semantics. If anyone later loosens the gate to `symbolMatches.length
// === 0` alone, this test fails.
it('does NOT fire CONTAINS fallback when FTS is available but BM25 returns empty', async () => {
it('falls back to graph names when FTS is available but BM25 returns empty', async () => {
const bm25 = await import('../../src/core/search/bm25-index.js');
const spy = vi
.spyOn(bm25, 'searchFTSFromLbug')
.mockResolvedValue({ results: [], ftsAvailable: true });
try {
// 'login' WOULD match a graph node via CONTAINS, but FTS is available
// and empty → fallback gate must hold → result must be ''.
// FTS health does not imply its top file results contain the symbol's
// definition. The graph-name fallback must recover the exact node.
const result = await augment('login', handle.dbPath);
expect(result).toBe('');
expect(result).toContain('[GitNexus]');
expect(result).toContain('login');
} finally {
spy.mockRestore();
}
});
it('finds an exact-name symbol when FTS ranks only mentioning files', async () => {
const bm25 = await import('../../src/core/search/bm25-index.js');
const spy = vi.spyOn(bm25, 'searchFTSFromLbug').mockResolvedValue({
results: Array.from({ length: 5 }, (_, i) => ({
filePath: `src/caller-${i}.ts`,
score: 100 - i,
nodeIds: [`file:caller-${i}`],
})),
ftsAvailable: true,
});
try {
// The graph contains func:login in src/auth.ts, but none of the
// FTS-ranked files defines it. Exact-name lookup must still recover it.
const result = await augment('login', handle.dbPath);
expect(result).toContain('[GitNexus]');
expect(result).toContain('login');
} finally {
spy.mockRestore();
}