mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
#3461 — insertChunks stamped DEFAULT_EMBEDDING_MODEL on chunk provenance
whenever the AI gateway was unconfigured: getEmbeddingModel() throws
rather than returning falsy, so the reland's '|| resolvedModel' guard
(e1919fab) was dead code and the catch path kept the compile-time
constant. Both engines now fall back to the brain's own
config.embedding_model row on the throw path, with the compiled default
as last resort. Same-line sibling: the ON CONFLICT clause overwrote
'model' via COALESCE even when the existing vector was preserved —
'model' now mirrors the 'embedding' CASE branch-for-branch so the label
always describes whichever vector wins the upsert. The initSchema
sizing sites drop their dead '||' terms too.
#3507 — every plain re-embed path (embed <slug>, --all, --stale, and
the embed-backfill Minion loop) embedded raw chunk_text, silently
stripping the contextual-retrieval prefixes the sync path applied —
and embed --stale is the NORMAL post-model-migration path. All four
sites now wrap through wrapChunkTextsForStoredMode(), reproducing the
page's STORED convention (pages.contextual_retrieval_mode):
title/per_chunk_synopsis pages get the title-tier prefix, fenced_code
chunks stay unwrapped (D20-T4), unstamped pages stay raw. A fully
re-embedded per_chunk_synopsis page is restamped to 'title' so the
mode column keeps describing the vectors actually in the DB.
Deliberately NOT folding the contextual mode into
currentEmbeddingSignature(): the convention is already recorded
per-page (mode + corpus_generation), pages legitimately differ
per-page so a global signature cannot represent it, and bumping
signature semantics would force a full-corpus re-embed on upgrade.
No KNOBS_HASH_VERSION change (no collision with #3514).
Tests fail on unmodified master (6) and pass with the fix; postgres.js
path verified against a real pgvector instance in addition to the
PGLite suite.
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
363 lines
14 KiB
TypeScript
363 lines
14 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();
|
|
});
|
|
|
|
// #3461: getEmbeddingModel() THROWS when the gateway is unconfigured — it
|
|
// never returns falsy — so the reland's `|| resolvedModel` guard was dead
|
|
// code and the catch path still stamped the compile-time default onto rows
|
|
// whose vectors came from the config-resolved provider. The engine must
|
|
// fall back to the brain's own `config.embedding_model` row instead.
|
|
test('#3461: unconfigured gateway falls back to the brain config model, never the compiled default', async () => {
|
|
await engine.setConfig('embedding_model', 'voyage:voyage-3-large');
|
|
// The preload's beforeEach re-configures the gateway before every test,
|
|
// so the reset must happen INSIDE the test body.
|
|
resetGateway();
|
|
|
|
await engine.putPage('docs/provenance-throw-path', {
|
|
type: 'concept',
|
|
title: 'Provenance throw-path page',
|
|
compiled_truth: 'Chunk written while the gateway is unconfigured.',
|
|
});
|
|
await engine.upsertChunks('docs/provenance-throw-path', [
|
|
{ chunk_index: 0, chunk_text: 'throw-path 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-throw-path'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].model).toBe('voyage:voyage-3-large');
|
|
|
|
// Restore the value initSchema wrote for the rest of the file.
|
|
await engine.setConfig('embedding_model', 'openai:text-embedding-3-large');
|
|
});
|
|
|
|
// #3461 sibling: on a partial re-upsert that carries NO new embedding (the
|
|
// exact shape `embed --stale` produces for a page's non-stale chunks), the
|
|
// preserved vector must KEEP its original model label. The old
|
|
// COALESCE(EXCLUDED.model, …) relabeled it with the current gateway model.
|
|
test('#3461: preserved vector keeps its original model label on a no-embedding re-upsert', async () => {
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { OPENAI_API_KEY: 'sk-test' },
|
|
});
|
|
|
|
await engine.putPage('docs/provenance-preserve', {
|
|
type: 'concept',
|
|
title: 'Provenance preserve page',
|
|
compiled_truth: 'Chunk embedded under model A, re-upserted under model B.',
|
|
});
|
|
await engine.upsertChunks('docs/provenance-preserve', [
|
|
{
|
|
chunk_index: 0,
|
|
chunk_text: 'stable chunk text',
|
|
chunk_source: 'compiled_truth',
|
|
embedding: new Float32Array(VEC1536_A),
|
|
},
|
|
]);
|
|
|
|
// Model swap: the gateway now resolves a different model, and the
|
|
// re-upsert (same chunk_text) carries no new embedding.
|
|
configureGateway({
|
|
embedding_model: 'voyage:voyage-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { VOYAGE_API_KEY: 'test' },
|
|
});
|
|
await engine.upsertChunks('docs/provenance-preserve', [
|
|
{ chunk_index: 0, chunk_text: 'stable chunk text', chunk_source: 'compiled_truth' },
|
|
]);
|
|
|
|
const rows = await engine.executeRaw<{ model: string; has_embedding: boolean }>(
|
|
`SELECT cc.model, cc.embedding IS NOT NULL AS has_embedding
|
|
FROM content_chunks cc
|
|
JOIN pages p ON p.id = cc.page_id
|
|
WHERE p.slug = 'docs/provenance-preserve'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].has_embedding).toBe(true); // vector preserved…
|
|
expect(rows[0].model).toBe('openai:text-embedding-3-large'); // …and its label still describes it
|
|
|
|
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)');
|
|
});
|
|
});
|