Skip to content

Commit 029cafa

Browse files
committed
Rebuild Symbol HNSW around incremental embedding writes
1 parent fe11889 commit 029cafa

5 files changed

Lines changed: 137 additions & 85 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
### Changed
1313

1414
- **CPU embedding auto-tuning**: Omitted local CPU settings now use physical-core and startup free-memory bounds for embedding concurrency, symbol batch 8, and the physical-core width for automatic ONNX intra-op threads. Auto-detected and pinned performance tiers share these presets, while explicit settings remain authoritative; remove explicit `embeddingConcurrency`, `embeddingBatchSize`, or positive `onnx.intraOpNumThreads` fields to adopt the automatic values.
15-
- **Incremental symbol embedding refresh**: Only changed `SymbolVectorEmbedding` rows are deleted and reinserted while a healthy HNSW is retained; the index is created only for bootstrap or missing-index recovery after at least one complete model row exists. Repository-local cleanup removes only changed-file ownership links, preserves shared Symbol vectors, and deterministically transfers their canonical owner. Symbol ANN queries filter through repository-scoped projected graphs before ranking. In a direct spike, this was 18.5x faster than the prior drop-and-rebuild path for 50 changed rows out of 26,000, with ANN results durable after close/reopen; this is evidence, not an SLA. Migration 26 copies complete legacy vectors in-database. Legacy `Symbol` embedding columns remain inert for compatibility, and upgraded databases may also retain legacy `Symbol` HNSW indexes until a safe rebuild.
15+
- **Incremental symbol embedding refresh**: Only changed `SymbolVectorEmbedding` rows are deleted and reinserted. When the shared table already has a model-specific HNSW, refresh checkpoints, drops the index once, replaces all changed rows, recreates the index once, and checkpoints again; bootstrap or missing-index recovery creates it after at least one complete model row exists. Repository-local cleanup removes only changed-file ownership links, preserves shared Symbol vectors, and deterministically transfers their canonical owner. Symbol ANN queries filter through repository-scoped projected graphs before ranking. Migration 26 copies complete legacy vectors in-database. Legacy `Symbol` embedding columns remain inert for compatibility, and upgraded databases may also retain legacy `Symbol` HNSW indexes until a safe rebuild.
1616

1717
### Fixed
1818

19+
- **Multi-repository Jina indexing crash**: Prevent later repositories from writing new `SymbolVectorEmbedding` rows through a large live LadybugDB HNSW, which could terminate the process after earlier repositories had populated the shared index.
20+
1921
## [0.13.5] - 2026-08-25
2022

2123
### Added

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ Read pool enables concurrent multi-session reads (4-6 MCP sessions). Write seria
305305
| **SummaryCache** | symbolId, summary, provider, model, cardHash, costUsd |
306306
| **SymbolReference** | referenceId, symbolId, file, line |
307307

308-
Production Symbol vectors live in `SymbolVectorEmbedding`, with one complete row per Symbol and model. Model-specific HNSW indexes target this table. Incremental indexing and background semantic repair delete and replace only changed embedding rows while retaining a healthy HNSW; index bootstrap occurs only when the exact table/name/type/property identity is absent and at least one complete row exists. Symbol ANN queries rank candidates inside a repository-scoped projected graph, so matching Symbol-to-File-to-Repo ownership filters the graph before the top-K search.
308+
Production Symbol vectors live in `SymbolVectorEmbedding`, with one complete row per Symbol and model. Model-specific HNSW indexes target this table. Incremental indexing and background semantic repair delete and replace only changed embedding rows inside one checkpointed HNSW drop/write/recreate cycle; index bootstrap occurs only when the exact table/name/type/property identity is absent and at least one complete row exists. Symbol ANN queries rank candidates inside a repository-scoped projected graph, so matching Symbol-to-File-to-Repo ownership filters the graph before the top-K search.
309309

310310
**Sync, policy, and memory nodes:**
311311

src/indexer/embeddings.ts

