fix(embed): stamp gateway-resolved model in content_chunks.model, not compiled default (#2846)

upsertChunks fell back to the compile-time DEFAULT_EMBEDDING_MODEL
('zeroentropyai:zembed-1') when a ChunkInput carried no explicit `model`.
The embed pipeline (src/commands/embed.ts) builds ChunkInputs without a
`model` field, so rows whose vectors were produced by the config-resolved
model (e.g. openai:text-embedding-3-large) were mislabeled with the
hardcoded default — corrupting the provenance that signature-drift
staleness and dimension-migration logic depend on.

Both engines now resolve the gateway's runtime embedding model once per
upsert and use it as the fallback, mirroring the existing resolve-then-
default pattern used for schema sizing. Regression test added (pglite);
verified via negative control that it fails against the old fallback.

This is a write-path change (upsertChunks), not a search-path change, so
retrieval eval replay is not applicable.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
SailorJoe6
2026-07-23 11:37:06 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent e36251c023
commit 5aa4795c04
3 changed files with 69 additions and 2 deletions
+13 -1
View File
@@ -2288,6 +2288,18 @@ export class PGLiteEngine implements BrainEngine {
const params: unknown[] = [];
let paramIdx = 1;
// Provenance fallback for chunks without an explicit `model`: resolve the
// gateway's runtime model, not the compile-time DEFAULT_EMBEDDING_MODEL.
// See postgres-engine.ts _upsertChunksOnce for the full rationale — pglite
// mirrors it for parity.
let resolvedModel: string = DEFAULT_EMBEDDING_MODEL;
try {
const gw = await import('./ai/gateway.ts');
resolvedModel = gw.getEmbeddingModel() || resolvedModel;
} catch {
// Gateway unconfigured (unit tests / pre-connect): keep the default.
}
for (const chunk of chunks) {
const embeddingStr = chunk.embedding
? '[' + Array.from(chunk.embedding).join(',') + ']'
@@ -2320,7 +2332,7 @@ export class PGLiteEngine implements BrainEngine {
if (embeddingImageStr) params.push(embeddingImageStr);
params.push(
pageId, chunk.chunk_index, chunk.chunk_text, chunk.chunk_source,
chunk.model || DEFAULT_EMBEDDING_MODEL, chunk.token_count || null,
chunk.model || resolvedModel, chunk.token_count || null,
chunk.language || null, chunk.symbol_name || null, chunk.symbol_type || null,
chunk.start_line ?? null, chunk.end_line ?? null,
parentPath, chunk.doc_comment || null, chunk.symbol_name_qualified || null,
+18 -1
View File
@@ -2421,6 +2421,23 @@ export class PostgresEngine implements BrainEngine {
const params: unknown[] = [];
let paramIdx = 1;
// Provenance fallback for chunks that don't carry an explicit `model`:
// resolve the model the gateway ACTUALLY uses at runtime, not the
// compile-time DEFAULT_EMBEDDING_MODEL constant. Callers like `embed`
// build ChunkInputs without a `model` field (src/commands/embed.ts), so
// the old `chunk.model || DEFAULT_EMBEDDING_MODEL` fallback stamped the
// hardcoded default (e.g. zeroentropyai:zembed-1) onto rows whose vectors
// were produced by a different, config-resolved model — corrupting the
// provenance that signature-drift staleness + dim-migration logic trust.
// Mirrors the resolve-then-fallback pattern used for schema sizing above.
let resolvedModel: string = DEFAULT_EMBEDDING_MODEL;
try {
const gw = await import('./ai/gateway.ts');
resolvedModel = gw.getEmbeddingModel() || resolvedModel;
} catch {
// Gateway unconfigured (unit tests / pre-connect): keep the default.
}
for (const chunk of chunks) {
const embeddingStr = chunk.embedding
? '[' + Array.from(chunk.embedding).join(',') + ']'
@@ -2450,7 +2467,7 @@ export class PostgresEngine implements BrainEngine {
if (embeddingImageStr) params.push(embeddingImageStr);
params.push(
pageId, chunk.chunk_index, chunk.chunk_text, chunk.chunk_source,
chunk.model || DEFAULT_EMBEDDING_MODEL, chunk.token_count || null,
chunk.model || resolvedModel, chunk.token_count || null,
chunk.language || null, chunk.symbol_name || null, chunk.symbol_type || null,
chunk.start_line ?? null, chunk.end_line ?? null,
parentPath, chunk.doc_comment || null, chunk.symbol_name_qualified || null,
+38
View File
@@ -216,6 +216,44 @@ describe('hybridSearch + resolver — unknown column at entry (D11)', () => {
});
});
describe('upsertChunks — model provenance uses gateway-resolved model, not compiled default', () => {
// Regression (zbrain-rfi): when a caller builds ChunkInputs without an
// explicit `model` (as src/commands/embed.ts does), the engine used to
// stamp the compile-time DEFAULT_EMBEDDING_MODEL ('zeroentropyai:zembed-1')
// onto content_chunks.model — even though the vector was produced by the
// config-resolved model. That corrupted provenance the signature-drift +
// dim-migration logic trusts. The engine must fall back to the model the
// gateway ACTUALLY resolves at write time.
test('unspecified chunk.model records the resolved model, not zeroentropyai:zembed-1', async () => {
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { OPENAI_API_KEY: 'sk-test' },
});
await engine.putPage('docs/provenance-page', {
type: 'concept',
title: 'Provenance test page',
compiled_truth: 'Chunk whose model column must reflect the resolved model.',
});
// No `model` field on the input — the write-side fallback must fill it.
await engine.upsertChunks('docs/provenance-page', [
{ chunk_index: 0, chunk_text: 'provenance chunk', chunk_source: 'compiled_truth' },
]);
const rows = await engine.executeRaw<{ model: string }>(
`SELECT cc.model FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
WHERE p.slug = 'docs/provenance-page'`,
);
expect(rows.length).toBe(1);
expect(rows[0].model).toBe('openai:text-embedding-3-large');
expect(rows[0].model).not.toBe('zeroentropyai:zembed-1');
resetGateway();
});
});
describe('buildVectorCastFragment — engine SQL composer (D3)', () => {
test('vector descriptor emits $1::vector', () => {
const r: ResolvedColumn = {