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

This commit is contained in:
Parafee41 2026-10-05 15:27:59 +08:00 • committed by GitHub
parent 10947d4b52
commit ced9660e77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 41 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

@ -21,6 +21,11 @@ const AUGMENT_SEED_DATA = [
`CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 15, isExported: true, content: 'function login authenticates user credentials', description: 'user login'})`,
`CREATE (n:Function {id: 'func:validate', name: 'validate', filePath: 'src/auth.ts', startLine: 17, endLine: 25, isExported: true, content: 'function validate checks user input', description: 'input validation'})`,
`CREATE (n:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash computes bcrypt hash', description: 'password hashing'})`,
...Array.from(
{ length: 5 },
(_, i) =>
`CREATE (n:Function {id: 'a:login-${i}', name: 'loginPartial${i}', filePath: 'src/login-partial-${i}.ts', startLine: 1, endLine: 2, isExported: true, content: 'partial login match ${i}', description: 'login partial'})`,
),
// Class / Method / Interface nodes
`CREATE (n:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService handles authentication', description: 'auth service'})`,
@ -130,23 +135,40 @@ 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).toMatch(/^login \(src\/auth\.ts\)$/m);
} 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,
rank: i + 1,
nodeIds: [`file:caller-${i}`],
})),
ftsAvailable: true,
});
try {
// Five partial login matches have IDs that sort before func:login, so
// the five-row fallback only retains the exact node when exact names
// rank ahead of partial matches.
const result = await augment('login', handle.dbPath);
expect(result).toContain('[GitNexus]');
expect(result).toMatch(/^login \(src\/auth\.ts\)$/m);
} finally {
spy.mockRestore();
}