Files
gbrain/test/e2e/embedding-column-pglite.test.ts
T
e1919fab9f reland: fix(embed): stamp gateway-resolved model in content_chunks.model, not compiled default (#2846) (#3343)
* 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>

* test: Lane A.7 pins gateway-resolved chunk model, not compiled default

#2846 changed upsertChunks' fallback from DEFAULT_EMBEDDING_MODEL to the
gateway-resolved runtime model. Lane A.7 still pinned the old fallback,
and the test preload (test/helpers/legacy-embedding-preload.ts) pins the
gateway to openai:text-embedding-3-large for every test process — so the
original #2846 landing failed this test deterministically and got batch-
reverted. The test now asserts the resolved model (the intended #2846
semantics) while keeping the CDX2-4 bare-literal regression guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: SailorJoe6 <SailorJoe6@Gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-23 18:48:22 -07:00

282 lines
10 KiB
TypeScript

/**
* v0.36 E2E — dynamic embedding column selection (PGLite).
*
* Covers (per D4 + D9 + D11 + D12 + CDX-2 + CDX-3 + CDX-7 + CDX-8 + CDX-10):
* - Multi-column search: same query against `embedding` and against an
* ad-hoc `embedding_voyage` column produces different orderings
* consistent with the seeded vectors.
* - Halfvec column: ALTER TABLE ADD `embedding_ze halfvec(2560)` and
* confirm the `$1::halfvec(2560)` cast works.
* - Image branch unaffected: `embedding_image` still works via the
* existing operations.ts path.
* - cosineReScore reads from the active column, not the default
* (D9 — pre-fix, rescore against Voyage HNSW used OpenAI vectors).
* - Unknown column at hybridSearch entry throws loud.
* - Mid-session column switch invalidates the cache (knobs_hash v=3).
*
* No DATABASE_URL needed — PGLite in-memory.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { hybridSearch } from '../../src/core/search/hybrid.ts';
import {
buildVectorCastFragment,
EmbeddingColumnNotRegisteredError,
} from '../../src/core/search/embedding-column.ts';
import {
configureGateway,
resetGateway,
__setEmbedTransportForTests,
} from '../../src/core/ai/gateway.ts';
import type { ResolvedColumn } from '../../src/core/types.ts';
let engine: PGLiteEngine;
let chunkIdA: number;
let chunkIdB: number;
const VEC1536_A = new Array(1536).fill(0).map((_, i) => 0.001 * (i % 10));
const VEC1536_B = new Array(1536).fill(0).map((_, i) => 0.002 * (i % 10));
const VEC1024_A = new Array(1024).fill(0).map((_, i) => 0.5 - 0.001 * (i % 10));
const VEC1024_B = new Array(1024).fill(0).map((_, i) => 0.4 + 0.001 * (i % 10));
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Add the ad-hoc Voyage + ZE columns the way a user with a multi-provider
// brain has done it (outside the committed schema, per-instance ALTER).
await (engine as any).db.exec(
`ALTER TABLE content_chunks ADD COLUMN IF NOT EXISTS embedding_voyage vector(1024)`,
);
await (engine as any).db.exec(
`ALTER TABLE content_chunks ADD COLUMN IF NOT EXISTS embedding_ze halfvec(2560)`,
);
// Two pages with one chunk each.
await engine.putPage('docs/page-a', {
type: 'concept',
title: 'Page A — about cats',
compiled_truth: 'Page A discusses cats and their behavior.',
});
await engine.putPage('docs/page-b', {
type: 'concept',
title: 'Page B — about dogs',
compiled_truth: 'Page B discusses dogs and their habits.',
});
await engine.upsertChunks('docs/page-a', [
{ chunk_index: 0, chunk_text: 'cats behavior chunk A', chunk_source: 'compiled_truth' },
]);
await engine.upsertChunks('docs/page-b', [
{ chunk_index: 0, chunk_text: 'dogs habits chunk B', chunk_source: 'compiled_truth' },
]);
// Look up chunk ids.
const rows = await engine.executeRaw<{ id: number; slug: string }>(
`SELECT cc.id, p.slug FROM content_chunks cc JOIN pages p ON p.id = cc.page_id ORDER BY p.slug`,
);
chunkIdA = rows.find(r => r.slug === 'docs/page-a')!.id;
chunkIdB = rows.find(r => r.slug === 'docs/page-b')!.id;
// Seed vectors. Vectors are intentionally distinct between columns so
// search orderings depend on which column the engine actually reads.
const vecLit = (arr: number[]) => `[${arr.join(',')}]`;
await (engine as any).db.query(
`UPDATE content_chunks SET embedding = $1::vector WHERE id = $2`,
[vecLit(VEC1536_A), chunkIdA],
);
await (engine as any).db.query(
`UPDATE content_chunks SET embedding = $1::vector WHERE id = $2`,
[vecLit(VEC1536_B), chunkIdB],
);
await (engine as any).db.query(
`UPDATE content_chunks SET embedding_voyage = $1::vector WHERE id = $2`,
[vecLit(VEC1024_A), chunkIdA],
);
await (engine as any).db.query(
`UPDATE content_chunks SET embedding_voyage = $1::vector WHERE id = $2`,
[vecLit(VEC1024_B), chunkIdB],
);
});
afterAll(async () => {
if (engine) await engine.disconnect();
__setEmbedTransportForTests(null);
resetGateway();
});
describe('PGLite engine: searchVector accepts ResolvedColumn descriptor (D11)', () => {
test('vector cast routes to correct column when descriptor names embedding_voyage', async () => {
const queryVec = new Float32Array(VEC1024_A);
const descriptor: ResolvedColumn = {
name: 'embedding_voyage',
type: 'vector',
dimensions: 1024,
embeddingModel: 'voyage:voyage-3-large',
};
const results = await engine.searchVector(queryVec, {
embeddingColumn: descriptor,
limit: 5,
});
// Both pages have voyage embeddings; cosine to VEC1024_A is closer to
// page-a (identical) than page-b. Verify ordering.
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0].slug).toBe('docs/page-a');
});
test('halfvec cast accepted: ALTER TABLE column + $1::halfvec(N)', async () => {
// Seed halfvec values via direct cast.
const ze1 = `[${new Array(2560).fill(0.5).join(',')}]`;
const ze2 = `[${new Array(2560).fill(0.6).join(',')}]`;
await (engine as any).db.query(
`UPDATE content_chunks SET embedding_ze = $1::halfvec WHERE id = $2`,
[ze1, chunkIdA],
);
await (engine as any).db.query(
`UPDATE content_chunks SET embedding_ze = $1::halfvec WHERE id = $2`,
[ze2, chunkIdB],
);
const queryVec = new Float32Array(2560).fill(0.5);
const descriptor: ResolvedColumn = {
name: 'embedding_ze',
type: 'halfvec',
dimensions: 2560,
embeddingModel: 'zeroentropyai:zembed-1',
};
const results = await engine.searchVector(queryVec, {
embeddingColumn: descriptor,
limit: 5,
});
expect(results.length).toBeGreaterThanOrEqual(1);
// Page A's halfvec is closer to the all-0.5 query.
expect(results[0].slug).toBe('docs/page-a');
});
test('legacy embedding_image literal still routes correctly', async () => {
// We never seeded embedding_image so we expect zero results, but the
// query MUST NOT throw — the legacy-literal path must still work
// (no regression on the existing image branch).
const v = new Float32Array(1024).fill(0.1);
const results = await engine.searchVector(v, {
embeddingColumn: 'embedding_image',
limit: 5,
});
expect(Array.isArray(results)).toBe(true);
});
});
describe('PGLite engine: getEmbeddingsByChunkIds column param (D9)', () => {
test('default fetches from embedding (back-compat)', async () => {
const map = await engine.getEmbeddingsByChunkIds([chunkIdA, chunkIdB]);
expect(map.get(chunkIdA)!.length).toBe(1536);
});
test('column="embedding_voyage" fetches from voyage column', async () => {
const map = await engine.getEmbeddingsByChunkIds([chunkIdA, chunkIdB], 'embedding_voyage');
expect(map.get(chunkIdA)!.length).toBe(1024);
});
test('invalid column rejected at engine layer (regex guard)', async () => {
let threw: Error | null = null;
try {
await engine.getEmbeddingsByChunkIds([chunkIdA], 'embed-bad-name');
} catch (e) {
threw = e as Error;
}
expect(threw).toBeInstanceOf(EmbeddingColumnNotRegisteredError);
});
});
describe('hybridSearch + resolver — unknown column at entry (D11)', () => {
test('unknown name in opts.embeddingColumn throws via resolver', async () => {
// configureGateway with a transport stub so we don't hit a real API.
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { OPENAI_API_KEY: 'sk-test' },
});
__setEmbedTransportForTests(async () => ({
embeddings: [new Array(1536).fill(0)],
usage: { tokens: 0 },
} as any));
let threw: Error | null = null;
try {
await hybridSearch(engine, 'cats', {
embeddingColumn: 'nonexistent_column',
limit: 5,
});
} catch (e) {
threw = e as Error;
}
expect(threw).toBeInstanceOf(EmbeddingColumnNotRegisteredError);
});
});
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 = {
name: 'embedding',
type: 'vector',
dimensions: 1536,
embeddingModel: '',
};
const { col, castSql } = buildVectorCastFragment(r);
expect(col).toBe('"embedding"');
expect(castSql).toBe('$1::vector');
});
test('halfvec descriptor emits $1::halfvec(N) with parenthesized N', () => {
const r: ResolvedColumn = {
name: 'embedding_ze',
type: 'halfvec',
dimensions: 2560,
embeddingModel: 'zeroentropyai:zembed-1',
};
const { col, castSql } = buildVectorCastFragment(r);
expect(col).toBe('"embedding_ze"');
expect(castSql).toBe('$1::halfvec(2560)');
});
});