fix(local-backend): (#1178)skip vector index query on unsupported platforms

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Morieity 2026-04-30 13:56:29 +08:00
parent dafda284bc
commit 27feb40e98
2 changed files with 83 additions and 16 deletions

View file

@ -35,7 +35,10 @@ import {
type ExactEmbeddingRow,
} from '../../core/embeddings/exact-search.js';
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js';
import { getExactScanLimit } from '../../core/platform/capabilities.js';
import {
getExactScanLimit,
isVectorExtensionSupportedByPlatform,
} from '../../core/platform/capabilities.js';
import { PhaseTimer } from '../../core/search/phase-timer.js';
import { checkStaleness, checkCwdMatch } from '../../core/git-staleness.js';
// AI context generation is CLI-only (gitnexus analyze)
@ -223,6 +226,14 @@ export class LocalBackend {
*/
private warnedSiblingDrift: Set<string> = new Set();
/**
* One-shot stderr warning for the VECTOR-extension fallback. Without this
* guard the diagnostic would fire on every `semanticSearch()` call on
* platforms where the extension is unsupported (e.g. Windows), making MCP
* stderr noisy per DoD §2.8.
*/
private warnedVectorUnsupported = false;
/**
* Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers.
*/
@ -1071,9 +1082,10 @@ export class LocalBackend {
string,
{ distance: number; chunkIndex: number; startLine: number; endLine: number }
>();
try {
bestChunks = await collectBestChunks(limit, async (fetchLimit) => {
const vectorQuery = `
if (isVectorExtensionSupportedByPlatform()) {
try {
bestChunks = await collectBestChunks(limit, async (fetchLimit) => {
const vectorQuery = `
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
CAST(${queryVecStr} AS FLOAT[${dims}]), ${fetchLimit})
YIELD node AS emb, distance
@ -1084,17 +1096,28 @@ export class LocalBackend {
ORDER BY distance
`;
const embResults = await executeQuery(repo.id, vectorQuery);
return embResults.map((row) => ({
nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
startLine: row.startLine ?? row[2] ?? 0,
endLine: row.endLine ?? row[3] ?? 0,
distance: row.distance ?? row[4],
}));
});
} catch {
bestChunks = new Map();
const embResults = await executeQuery(repo.id, vectorQuery);
return embResults.map((row) => ({
nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
startLine: row.startLine ?? row[2] ?? 0,
endLine: row.endLine ?? row[3] ?? 0,
distance: row.distance ?? row[4],
}));
});
} catch {
bestChunks = new Map();
}
} else if (!this.warnedVectorUnsupported) {
// Rare diagnostic: surface why we fell back to the exact scan path so
// operators can see at a glance that the VECTOR extension is missing on
// this runtime (e.g. Windows builds without the optional native
// dependency). Emitted once per `LocalBackend` instance lifetime to
// avoid noisy stderr on hot semantic-search paths (DoD §2.8).
this.warnedVectorUnsupported = true;
console.error(
'GitNexus [query:vector]: VECTOR index unavailable for this runtime; using exact scan fallback',
);
}
if (bestChunks.size === 0) {

View file

@ -13,7 +13,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// local-backend.ts imports from core/lbug/pool-adapter.js; the mcp/core/lbug-adapter.js
// re-exports from the same module, so we mock the canonical source.
// vi.hoisted runs before vi.mock hoisting, making the fns available to both factories.
const { lbugMocks } = vi.hoisted(() => ({
const { lbugMocks, platformMocks } = vi.hoisted(() => ({
lbugMocks: {
initLbug: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn().mockResolvedValue([]),
@ -21,6 +21,9 @@ const { lbugMocks } = vi.hoisted(() => ({
closeLbug: vi.fn().mockResolvedValue(undefined),
isLbugReady: vi.fn().mockReturnValue(true),
},
platformMocks: {
isVectorExtensionSupportedByPlatform: vi.fn().mockReturnValue(true),
},
}));
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
@ -48,6 +51,14 @@ vi.mock('../../src/core/git-staleness.js', () => ({
checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }),
}));
vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/platform/capabilities.js')>();
return {
...actual,
isVectorExtensionSupportedByPlatform: platformMocks.isVectorExtensionSupportedByPlatform,
};
});
// Also mock the search modules to avoid loading onnxruntime
vi.mock('../../src/core/search/bm25-index.js', () => ({
searchFTSFromLbug: vi.fn().mockResolvedValue([]),
@ -157,6 +168,7 @@ describe('LocalBackend.callTool', () => {
beforeEach(async () => {
vi.clearAllMocks();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
@ -181,6 +193,38 @@ describe('LocalBackend.callTool', () => {
expect(result).toHaveProperty('definitions');
});
it('skips vector index query when VECTOR is unsupported by the platform', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(false);
(executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => {
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
if (cypher.includes('MATCH (e:CodeEmbedding)')) return [];
return [];
});
(executeParameterized as any).mockResolvedValue([]);
try {
await backend.callTool('query', { query: 'auth' });
const queries = (executeQuery as any).mock.calls.map(
([, cypher]: [string, string]) => cypher,
);
expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(false);
expect(
queries.some(
(cypher: string) =>
cypher.includes('RETURN e.nodeId AS nodeId') &&
cypher.includes('e.embedding AS embedding'),
),
).toBe(true);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('GitNexus [query:vector]: VECTOR index unavailable'),
);
} finally {
consoleError.mockRestore();
}
});
it('query tool returns error for empty query', async () => {
const result = await backend.callTool('query', { query: '' });
expect(result.error).toContain('query parameter is required');