#4812·gbrain

No bulk verb to backfill facts.embedding — embed --stale / embed-backfill / embed-catch-up are chunk-only

Author: jbonettiCreated Sep 2, 2026Updated Sep 17, 2026
Labelstriage:new-feature

Version: gbrain 0.46.30.0, Postgres (Supabase) engine, macOS launchd.

Observed: embed --stale, embed --catch-up, and the embed-backfill/embed-catch-up job handlers all operate on content_chunks only — none touch facts.embedding. There is no gbrain facts embed / embed --stale --facts verb, and none of the registered facts handlers (facts-absorb, extract-conversation-facts) do a bulk "embed every fact currently missing a vector" sweep: facts-absorb requires a single data.slug (one page at a time, semantics for existing facts unclear) and extract-conversation-facts extracts new facts from source pages rather than embedding facts that already exist. gbrain migrate embeddings --status reports the gap (facts pending: N) but the migration command's own dry-run note says facts "re-embed on their next write/extract" — i.e. by design this is lazy-on-touch, with no way to force a sweep for facts that never get touched again.

Confirmed this isn't just a --status artifact: postgres-engine/facts.ts runs semantic fact search as WHERE embedding IS NOT NULL ORDER BY embedding <=> ... — rows with a null embedding are silently excluded from every semantic recall over facts.

We independently found embed-backfill triggers a full sync.imports pass as a side effect when run without a matching in-flight job — surprising given the name, worth flagging: it imported 12 unrelated files in our environment.

Trigger for us: two independent bugs stacked. (1) collector scripts resolved OPENAI_API_KEY via the wrong openclaw.json path and silently sent the literal string "undefined" as the key for weeks — every embed call failed, chunks and facts both. (2) an embedding-dimension migration (3072d → 1536d) landed 2026-09-02. Both are now fixed on our side. Chunks self-healed cleanly via embed --stale once the key was fixed. Facts did not, and — per the design note above — structurally cannot, without a bulk verb.

Scale on our brain: 7,284 of 7,601 facts (96%) have embedding IS NULL; oldest is dated 2026-07-01. 1,378 distinct source_markdown_slug pages own the affected rows, and 2,258 facts have no source page at all, so even a per-page loop through facts-absorb (if its semantics turn out to fit) can't reach roughly a third of them.

Ask: a bulk facts-embed verb mirroring embed --stale for chunks — e.g. gbrain embed --stale --facts or gbrain facts embed --stale — that sweeps every facts row with embedding IS NULL (or a stale/mismatched signature, matching the page-chunk convention) regardless of whether its source page is ever touched again. A doctor check flagging facts pending: N > 0 would also help surface this proactively rather than only via migrate embeddings --status.

Repro:

gbrain migrate embeddings --status   # shows "facts pending: N" for any N > 0
gbrain embed --stale                 # drains chunks, facts pending unchanged
gbrain jobs submit embed-backfill --params '{"sourceId":"default"}' --follow   # embedded: 0 (chunk-scoped)
gbrain jobs submit embed-catch-up --follow                                     # embedded: 0 (chunk-scoped)
gbrain jobs submit facts-absorb --follow                                       # fails: "requires data.slug"