Skip to content

Commit 10654e3

Browse files
authored
Merge pull request #261 from AmintaCCCP/fix/persist-vector-search-config
fix: persist full vector search config to backend + stop sync loop (#259)
2 parents 6d65b93 + d608f37 commit 10654e3

5 files changed

Lines changed: 411 additions & 12 deletions

File tree

server/src/db/schema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ export function initializeSchema(db: Database.Database): void {
129129
embedding_config_id TEXT,
130130
index_mode TEXT NOT NULL DEFAULT 'readme',
131131
readme_max_chars INTEGER NOT NULL DEFAULT 6000,
132+
search_threshold REAL DEFAULT 0.35,
133+
search_top_k INTEGER DEFAULT 30,
134+
enable_hyde INTEGER DEFAULT 1,
135+
enable_reranking INTEGER DEFAULT 1,
136+
embedding_format_version INTEGER,
132137
status_json TEXT,
133138
last_sync_at TEXT,
134139
created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -149,6 +154,11 @@ export function initializeSchema(db: Database.Database): void {
149154
addColumnIfMissing(db, 'asset_filters', 'sort_order', 'INTEGER DEFAULT 0');
150155
addColumnIfMissing(db, 'vector_search_configs', 'index_mode', "TEXT NOT NULL DEFAULT 'readme'");
151156
addColumnIfMissing(db, 'vector_search_configs', 'readme_max_chars', 'INTEGER NOT NULL DEFAULT 6000');
157+
addColumnIfMissing(db, 'vector_search_configs', 'search_threshold', 'REAL DEFAULT 0.35');
158+
addColumnIfMissing(db, 'vector_search_configs', 'search_top_k', 'INTEGER DEFAULT 30');
159+
addColumnIfMissing(db, 'vector_search_configs', 'enable_hyde', 'INTEGER DEFAULT 1');
160+
addColumnIfMissing(db, 'vector_search_configs', 'enable_reranking', 'INTEGER DEFAULT 1');
161+
addColumnIfMissing(db, 'vector_search_configs', 'embedding_format_version', 'INTEGER');
152162
addColumnIfMissing(db, 'repositories', 'vector_indexed_at', 'TEXT');
153163
addColumnIfMissing(db, 'repositories', 'license', 'TEXT');
154164
// 上一次向量索引时采用的 license 值(SPDX id / null)。用于增量谓词判断 license 是否

server/src/routes/configs.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,7 @@ router.get('/api/configs/vector-search', (req, res) => {
681681
const row = db.prepare('SELECT * FROM vector_search_configs WHERE id = ?').get('default') as Record<string, unknown> | undefined;
682682

683683
if (!row) {
684-
res.json({ enabled: false, workerUrl: '', authToken: '', embeddingConfigId: '', indexMode: 'readme', readmeMaxChars: 6000 });
684+
res.json({ enabled: false, workerUrl: '', authToken: '', embeddingConfigId: '', indexMode: 'readme', readmeMaxChars: 6000, searchThreshold: 0.35, searchTopK: 30, enableHyDE: true, enableReranking: true });
685685
return;
686686
}
687687

@@ -710,6 +710,11 @@ router.get('/api/configs/vector-search', (req, res) => {
710710
embeddingConfigId: row.embedding_config_id ?? '',
711711
indexMode: row.index_mode ?? 'readme',
712712
readmeMaxChars: row.readme_max_chars ?? 6000,
713+
searchThreshold: typeof row.search_threshold === 'number' ? row.search_threshold : 0.35,
714+
searchTopK: typeof row.search_top_k === 'number' ? row.search_top_k : 30,
715+
enableHyDE: row.enable_hyde === 1,
716+
enableReranking: row.enable_reranking === 1,
717+
embeddingFormatVersion: typeof row.embedding_format_version === 'number' ? row.embedding_format_version : undefined,
713718
status,
714719
lastSyncAt: row.last_sync_at ?? null,
715720
});
@@ -724,6 +729,7 @@ router.put('/api/configs/vector-search', (req, res) => {
724729
try {
725730
const db = getDb();
726731
const { enabled, workerUrl, authToken, embeddingConfigId, indexMode, readmeMaxChars, status, lastSyncAt } = req.body as Record<string, unknown>;
732+
const { searchThreshold, searchTopK, enableHyDE, enableReranking, embeddingFormatVersion } = req.body as Record<string, unknown>;
727733

728734
let encryptedToken = '';
729735
const hasAuthToken = Object.prototype.hasOwnProperty.call(req.body, 'authToken');
@@ -741,11 +747,22 @@ router.put('/api/configs/vector-search', (req, res) => {
741747
const statusJson = status ? JSON.stringify(status) : null;
742748
const mode = indexMode === 'description' ? 'description' : 'readme';
743749
const maxChars = typeof readmeMaxChars === 'number' && readmeMaxChars > 0 ? readmeMaxChars : 6000;
750+
const threshold = typeof searchThreshold === 'number' && Number.isFinite(searchThreshold) && searchThreshold >= 0 && searchThreshold <= 1
751+
? searchThreshold
752+
: 0.35;
753+
const topK = typeof searchTopK === 'number' && Number.isInteger(searchTopK) && searchTopK >= 5 && searchTopK <= 50
754+
? searchTopK
755+
: 30;
756+
const hyde = enableHyDE === true ? 1 : 0;
757+
const reranking = enableReranking === true ? 1 : 0;
758+
const formatVersion = typeof embeddingFormatVersion === 'number' && Number.isInteger(embeddingFormatVersion) && embeddingFormatVersion >= 1
759+
? embeddingFormatVersion
760+
: null;
744761

745762
db.prepare(`
746-
INSERT OR REPLACE INTO vector_search_configs (id, enabled, worker_url, auth_token_encrypted, embedding_config_id, index_mode, readme_max_chars, status_json, last_sync_at, updated_at)
747-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
748-
`).run('default', enabled ? 1 : 0, workerUrl ?? '', encryptedToken, embeddingConfigId ?? '', mode, maxChars, statusJson, lastSyncAt ?? null);
763+
INSERT OR REPLACE INTO vector_search_configs (id, enabled, worker_url, auth_token_encrypted, embedding_config_id, index_mode, readme_max_chars, search_threshold, search_top_k, enable_hyde, enable_reranking, embedding_format_version, status_json, last_sync_at, updated_at)
764+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
765+
`).run('default', enabled ? 1 : 0, workerUrl ?? '', encryptedToken, embeddingConfigId ?? '', mode, maxChars, threshold, topK, hyde, reranking, formatVersion, statusJson, lastSyncAt ?? null);
749766

750767
res.json({ updated: true });
751768
} catch (err) {
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import express from 'express';
2+
import request from 'supertest';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
const getDbMock = vi.fn();
6+
7+
vi.mock('../../src/db/connection.js', () => ({
8+
getDb: () => getDbMock(),
9+
}));
10+
11+
vi.mock('../../src/services/crypto.js', () => ({
12+
decrypt: (value: string) => value,
13+
encrypt: (value: string) => value,
14+
}));
15+
16+
vi.mock('../../src/config.js', () => ({
17+
config: { encryptionKey: 'test-encryption-key' },
18+
}));
19+
20+
const { default: configsRouter } = await import('../../src/routes/configs.js');
21+
22+
const createTestApp = () => {
23+
const app = express();
24+
app.use(express.json());
25+
app.use(configsRouter);
26+
return app;
27+
};
28+
29+
// PUT binds params in INSERT order; capture them so we can assert what persisted.
30+
function capturePut(): { params: unknown[][] } {
31+
const capture = { params: [] as unknown[][] };
32+
getDbMock.mockReturnValue({
33+
prepare: () => ({
34+
get: () => undefined,
35+
run: (...p: unknown[]) => {
36+
capture.params.push(p);
37+
},
38+
}),
39+
});
40+
return capture;
41+
}
42+
43+
function mockGetReturningRow(row: Record<string, unknown>) {
44+
getDbMock.mockReturnValue({
45+
prepare: (sql: string) => ({
46+
get: () => (sql.includes('vector_search_configs') ? row : undefined),
47+
run: () => ({ changes: 1 }),
48+
}),
49+
});
50+
}
51+
52+
const fullConfig = {
53+
enabled: true,
54+
workerUrl: 'https://example.com/vectorize',
55+
authToken: 'worker-secret-token',
56+
embeddingConfigId: 'emb_test1',
57+
indexMode: 'readme',
58+
readmeMaxChars: 8000,
59+
searchThreshold: 0.42,
60+
searchTopK: 25,
61+
enableHyDE: false,
62+
enableReranking: true,
63+
embeddingFormatVersion: 2,
64+
};
65+
66+
describe('vector search config route (GET/PUT /api/configs/vector-search)', () => {
67+
it('PUT persists all vector-search fields to the backend', async () => {
68+
const capture = capturePut();
69+
const res = await request(createTestApp()).put('/api/configs/vector-search').send(fullConfig);
70+
71+
expect(res.status).toBe(200);
72+
expect(res.body).toEqual({ updated: true });
73+
expect(capture.params).toHaveLength(1);
74+
75+
const p = capture.params[0];
76+
expect(p[0]).toBe('default');
77+
expect(p[1]).toBe(1); // enabled
78+
expect(p[2]).toBe(fullConfig.workerUrl);
79+
expect(p[3]).toBe(fullConfig.authToken); // encrypted (encrypt is identity in mock)
80+
expect(p[4]).toBe(fullConfig.embeddingConfigId);
81+
expect(p[5]).toBe(fullConfig.indexMode);
82+
expect(p[6]).toBe(fullConfig.readmeMaxChars);
83+
expect(p[7]).toBe(fullConfig.searchThreshold);
84+
expect(p[8]).toBe(fullConfig.searchTopK);
85+
expect(p[9]).toBe(0); // enableHyDE false → 0
86+
expect(p[10]).toBe(1); // enableReranking true → 1
87+
expect(p[11]).toBe(2); // embedding_format_version
88+
});
89+
90+
it('GET returns all persisted vector-search fields', async () => {
91+
const row = {
92+
id: 'default',
93+
enabled: 1,
94+
worker_url: fullConfig.workerUrl,
95+
auth_token_encrypted: fullConfig.authToken,
96+
embedding_config_id: fullConfig.embeddingConfigId,
97+
index_mode: fullConfig.indexMode,
98+
readme_max_chars: fullConfig.readmeMaxChars,
99+
search_threshold: fullConfig.searchThreshold,
100+
search_top_k: fullConfig.searchTopK,
101+
enable_hyde: 0,
102+
enable_reranking: 1,
103+
embedding_format_version: 2,
104+
status_json: null,
105+
last_sync_at: null,
106+
};
107+
mockGetReturningRow(row);
108+
109+
const res = await request(createTestApp()).get('/api/configs/vector-search?decrypt=true');
110+
111+
expect(res.status).toBe(200);
112+
expect(res.body).toMatchObject({
113+
enabled: true,
114+
workerUrl: fullConfig.workerUrl,
115+
authToken: fullConfig.authToken,
116+
embeddingConfigId: fullConfig.embeddingConfigId,
117+
indexMode: fullConfig.indexMode,
118+
readmeMaxChars: fullConfig.readmeMaxChars,
119+
searchThreshold: fullConfig.searchThreshold,
120+
searchTopK: fullConfig.searchTopK,
121+
enableHyDE: false,
122+
enableReranking: true,
123+
embeddingFormatVersion: 2,
124+
});
125+
});
126+
127+
it('PUT normalizes out-of-range search params and clears the format version when absent', async () => {
128+
const capture = capturePut();
129+
await request(createTestApp()).put('/api/configs/vector-search').send({
130+
enabled: false,
131+
authToken: '',
132+
searchThreshold: 5,
133+
searchTopK: 999,
134+
embeddingFormatVersion: 0,
135+
});
136+
137+
const p = capture.params[0];
138+
expect(p[7]).toBe(0.35); // out-of-range threshold → default
139+
expect(p[8]).toBe(30); // out-of-range topK → default
140+
expect(p[9]).toBe(0); // enable_hyde absent → false
141+
expect(p[10]).toBe(0); // enable_reranking absent → false
142+
expect(p[11]).toBeNull(); // embeddingFormatVersion 0 (invalid) → null
143+
});
144+
});

src/services/autoSync.ts

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,67 @@ function quickHash(data: unknown): string {
4343
return JSON.stringify(data);
4444
}
4545

46+
/** Canonical fingerprint for the vector search config.
47+
*
48+
* The backend GET payload and the store config have different key sets/order
49+
* (backend adds authTokenStatus/status/lastSyncAt, the store always carries
50+
* embeddingFormatVersion). A naive quickHash over each raw side therefore never
51+
* matches, which kept the poll→push loop running forever. Fingerprinting only the
52+
* shared, meaningful fields makes both sides converge after one round-trip.
53+
*/
54+
export function vectorSearchFingerprint(config: unknown): string {
55+
const c = (config ?? {}) as Record<string, unknown>;
56+
return quickHash({
57+
enabled: !!c.enabled,
58+
workerUrl: c.workerUrl ?? '',
59+
authToken: c.authToken ?? '',
60+
embeddingConfigId: c.embeddingConfigId ?? '',
61+
indexMode: c.indexMode ?? 'readme',
62+
readmeMaxChars: c.readmeMaxChars ?? 6000,
63+
searchThreshold: c.searchThreshold ?? 0.35,
64+
searchTopK: c.searchTopK ?? 30,
65+
enableHyDE: c.enableHyDE ?? true,
66+
enableReranking: c.enableReranking ?? true,
67+
embeddingFormatVersion: c.embeddingFormatVersion ?? null,
68+
});
69+
}
70+
71+
/**
72+
* Decide whether a fresh/empty backend must NOT overwrite a locally configured
73+
* vector search. Mirrors the repositories bootstrap guard: on the first-ever sync
74+
* in this session, an empty backend (nothing stored) must not wipe a working local
75+
* config — otherwise the local worker/embedding setup is lost and the follow-up
76+
* push then persists the wiped (empty) config to the backend. When true, the
77+
* caller keeps the local config and pushes it up instead.
78+
*/
79+
export function shouldPreserveLocalVectorSearch(
80+
backendConfig: unknown,
81+
localConfig: unknown,
82+
isFirstSync: boolean
83+
): boolean {
84+
if (!isFirstSync) return false;
85+
const b = (backendConfig ?? {}) as Record<string, unknown>;
86+
const l = (localConfig ?? {}) as Record<string, unknown>;
87+
const backendEmpty = !b.enabled && !b.workerUrl && !b.embeddingConfigId;
88+
const localConfigured = !!(l.enabled || l.workerUrl || l.embeddingConfigId);
89+
return backendEmpty && localConfigured;
90+
}
91+
92+
/**
93+
* True when the backend's stored vector-search authToken is unusable (empty or
94+
* failed to decrypt) but the local client has a working token to preserve. The
95+
* caller must then queue a repair push: during a pull the store subscription is
96+
* suppressed, so without it the preserved token would never reach the backend,
97+
* the stored fingerprint (with the local token) would never match the backend's
98+
* empty token, and the poll→push loop would run forever.
99+
*/
100+
export function shouldQueueVectorSearchRepairPush(backendConfig: unknown, localConfig: unknown): boolean {
101+
const b = (backendConfig ?? {}) as Record<string, unknown>;
102+
const l = (localConfig ?? {}) as Record<string, unknown>;
103+
const backendTokenUnusable = b.authTokenStatus === 'decrypt_failed' || !b.authToken;
104+
return backendTokenUnusable && !!l.authToken;
105+
}
106+
46107
function setRepositorySyncVisualState(isSyncing: boolean): void {
47108
if (typeof window === 'undefined') return;
48109
window.dispatchEvent(new CustomEvent('gsm:repository-sync-visual-state', { detail: { isSyncing } }));
@@ -171,9 +232,8 @@ export async function syncFromBackend(): Promise<void> {
171232
}
172233

173234
if (vectorSearchResult.status === 'fulfilled') {
174-
const hash = quickHash(vectorSearchResult.value);
235+
const hash = vectorSearchFingerprint(vectorSearchResult.value);
175236
if (hash !== _lastHash.vectorSearch) {
176-
hashes.vectorSearch = hash;
177237
changed.vectorSearch = true;
178238
}
179239
}
@@ -277,16 +337,27 @@ export async function syncFromBackend(): Promise<void> {
277337
}
278338
if (changed.vectorSearch && vectorSearchResult.status === 'fulfilled') {
279339
const backendConfig = vectorSearchResult.value;
280-
// Preserve local authToken if backend returned empty or decrypt_failed
281340
const localConfig = state.vectorSearchConfig;
282-
if ((backendConfig as Record<string, unknown>).authTokenStatus === 'decrypt_failed' || !backendConfig.authToken) {
283-
if (localConfig.authToken) {
341+
// Bootstrap guard (mirrors repositories): a fresh/empty backend must not
342+
// wipe a locally-configured vector search — keep local and push it up.
343+
if (shouldPreserveLocalVectorSearch(backendConfig, localConfig, _lastHash.vectorSearch === '')) {
344+
_hasPendingPush = true;
345+
} else {
346+
// Preserve local authToken if backend returned empty or decrypt_failed,
347+
// and queue a repair push so the backend is re-synced with it. Without
348+
// the push the preserved token stays local-only and the poll loop keeps
349+
// re-detecting the backend/effective fingerprint mismatch.
350+
if (shouldQueueVectorSearchRepairPush(backendConfig, localConfig)) {
284351
logger.warn('sync.decryptFailed', 'Backend decrypt_failed for vector search authToken, preserving local value');
285352
backendConfig.authToken = localConfig.authToken;
353+
_hasPendingPush = true;
286354
}
355+
state.setVectorSearchConfig(backendConfig);
356+
// Fingerprint the effective store config (after merge/normalization) so it
357+
// matches the push fingerprint in syncToBackend(); hashing the raw backend
358+
// payload instead would leave the two sides forever unequal.
359+
_lastHash.vectorSearch = vectorSearchFingerprint(useAppStore.getState().vectorSearchConfig);
287360
}
288-
state.setVectorSearchConfig(backendConfig);
289-
_lastHash.vectorSearch = hashes.vectorSearch;
290361
}
291362
// Sync active selections from settings
292363
if (changed.settings && settingsResult.status === 'fulfilled') {
@@ -404,7 +475,7 @@ export async function syncToBackend(): Promise<void> {
404475
if (aiSync.status === 'fulfilled') _lastHash.ai = quickHash(state.aiConfigs);
405476
if (webdavSync.status === 'fulfilled') _lastHash.webdav = quickHash(state.webdavConfigs);
406477
if (embeddingSync.status === 'fulfilled') _lastHash.embedding = quickHash(state.embeddingConfigs);
407-
if (vectorSearchSync.status === 'fulfilled') _lastHash.vectorSearch = quickHash(state.vectorSearchConfig);
478+
if (vectorSearchSync.status === 'fulfilled') _lastHash.vectorSearch = vectorSearchFingerprint(state.vectorSearchConfig);
408479
if (settingsSync.status === 'fulfilled') {
409480
_lastHash.settings = quickHash({
410481
activeAIConfig: state.activeAIConfig,

0 commit comments

Comments
 (0)