mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9a4b4cd31 | ||
|
|
567813028a |
+79
-1
@@ -130,6 +130,13 @@ export interface EmbedResult {
|
||||
pages_processed: number;
|
||||
/** True if this run was a dry-run. */
|
||||
dryRun: boolean;
|
||||
/**
|
||||
* #2089: take claims newly embedded in this run (the `--stale` path also
|
||||
* backfills `takes.embedding` so the vector takes arm — think takes_vec —
|
||||
* has data to search). Present only when > 0. In dryRun mode stale takes
|
||||
* are counted into `would_embed` instead.
|
||||
*/
|
||||
takes_embedded?: number;
|
||||
/**
|
||||
* E1 (paced-backfill): end-of-run pacing telemetry. Present ONLY when pacing
|
||||
* was active (enabled bundle). The number the operator could not get from an
|
||||
@@ -644,7 +651,17 @@ async function embedAll(
|
||||
// D7: thread sourceId so `gbrain embed --stale --source X` actually scopes.
|
||||
// v0.41.18.0 (A13): thread batchSize/priority/catchUp into the stale path.
|
||||
// #1737: thread the external abort signal so the cycle embed phase bails.
|
||||
return await embedAllStale(engine, sourceId, dryRun, result, onProgress, staleOpts, signature, signal);
|
||||
await embedAllStale(engine, sourceId, dryRun, result, onProgress, staleOpts, signature, signal);
|
||||
// #2089: backfill takes.embedding — before this pass nothing ever wrote
|
||||
// it, so the vector takes arm (searchTakesVector / think takes_vec) was
|
||||
// structurally dead. Runs on every --stale caller (CLI, cycle embed
|
||||
// phase, sync auto-embed). listStaleTakes is brain-wide (not
|
||||
// source-scoped) — embedding another source's takes is a harmless
|
||||
// idempotent write, not a read-side leak.
|
||||
if (!isAborted(signal)) {
|
||||
await embedStaleTakes(engine, dryRun, result, signal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --all path: pacer (no-op when off). E-1: lower the worker count to the
|
||||
@@ -770,6 +787,67 @@ async function embedAll(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #2089: embed stale take claims (`active AND embedding IS NULL`) so the
|
||||
* vector takes search arm has data. Mirrors embedAllStale's log-and-skip
|
||||
* semantics: a failure leaves the remaining takes stale (retried next run)
|
||||
* and never throws. Exported with an `embedFn` seam for tests (same pattern
|
||||
* as embedStaleForSource).
|
||||
*/
|
||||
export async function embedStaleTakes(
|
||||
engine: BrainEngine,
|
||||
dryRun: boolean,
|
||||
result: EmbedResult,
|
||||
signal?: AbortSignal,
|
||||
embedFn: (texts: string[], o: { abortSignal?: AbortSignal }) => Promise<Float32Array[]> =
|
||||
(texts, o) => embedBatchWithBackoff(texts, { abortSignal: o.abortSignal }),
|
||||
): Promise<void> {
|
||||
let staleCount: number;
|
||||
try {
|
||||
// ?? 0: tolerate partial engines (test mocks) that don't implement takes.
|
||||
staleCount = Number(await engine.countStaleTakes() ?? 0);
|
||||
} catch (e: unknown) {
|
||||
// Pre-takes-table brain (partial upgrade) — nothing to do.
|
||||
serr(` [embed] takes: stale count failed: ${e instanceof Error ? e.message : e}`);
|
||||
return;
|
||||
}
|
||||
if (!staleCount) return;
|
||||
|
||||
if (dryRun) {
|
||||
result.would_embed += staleCount;
|
||||
slog(`[dry-run] Would embed ${staleCount} stale take claim(s)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = (await engine.listStaleTakes()) ?? [];
|
||||
const BATCH = 100;
|
||||
let embedded = 0;
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
if (isAborted(signal)) break;
|
||||
const batch = rows.slice(i, i + BATCH);
|
||||
try {
|
||||
const embeddings = await embedFn(batch.map((r) => r.claim), { abortSignal: signal });
|
||||
embedded += await engine.updateTakeEmbeddings(
|
||||
batch.map((r, j) => ({ take_id: r.take_id, embedding: embeddings[j] })),
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
if (isAborted(signal)) break;
|
||||
// ponytail: stop on first failure (dim mismatch against the takes
|
||||
// vector column, provider outage) instead of grinding every batch into
|
||||
// the same error; the remaining takes stay NULL and retry next --stale.
|
||||
serr(
|
||||
` [embed] takes: failed at ${batch[0].page_slug}#${batch[0].row_num}: ` +
|
||||
`${e instanceof Error ? e.message : e}; remaining stale takes will retry next run`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (embedded > 0) {
|
||||
result.takes_embedded = (result.takes_embedded ?? 0) + embedded;
|
||||
slog(`Embedded ${embedded} take claim(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL-side stale path: replaces the listPages + per-page getChunks
|
||||
* walk with a count + slug-grouped SELECT. Preserves the existing
|
||||
|
||||
@@ -1524,6 +1524,14 @@ export interface BrainEngine {
|
||||
/** List stale takes (no embedding column in payload — same pattern as listStaleChunks). */
|
||||
listStaleTakes(): Promise<StaleTakeRow[]>;
|
||||
|
||||
/**
|
||||
* #2089: write claim embeddings for takes (the write side of
|
||||
* searchTakesVector). Sets `embedding` + `embedded_at`; does NOT bump
|
||||
* `updated_at` (embeddings are derived data, not a content change).
|
||||
* Returns the number of rows updated (inactive/deleted rows are skipped).
|
||||
*/
|
||||
updateTakeEmbeddings(rows: Array<{ take_id: number; embedding: Float32Array }>): Promise<number>;
|
||||
|
||||
/**
|
||||
* Update a take's mutable fields. May NOT change claim/kind/holder per the
|
||||
* supersession invariants — those route through supersedeTake. Throws
|
||||
|
||||
@@ -5671,6 +5671,62 @@ export const MIGRATIONS: Migration[] = [
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 125,
|
||||
name: 'takes_embedding_dim_align',
|
||||
// #2089: the takes migration hardcoded `embedding VECTOR(1536)` while
|
||||
// content_chunks/facts resolve dims from config, so brains with a
|
||||
// non-1536 embedder (e.g. Voyage 1024) could never write a take
|
||||
// embedding. Before v0.42.x there was ALSO no writer at all, so the
|
||||
// column is all-NULL on every install — retyping it to the configured
|
||||
// dims loses nothing. DROP/ADD (not ALTER TYPE) so the same path works
|
||||
// on PGLite, which can't ALTER COLUMN TYPE vector(N). Guarded: any
|
||||
// non-NULL embedding present → no-op (the current type demonstrably
|
||||
// works for that brain). All takes access uses explicit column lists,
|
||||
// so column-order change from DROP/ADD is safe.
|
||||
idempotent: true,
|
||||
sql: '',
|
||||
handler: async (engine: BrainEngine) => {
|
||||
let dims = 1536;
|
||||
try {
|
||||
const dimRows = await engine.executeRaw<{ value: string }>(
|
||||
`SELECT value FROM config WHERE key = 'embedding_dimensions'`,
|
||||
);
|
||||
const parsed = parseInt(dimRows[0]?.value ?? '', 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 16000) dims = parsed;
|
||||
} catch { /* no config row — keep default, which matches the DDL */ }
|
||||
|
||||
const colRows = await engine.executeRaw<{ formatted: string | null }>(
|
||||
`SELECT format_type(a.atttypid, a.atttypmod) AS formatted
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON c.oid = a.attrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relname = 'takes'
|
||||
AND a.attname = 'embedding' AND NOT a.attisdropped`,
|
||||
);
|
||||
const m = (colRows[0]?.formatted ?? '').match(/vector\((\d+)\)/i);
|
||||
const colDims = m ? parseInt(m[1], 10) : null;
|
||||
if (colDims === null || colDims === dims) return;
|
||||
|
||||
const dataRows = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM takes WHERE embedding IS NOT NULL`,
|
||||
);
|
||||
if (Number(dataRows[0]?.n ?? 0) > 0) {
|
||||
process.stderr.write(` v125: takes.embedding is vector(${colDims}) (config says ${dims}) but already holds data; leaving it alone\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
await engine.executeRaw(`DROP INDEX IF EXISTS idx_takes_embedding_hnsw`);
|
||||
await engine.executeRaw(`ALTER TABLE takes DROP COLUMN embedding`);
|
||||
await engine.executeRaw(`ALTER TABLE takes ADD COLUMN embedding VECTOR(${dims})`);
|
||||
await engine.executeRaw(
|
||||
`CREATE INDEX IF NOT EXISTS idx_takes_embedding_hnsw ON takes
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
WHERE active AND embedding IS NOT NULL`,
|
||||
);
|
||||
process.stderr.write(` v125: takes.embedding retyped vector(${colDims}) → vector(${dims}) to match the configured embedder (#2089)\n`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
@@ -4869,6 +4869,24 @@ export class PGLiteEngine implements BrainEngine {
|
||||
return rows as unknown as StaleTakeRow[];
|
||||
}
|
||||
|
||||
async updateTakeEmbeddings(rows: Array<{ take_id: number; embedding: Float32Array }>): Promise<number> {
|
||||
if (rows.length === 0) return 0;
|
||||
let updated = 0;
|
||||
// ponytail: per-row UPDATE loop — takes volume is small (hundreds, not
|
||||
// millions); switch to an unnest batch if listStaleTakes ever pages.
|
||||
for (const r of rows) {
|
||||
const vec = `[${Array.from(r.embedding).join(',')}]`;
|
||||
const res = await this.db.query(
|
||||
`UPDATE takes SET embedding = $2::vector, embedded_at = now()
|
||||
WHERE id = $1 AND active
|
||||
RETURNING 1`,
|
||||
[r.take_id, vec]
|
||||
);
|
||||
updated += res.rows.length;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateTake(
|
||||
pageId: number,
|
||||
rowNum: number,
|
||||
|
||||
@@ -5004,6 +5004,24 @@ export class PostgresEngine implements BrainEngine {
|
||||
return rows as unknown as StaleTakeRow[];
|
||||
}
|
||||
|
||||
async updateTakeEmbeddings(rows: Array<{ take_id: number; embedding: Float32Array }>): Promise<number> {
|
||||
if (rows.length === 0) return 0;
|
||||
const sql = this.sql;
|
||||
let updated = 0;
|
||||
// ponytail: per-row UPDATE loop — takes volume is small (hundreds, not
|
||||
// millions); switch to an unnest batch if listStaleTakes ever pages.
|
||||
for (const r of rows) {
|
||||
const vec = `[${Array.from(r.embedding).join(',')}]`;
|
||||
const res = await sql`
|
||||
UPDATE takes SET embedding = ${vec}::vector, embedded_at = now()
|
||||
WHERE id = ${r.take_id} AND active
|
||||
RETURNING 1
|
||||
`;
|
||||
updated += res.length;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateTake(
|
||||
pageId: number,
|
||||
rowNum: number,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* #2089: takes.embedding write path. Before this fix nothing ever wrote
|
||||
* takes.embedding, so the vector takes arm (searchTakesVector / think
|
||||
* takes_vec) was structurally dead on every install. Covers:
|
||||
* - embedStaleTakes backfills stale claims (injected embedFn, no gateway)
|
||||
* - dry-run counts without writing
|
||||
* - searchTakesVector actually returns hits once embeddings exist
|
||||
* - migration v125 retypes the hardcoded vector(1536) column to the
|
||||
* configured embedder dims when the column is empty
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { embedStaleTakes, type EmbedResult } from '../src/commands/embed.ts';
|
||||
import { MIGRATIONS } from '../src/core/migrate.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let pageId: number;
|
||||
|
||||
const DIMS = 1536;
|
||||
|
||||
/** Deterministic fake embedder: unit vector rotated by first char code. */
|
||||
function fakeEmbed(text: string): Float32Array {
|
||||
const v = new Float32Array(DIMS);
|
||||
v[text.charCodeAt(0) % DIMS] = 1;
|
||||
return v;
|
||||
}
|
||||
const embedFn = async (texts: string[]) => texts.map(fakeEmbed);
|
||||
|
||||
function freshResult(dryRun = false): EmbedResult {
|
||||
return { embedded: 0, skipped: 0, would_embed: 0, total_chunks: 0, pages_processed: 0, dryRun };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
const page = await engine.putPage('people/alice-example', {
|
||||
title: 'Alice Example',
|
||||
type: 'person' as const,
|
||||
compiled_truth: '## Takes\n\nAlice is a strong founder.\n',
|
||||
});
|
||||
pageId = page.id;
|
||||
await engine.addTakesBatch([
|
||||
{ page_id: pageId, row_num: 1, claim: 'Alice is a strong technical founder', kind: 'take', holder: 'garry', weight: 0.9 },
|
||||
{ page_id: pageId, row_num: 2, claim: 'Zebra stripes are cosmetic', kind: 'hunch', holder: 'garry', weight: 0.4 },
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
describe('embedStaleTakes (#2089)', () => {
|
||||
test('dry-run counts stale takes into would_embed without writing', async () => {
|
||||
const result = freshResult(true);
|
||||
await embedStaleTakes(engine, true, result, undefined, embedFn);
|
||||
expect(result.would_embed).toBe(2);
|
||||
expect(await engine.countStaleTakes()).toBe(2);
|
||||
});
|
||||
|
||||
test('backfills stale take embeddings and makes searchTakesVector live', async () => {
|
||||
expect(await engine.countStaleTakes()).toBe(2);
|
||||
|
||||
const result = freshResult();
|
||||
await embedStaleTakes(engine, false, result, undefined, embedFn);
|
||||
|
||||
expect(result.takes_embedded).toBe(2);
|
||||
expect(await engine.countStaleTakes()).toBe(0);
|
||||
|
||||
// The read side that was structurally dead: query with the same fake
|
||||
// embedding as the 'Alice...' claim → that take ranks first.
|
||||
const hits = await engine.searchTakesVector(fakeEmbed('Alice is a strong technical founder'), { limit: 5 });
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
expect(hits[0].claim).toBe('Alice is a strong technical founder');
|
||||
expect(hits[0].score).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
test('second run is a no-op (embedding IS NULL predicate)', async () => {
|
||||
const result = freshResult();
|
||||
await embedStaleTakes(engine, false, result, undefined, async (texts) => {
|
||||
throw new Error(`should not be called, got ${texts.length} texts`);
|
||||
});
|
||||
expect(result.takes_embedded).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration v125: takes.embedding dim align (#2089)', () => {
|
||||
// Fresh engine so the column is untouched (all NULL).
|
||||
let e2: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
e2 = new PGLiteEngine();
|
||||
await e2.connect({});
|
||||
await e2.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await e2.disconnect();
|
||||
});
|
||||
|
||||
test('retypes an all-NULL 1536 column to the configured dims', async () => {
|
||||
await e2.executeRaw(`UPDATE config SET value = '8' WHERE key = 'embedding_dimensions'`);
|
||||
const v125 = MIGRATIONS.find((m) => m.version === 125);
|
||||
expect(v125?.handler).toBeDefined();
|
||||
await v125!.handler!(e2);
|
||||
|
||||
const rows = await e2.executeRaw<{ formatted: string }>(
|
||||
`SELECT format_type(a.atttypid, a.atttypmod) AS formatted
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON c.oid = a.attrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relname = 'takes'
|
||||
AND a.attname = 'embedding' AND NOT a.attisdropped`,
|
||||
);
|
||||
expect(rows[0].formatted).toBe('vector(8)');
|
||||
|
||||
// And the write path works at the new dims.
|
||||
const page = await e2.putPage('companies/acme-example', {
|
||||
title: 'Acme', type: 'company' as const, compiled_truth: 'Acme.\n',
|
||||
});
|
||||
await e2.addTakesBatch([
|
||||
{ page_id: page.id, row_num: 1, claim: 'B2B SaaS', kind: 'fact', holder: 'world', weight: 1 },
|
||||
]);
|
||||
const updated = await e2.updateTakeEmbeddings([
|
||||
{ take_id: (await e2.listStaleTakes())[0].take_id, embedding: new Float32Array([1, 0, 0, 0, 0, 0, 0, 0]) },
|
||||
]);
|
||||
expect(updated).toBe(1);
|
||||
expect(await e2.countStaleTakes()).toBe(0);
|
||||
});
|
||||
|
||||
test('no-op when column dims already match config', async () => {
|
||||
// The main engine is vector(1536) with config 1536: handler returns
|
||||
// without touching the (now populated) column.
|
||||
const v125 = MIGRATIONS.find((m) => m.version === 125);
|
||||
await v125!.handler!(engine);
|
||||
expect(await engine.countStaleTakes()).toBe(0); // embeddings survived
|
||||
});
|
||||
});
|
||||
@@ -691,6 +691,13 @@ const COLUMN_EXEMPTIONS = new Set<string>([
|
||||
//
|
||||
// Refreshing PGLITE_SCHEMA_SQL is a separate concern handled by
|
||||
// `bun run build:schema` from src/schema.sql; not gated by this test.
|
||||
// v125 (#2089): takes.embedding is not NEW — the takes table (migration
|
||||
// v37) already carries it. v125's ADD COLUMN is the second half of a
|
||||
// DROP/ADD retype to the configured embedder dims (all-NULL column, so
|
||||
// nothing to convert), guarded to run only on dim mismatch. The takes
|
||||
// table is migration-created (absent from the schema blob), so there is
|
||||
// no forward reference for the bootstrap to cover.
|
||||
'takes.embedding',
|
||||
'minion_jobs.quiet_hours',
|
||||
'minion_jobs.stagger_key',
|
||||
'sources.chunker_version',
|
||||
|
||||
Reference in New Issue
Block a user