fix(search): surface warning when FTS indexes are missing (#1403)

searchFTSFromLbug now returns { results, ftsAvailable } so callers
can distinguish "zero matches" from "indexes do not exist".  When
FTS is unavailable the existing query-response warning fires,
telling the user to re-run gitnexus analyze.
This commit is contained in:
azizur100389 2026-05-07 20:01:27 +01:00
parent 4e362ba70a
commit e2cfa7e6dd
5 changed files with 46 additions and 18 deletions

View file

@ -104,7 +104,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
// Step 1: BM25 search (fast, no embeddings)
const bm25Results = await searchFTSFromLbug(pattern, 10, repoId);
const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId);
if (bm25Results.length === 0) return '';

View file

@ -15,17 +15,28 @@ export interface BM25SearchResult {
nodeIds?: string[];
}
export interface FTSSearchResponse {
results: BM25SearchResult[];
/** True when at least one FTS index query succeeded (index exists). */
ftsAvailable: boolean;
}
/**
* Execute a single FTS query via a custom executor (for MCP connection pool).
* Returns the same shape as core queryFTS (from LadybugDB adapter).
*/
/**
* Execute a single FTS query via a custom executor (for MCP connection pool).
* Returns `null` when the query fails (e.g. FTS index does not exist) so the
* caller can distinguish "zero matches" from "index missing".
*/
async function queryFTSViaExecutor(
executor: (cypher: string) => Promise<any[]>,
tableName: string,
indexName: string,
query: string,
limit: number,
): Promise<Array<{ filePath: string; score: number; nodeId: string }>> {
): Promise<Array<{ filePath: string; score: number; nodeId: string }> | null> {
// Escape single quotes and backslashes to prevent Cypher injection
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
const cypher = `
@ -46,7 +57,7 @@ async function queryFTSViaExecutor(
};
});
} catch {
return [];
return null;
}
}
@ -65,8 +76,9 @@ export const searchFTSFromLbug = async (
query: string,
limit: number = 20,
repoId?: string,
): Promise<BM25SearchResult[]> => {
): Promise<FTSSearchResponse> => {
const resultsByIndex: any[][] = [];
let queriesSucceeded = 0;
if (repoId) {
// Use MCP connection pool via dynamic import
@ -77,15 +89,27 @@ export const searchFTSFromLbug = async (
const executor = (cypher: string) => executeQuery(repoId, cypher);
for (const { table, indexName } of FTS_INDEXES) {
resultsByIndex.push(await queryFTSViaExecutor(executor, table, indexName, query, limit));
const result = await queryFTSViaExecutor(executor, table, indexName, query, limit);
if (result !== null) {
queriesSucceeded++;
resultsByIndex.push(result);
}
}
} else {
// Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
for (const { table, indexName } of FTS_INDEXES) {
resultsByIndex.push(await queryFTS(table, indexName, query, limit, false).catch(() => []));
try {
const result = await queryFTS(table, indexName, query, limit, false);
queriesSucceeded++;
resultsByIndex.push(result);
} catch {
// FTS index may not exist — count as failed
}
}
}
const ftsAvailable = queriesSucceeded > 0;
// Collect all node scores per filePath to track which nodes actually matched
const fileNodeScores = new Map<string, Array<{ score: number; nodeId: string }>>();
@ -116,10 +140,13 @@ export const searchFTSFromLbug = async (
.sort((a, b) => b.score - a.score)
.slice(0, limit);
return sorted.map((r, index) => ({
filePath: r.filePath,
score: r.score,
rank: index + 1,
nodeIds: r.nodeIds,
}));
return {
results: sorted.map((r, index) => ({
filePath: r.filePath,
score: r.score,
rank: index + 1,
nodeIds: r.nodeIds,
})),
ftsAvailable,
};
};

View file

@ -160,7 +160,7 @@ export const hybridSearch = async (
) => Promise<SemanticSearchResult[]>,
): Promise<HybridSearchResult[]> => {
// Use LadybugDB FTS for always-fresh BM25 results
const bm25Results = await searchFTSFromLbug(query, limit);
const { results: bm25Results } = await searchFTSFromLbug(query, limit);
const semanticResults = await semanticSearch(executeQuery, query, limit);
return mergeWithRRF(bm25Results, semanticResults, limit);
};

View file

@ -986,15 +986,16 @@ export class LocalBackend {
limit: number,
): Promise<{ results: any[]; ftsUsed: boolean }> {
const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js');
let bm25Results;
let ftsResponse;
try {
bm25Results = await searchFTSFromLbug(query, limit, repo.id);
ftsResponse = await searchFTSFromLbug(query, limit, repo.id);
} catch (err: any) {
console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message);
return { results: [], ftsUsed: false };
}
const ftsUsed = bm25Results.length === 0 || bm25Results[0]?.ftsUsed !== false;
const bm25Results = ftsResponse.results;
const ftsUsed = ftsResponse.ftsAvailable;
const results: any[] = [];

View file

@ -1078,7 +1078,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
sources: ['semantic'],
}));
} else if (mode === 'bm25') {
searchResults = await searchFTSFromLbug(query, limit);
searchResults = (await searchFTSFromLbug(query, limit)).results;
searchResults = searchResults.map((r: any, i: number) => ({
...r,
rank: i + 1,
@ -1092,7 +1092,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
await import('../core/embeddings/embedding-pipeline.js');
searchResults = await hybridSearch(query, limit, executeQuery, semSearch);
} else {
searchResults = await searchFTSFromLbug(query, limit);
searchResults = (await searchFTSFromLbug(query, limit)).results;
}
}