Lines changed: 92 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
} from "../db/ladybug-symbol-embeddings.js";
3232
import {
3333
createVectorIndex,
34+
dropVectorIndex,
3435
showIndexesStrict,
3536
} from "../retrieval/index-lifecycle.js";
3637
import {
@@ -422,63 +423,59 @@ export async function refreshSymbolEmbeddings(params: {
422423
shouldBootstrapIndex = configuredIndex === undefined;
423424
}
424425

425-
const bootstrapVectorIndex = async (): Promise<void> => {
426-
if (
427-
!shouldBootstrapIndex ||
428-
vecProp === null ||
429-
indexName === null ||
430-
!modelInfo
431-
) {
432-
return;
426+
const createRequiredVectorIndex = async (): Promise<void> => {
427+
if (vecProp === null || indexName === null || !modelInfo) return;
428+
recordMemorySnapshot("beforeHnsw");
429+
params.onProgress?.({
430+
stage: "embeddings",
431+
substage: "symbolVectorIndex",
432+
current: Math.min(skipped + embedded, symbols.length),
433+
total: symbols.length,
434+
model: storageModel,
435+
message: "building HNSW",
436+
});
437+
let ok: boolean;
438+
try {
439+
ok = await measure("hnsw.create", () =>
440+
withWriteConn((wConn) =>
441+
createVectorIndex(
442+
wConn,
443+
SYMBOL_VECTOR_EMBEDDING_TABLE,
444+
vecProp,
445+
indexName,
446+
modelInfo.dimension,
447+
params.vectorEfc,
448+
),
449+
),
450+
);
451+
} finally {
452+
recordMemorySnapshot("afterHnsw");
453+
}
454+
params.onProgress?.({
455+
stage: "embeddings",
456+
substage: "symbolVectorIndex",
457+
current: Math.min(skipped + embedded, symbols.length),
458+
total: symbols.length,
459+
model: storageModel,
460+
message: ok ? "ready" : "creation failed",
461+
});
462+
if (!ok) {
463+
throw new IndexError(
464+
`Failed to create required vector index '${indexName}' on ${SYMBOL_VECTOR_EMBEDDING_TABLE}.${vecProp}`,
465+
);
433466
}
467+
logger.info(
468+
`[embeddings] Vector index '${indexName}' created on ${SYMBOL_VECTOR_EMBEDDING_TABLE}`,
469+
);
470+
};
471+
472+
const bootstrapVectorIndex = async (): Promise<void> => {
473+
if (!shouldBootstrapIndex) return;
434474
if (!(await hasCompleteSymbolVectorEmbedding(conn, modelName))) return;
435475
await runHnswRebuildCycle(
436476
"symbol-vector-bootstrap-pre-create",
437477
"symbol-vector-bootstrap-post-create",
438-
async () => {
439-
recordMemorySnapshot("beforeHnsw");
440-
params.onProgress?.({
441-
stage: "embeddings",
442-
substage: "symbolVectorIndex",
443-
current: Math.min(skipped + embedded, symbols.length),
444-
total: symbols.length,
445-
model: storageModel,
446-
message: "building HNSW",
447-
});
448-
let ok: boolean;
449-
try {
450-
ok = await measure("hnsw.create", () =>
451-
withWriteConn((wConn) =>
452-
createVectorIndex(
453-
wConn,
454-
SYMBOL_VECTOR_EMBEDDING_TABLE,
455-
vecProp,
456-
indexName,
457-
modelInfo.dimension,
458-
params.vectorEfc,
459-
),
460-
),
461-
);
462-
} finally {
463-
recordMemorySnapshot("afterHnsw");
464-
}
465-
params.onProgress?.({
466-
stage: "embeddings",
467-
substage: "symbolVectorIndex",
468-
current: Math.min(skipped + embedded, symbols.length),
469-
total: symbols.length,
470-
model: storageModel,
471-
message: ok ? "ready" : "creation failed",
472-
});
473-
if (!ok) {
474-
throw new IndexError(
475-
`Failed to create required vector index '${indexName}' on ${SYMBOL_VECTOR_EMBEDDING_TABLE}.${vecProp}`,
476-
);
477-
}
478-
logger.info(
479-
`[embeddings] Vector index '${indexName}' created on ${SYMBOL_VECTOR_EMBEDDING_TABLE}`,
480-
);
481-
},
478+
createRequiredVectorIndex,
482479
params.postIndexSessionTimeoutMs,
483480
params.recordTiming,
484481
params.repoId,
@@ -542,6 +539,16 @@ export async function refreshSymbolEmbeddings(params: {
542539
// throughput optimisation.
543540
uncachedItems.sort((a, b) => a.prefixedText.length - b.prefixedText.length);
544541

542+
// LadybugDB can terminate the process when a large live HNSW receives new
543+
// vector rows. Keep every changed Symbol write inside one drop/recreate
544+
// cycle; an absent bootstrap index remains on the cheaper create-only path.
545+
const useRebuildPath =
546+
!shouldBootstrapIndex &&
547+
vecProp !== null &&
548+
indexName !== null &&
549+
modelInfo !== undefined &&
550+
uncachedItems.length > 0;
551+
545552
// Resolve concurrency: clamp to [1, MAX_EMBEDDING_CONCURRENCY].
546553
const maxConcurrency = Math.max(
547554
1,
@@ -757,7 +764,7 @@ export async function refreshSymbolEmbeddings(params: {
757764
}
758765
recordMemorySnapshot("afterInference");
759766
} finally {
760-
await bootstrapVectorIndex();
767+
if (!useRebuildPath) await bootstrapVectorIndex();
761768
}
762769

763770
// Progress: fire at end through fireProgress() so the monotonic clamp
@@ -771,5 +778,36 @@ export async function refreshSymbolEmbeddings(params: {
771778
? { embedded, skipped, degraded: true }
772779
: { embedded, skipped };
773780
};
774-
return runPersistenceCycle();
781+
if (!useRebuildPath || indexName === null) return runPersistenceCycle();
782+
return runHnswRebuildCycle(
783+
"symbol-vector-rebuild-pre-drop",
784+
"symbol-vector-rebuild-post-create",
785+
async () => {
786+
const dropResult = await measure("hnsw.drop", () =>
787+
withWriteConn((wConn) =>
788+
dropVectorIndex(
789+
wConn,
790+
SYMBOL_VECTOR_EMBEDDING_TABLE,
791+
indexName,
792+
),
793+
),
794+
);
795+
if (dropResult.status === "failed") {
796+
throw new IndexError(
797+
`Failed to drop required vector index '${indexName}' before Symbol embedding writes: ${dropResult.error}`,
798+
);
799+
}
800+
logger.info(
801+
`[embeddings] Dropped vector index '${indexName}' for ${uncachedItems.length} changed Symbol vector(s)`,
802+
);
803+
try {
804+
return await runPersistenceCycle();
805+
} finally {
806+
await createRequiredVectorIndex();
807+
}
808+
},
809+
params.postIndexSessionTimeoutMs,
810+
params.recordTiming,
811+
params.repoId,
812+
);
775813
}

tests/integration/semantic-embedding.test.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -891,7 +891,7 @@ describe("Semantic Embedding Pipeline", () => {
891891
);
892892
});
893893

894-
it("bootstraps HNSW once and retains it during incremental refresh", async () => {
894+
it("bootstraps HNSW and rebuilds it during incremental refresh", async () => {
895895
const { provider: recordingProvider } = createRecordingProvider();
896896
let embeddingCallsStarted = 0;
897897
let releaseConcurrentCalls!: () => void;
@@ -1012,11 +1012,23 @@ describe("Semantic Embedding Pipeline", () => {
10121012
});
10131013
assert.deepStrictEqual(second, { embedded: 1, skipped: 0 });
10141014
assert.deepStrictEqual(
1015-
incrementalPhases.filter(
1016-
(phaseName) => phaseName === "hnsw.drop" || phaseName === "hnsw.create",
1015+
incrementalPhases.filter((phaseName) =>
1016+
[
1017+
"checkpoint.pre",
1018+
"hnsw.drop",
1019+
"inference",
1020+
"hnsw.create",
1021+
"checkpoint.post",
1022+
].includes(phaseName),
10171023
),
1018-
[],
1019-
"incremental Symbol writes must retain the live HNSW",
1024+
[
1025+
"checkpoint.pre",
1026+
"hnsw.drop",
1027+
"inference",
1028+
"hnsw.create",
1029+
"checkpoint.post",
1030+
],
1031+
"incremental Symbol writes must run with the shared-table HNSW absent",
10201032
);
10211033
assert.strictEqual(await countSymbolVectorRows(conn), rowCountBefore);
10221034

tests/unit/semantic-pipeline-regressions.test.ts

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe("semantic pipeline regressions", () => {
8989
);
9090
});
9191

92-
it("retains Symbol HNSW while preserving FileSummary rebuilds", () => {
92+
it("rebuilds Symbol HNSW around incremental writes", () => {
9393
const symbolSource = readSource("src/indexer/embeddings.ts");
9494
const symbolStart = symbolSource.indexOf(
9595
"export async function refreshSymbolEmbeddings(",
@@ -101,40 +101,40 @@ describe("semantic pipeline regressions", () => {
101101
symbolEnd === -1 ? symbolSource.length : symbolEnd,
102102
);
103103

104-
assert.ok(
105-
!/dropVectorIndex\s*\(/.test(symbolBody),
106-
"Symbol embedding refresh must not drop its live HNSW",
107-
);
108-
assert.ok(
109-
!/rebuildMinUncachedRows|SYMBOL_VECTOR_REBUILD_MIN_ROWS|VECTOR_REBUILD_THRESHOLD|useRebuildPath/.test(
110-
symbolBody,
111-
),
112-
"Symbol embedding refresh must not defer small incremental writes behind a rebuild threshold",
104+
assert.match(
105+
symbolBody,
106+
/dropVectorIndex\(\s*wConn,\s*SYMBOL_VECTOR_EMBEDDING_TABLE,\s*indexName/s,
107+
"Symbol embedding refresh must remove the shared-table HNSW before writes",
113108
);
114-
115-
const fileSummarySource = readSource(
116-
"src/indexer/file-summary-embeddings.ts",
109+
assert.match(
110+
symbolBody,
111+
/symbol-vector-rebuild-pre-drop[\s\S]*symbol-vector-rebuild-post-create/,
112+
"Symbol embedding refresh must checkpoint the drop/write/recreate cycle",
117113
);
118-
assert.match(fileSummarySource, /rebuildMinUncachedRows/);
119114
assert.match(
120-
fileSummarySource,
121-
/dropVectorIndex\(wConn, "FileSummary", indexName\)/,
122-
"FileSummary keeps its existing drop/rebuild lifecycle",
115+
symbolBody,
116+
/dropResult\.status === "failed"[\s\S]*throw new IndexError/,
117+
"Symbol embedding refresh must fail before writes when HNSW drop fails",
118+
);
119+
assert.doesNotMatch(
120+
symbolBody,
121+
/rebuildMinUncachedRows|SYMBOL_VECTOR_REBUILD_MIN_ROWS/,
122+
"the safety lifecycle must not defer changed Symbol vectors",
123123
);
124124
});
125125

126126
it("fails refresh when a required Symbol HNSW bootstrap fails", () => {
127127
const source = readSource("src/indexer/embeddings.ts");
128-
const bootstrapStart = source.indexOf(
129-
"const bootstrapVectorIndex = async (): Promise<void> =>",
128+
const createStart = source.indexOf(
129+
"const createRequiredVectorIndex = async (): Promise<void> =>",
130130
);
131-
const bootstrapEnd = source.indexOf(
132-
"if (storageModel === \"mock-fallback\")",
133-
bootstrapStart,
131+
const createEnd = source.indexOf(
132+
"const bootstrapVectorIndex = async (): Promise<void> =>",
133+
createStart,
134134
);
135-
const bootstrapBody = source.slice(bootstrapStart, bootstrapEnd);
135+
const createBody = source.slice(createStart, createEnd);
136136

137-
assert.match(bootstrapBody, /if \(!ok\)[\s\S]*throw new IndexError/);
137+
assert.match(createBody, /if \(!ok\)[\s\S]*throw new IndexError/);
138138
});
139139

140140
it("runs semantic rebuilds outside ambient indexer sessions", () => {

0 commit comments

Comments
 (0)