omnigraph/docs/rfcs/rfc-0015-ingest-embeddings.md
Ragnor Comerford ca2ad974e1 docs: self-service upgrade message + gated cross-version test (on-demand)
The storage-format refusal now names the release line that wrote each
internal-schema stamp (e.g. v3 -> 0.6.2-0.7.2) plus the exact
export/init/load commands — fail-closed but self-service; the message is
engine-shared so server boot-quarantine and cluster status get it free.

Adds the OMNIGRAPH_OLD_BIN-gated cross-version test: mint a GENUINE v3
graph with a real omnigraph-cli 0.7.2 binary, assert the current binary
refuses it with the release-named message, and the documented rebuild
round-trips. Deliberately NOT wired into CI (the write_cost_s3
disposition): compiling 0.7.2 from source is a 15-25 min job and the
stamp seam changes a few times a year — testing.md documents the
on-demand invocation instead.

Rebased onto current main across the Lance 9.0.0-beta.15 migration;
validated locally against a genuine 0.7.2 binary — the v3 round-trip
passes on the Lance-9 main, which also makes it a live cross-substrate
read-compat check (a Lance-9 binary reading a Lance-7-written manifest
to find the stamp).
2026-07-06 12:48:25 +03:00

258 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# RFC-015: Ingest-time `@embed` via the embedding reconciler
**Status:** Proposed
**Date:** 2026-06-21
**Tickets:** `iss-ingest-embed` (umbrella); relates to RFC-012 (embedding client),
invariant 7 (indexes are derived state)
**Author track:** maintainer-internal RFC
> Evidence tags: **[S]** verified in v0.7.1 source (`file:line`), **[U]** verified
> against an external system, **[D]** documented behavior. Unmarked = design intent.
## Summary
`@embed("source_text", model="…")` records *which* String property seeds a
Vector column and with *which* model, but **does not embed at write time** — the
loader and mutation paths never call the embedder, and stored vectors must be
supplied in the load data or pre-filled by the offline `omnigraph embed` file
pipeline ([embeddings.md:92](../user/search/embeddings.md) [D]).
This RFC closes that gap **without putting a network call on the write path.**
An embedding is *derived physical state* — the same class as an index — so it is
materialized by a **reconciler**, not inline: `load`/`mutate` store the source
text and leave the dependent vector **pending** (NULL); an `ensure_embeddings`
reconciler (sibling of today's `ensure_indices`) embeds the pending rows in
batches from manifest state, with bounded retry. A not-yet-embedded row is
queryable by traversal/filter immediately and becomes a `nearest()` hit once
materialized — documented partial coverage, exactly as for a deferred index.
This is invariant 7 applied to embeddings, and it is the design the Postgres
ecosystem converged on (Timescale pgai Vectorizer, Supabase pgmq) [U].
## Motivation
The natural request — "embed automatically when I add data" — has one obvious
implementation (embed inline during `mutate`/`load`) that is **wrong for this
codebase on three independent counts**:
1. **It is on the deny-list.** [invariants.md](invariants.md) forbids
"synchronous inline vector/FTS index rebuilds on the write path." An
embedding is the same shape (expensive, network-bound, derivable). The
deny-list does not distinguish "build the index" from "compute the value the
index covers."
2. **It blows the write-path cost contract.** RFC-013 (and step 3b,
[rfc-013-step-3b-writetxn.md](rfc-013-step-3b-writetxn.md)) is making the
write path O(1) and failure-bounded. A per-row embedding call is
O(network-latency) and fallible — a single-row `mutate` would block on a
model round-trip (the latency that makes this unacceptable for interactive
mutations).
3. **It couples a logical write to a flaky external service.** Per pgai's stated
rule: *"primary data-modification operations (INSERT/UPDATE/DELETE) should not
be dependent on the embedding operation, otherwise the application is down
every time the endpoint is slow or fails"* [U]. That is invariant 7's "a
physical operation must never fail a logical one," verbatim from the field.
### Prior art (the three patterns)
| Pattern | Who | Fit |
|---|---|---|
| **Pre-embed / BYO vector** | raw pgvector, Qdrant, Milvus (classic), Atlas, Cloudflare Vectorize | omnigraph already has this — the `omnigraph embed` *file* pipeline. Keep it. |
| **Synchronous-inline at write** | **HelixDB** (`AddV(Embed(content), …)`) [U], Weaviate vectorizers, Milvus 2.5 functions, Pinecone integrated inference, LanceDB embedding registry | Blocks the write; fine for bulk-batched, bad for single-row mutations. **HelixDB — the closest analog — took this path and inherited the latency.** Forbidden here by the deny-list. |
| **Async materialization / reconciler** | **Timescale pgai Vectorizer**, Supabase (trigger → `pgmq` → worker) [U] | The write enqueues; a batched, retrying worker embeds and writes back; "a delay may occur before [vectors] become available." **= invariant 7. Recommended.** |
"Lazy on read" is not a fourth option: the ANN index needs the vector *present*
to index it, so you cannot defer the vector itself to query time without giving
up the index. The reconciler is "lazy relative to the write, eager relative to
search" — which is precisely invariant 7's partial-coverage licence.
## Design
### Embeddings are derived state, keyed off `@embed`
`@embed("source", model)` declares a derivation: `Vector_col = embed(source_col)`
[D]. That makes the vector column *physical, rebuildable, source-derived* state —
identical in kind to an index. The whole design follows from treating it that way.
### Write path: store source, leave the vector pending (NULL)
`load`/`mutate` are **unchanged in cost**: they write the source text and any
caller-supplied vector. They do **not** call the embedder. A row whose `@embed`
source is present but whose vector is NULL is *pending*. The write path's only
new behavior is **staleness invalidation** (see §"Staleness"): when a write
touches an `@embed` *source* column, it clears (NULLs) the dependent vector so
the reconciler re-embeds. Clearing a column is write-local and cheap (no
network).
This reuses the exact shape already in `table_ops.rs`: an untrainable Vector
column (all-NULL today) is isolated as a `PendingIndex` rather than aborting the
build [S]; here the *rows* are pending, tracked the same way.
### The reconciler: `ensure_embeddings`
A sibling of `ensure_indices`
(`db/omnigraph/table_ops.rs`, materialized in `optimize.rs` [S]):
1. For each `@embed`-annotated `(table, vector_col, source_col, model)`, scan for
**pending rows**: `vector_col IS NULL AND source_col IS NOT NULL`. Cost is
bounded by *pending rows*, not history (invariant 15).
2. Embed the source texts **in batches** via a new
`EmbeddingClient::embed_documents(&[&str]) -> Vec<Vec<f32>>` (see §"Required
new mechanics"), honoring the existing retry/backoff
(`OMNIGRAPH_EMBED_RETRY_*`, `embedding.rs:18` [S]) and the recorded model.
3. Write the vectors back via the normal staged-write + manifest publish path
(an `update` keyed on row id). Then fold the now-trainable vector column into
its index via the existing `ensure_indices` pass — embeddings unblock the
deferred vector index automatically.
4. A model mismatch (recorded model ≠ resolved client model) **refuses loudly**,
reusing the query-path same-space validation that already exists [D].
**Trigger (decision §A):** start **explicit**`ensure_embeddings` runs inside
`optimize` and as a new `omnigraph embed --graph <scope>` mode (the file
pipeline gains a graph target). This matches how indexes already reconcile
(operator/cron-triggered, no background loop). A **background loop** (pgai's
every-5-min worker [U]) is the eventual convergence, specified as a follow-up,
not built first.
### Staleness (decision §C)
A source-text `update` must re-embed. Two options:
- **Recommended — NULL-on-source-change.** When a write touches an `@embed`
source column, clear the dependent vector. Then *one* universal pending
predicate (`vector IS NULL AND source IS NOT NULL`) covers both first-insert
and post-update, with no per-row bookkeeping. Cost: the write path must know
the `@embed` dependency (a catalog lookup it already has) and clear a column.
- *Alternative — embedded-from marker.* Store the source version/hash the vector
was derived from; the reconciler re-embeds rows whose
`_row_last_updated_at_version` (`lance_version_columns` [S]) exceeds it. No
write-path coupling, but adds a per-row marker column.
### Read semantics (decision §B)
A pending row is **present and queryable** by traversal/filter/scalar predicates
immediately; it is simply **not yet a `nearest()` hit.** This is invariant 7
partial coverage, surfaced — not silent:
- Expose a **pending-embedding count** (per table/column) via `status`/`optimize`
output, so operators see the backlog.
- Provide an **explicit synchronous opt-in** for the rare read-after-write-on-
vectors case — e.g. `omnigraph embed --graph --wait` or a `mutate` flag that
runs `ensure_embeddings` for the touched rows before returning (opt-in only;
never the default, never on the hot path).
## Required new mechanics
| Mechanic | Today | Needed |
|---|---|---|
| **Batch embed** | `embed_document_text(&str)` / `embed_query_text(&str)` are one-string-per-call (`embedding.rs:248,252` [S]) | Add `embed_documents(&[&str]) -> Result<Vec<Vec<f32>>>` (provider batch endpoint where available; chunked sequential fallback). pgai treats batch as load-bearing for throughput/rate-limits [U]. |
| **Pending-row scan** | `PendingIndex` tracks declared-but-unbuilt *indexes* (`table_ops.rs` [S]) | A row-level pending scan (`vector IS NULL AND source IS NOT NULL`) per `@embed` column. |
| **`@embed` dependency in the write path** | `@embed` is a catalog annotation consumed at query typecheck/lint [D] | The loader/mutation must read it to NULL-on-source-change (decision §C). |
| **Graph-target embed** | `omnigraph embed` is file→file [D] | `omnigraph embed --graph <scope>` runs the reconciler against a live graph. |
The provider client, model-identity recording, same-space validation, and
retry/backoff already exist (RFC-012) — this is write-path + reconciler
integration, not a new subsystem.
## Invariants & deny-list check
- **7 (indexes are derived state):** this *is* an instance — embeddings converge
from manifest/source state, never extend the critical write path; reads are
correct (just not vector-ranked) under partial coverage.
- **Deny-list "synchronous inline vector/FTS index rebuilds on the write path":**
obeyed by construction — embedding is off the write path.
- **9 (integrity failures are loud):** a model mismatch or an embed failure on
the reconciler is surfaced (pending count, typed error), not a placeholder
vector.
- **13 (operational failures bounded/observable):** the reconciler's embed calls
are batched, retry-bounded, and report a pending backlog; a flaky provider
degrades vector-search freshness, never write availability.
- **2/3 (manifest-atomic, one snapshot):** unchanged — the reconciler's write-back
is an ordinary staged write + manifest publish.
## Cost contract
- **Write path:** unchanged — O(1), no embedder call (the NULL-on-source-change
clear is a local column write). Assert in `write_cost.rs` that a `mutate`
touching an `@embed` source performs **zero** embedding round-trips.
- **Reconciler:** O(pending rows), batched; flat in history depth (it scans by
the pending predicate, not the version chain).
## Testing
- **Write-path-clean:** a `mutate`/`load` into an `@embed` table issues no
embedder call and leaves the vector NULL (mock client asserts zero invocations).
- **Reconcile fills:** `ensure_embeddings` embeds the pending rows, the vector
column becomes non-NULL, and the deferred vector index then builds.
- **Staleness:** updating the source NULLs the vector; the next reconcile
re-embeds; updating a *non-source* property does **not**.
- **Read partial coverage:** a pending row is returned by a traversal/filter
query but not by `nearest()`; the pending count is reported.
- **Model mismatch refuses** on the reconciler (reuse the query-path validation).
- **Batch:** `embed_documents` over N texts maps 1:1 to N output vectors in order;
a mid-batch provider error is retry-bounded and reported, not silently dropped.
- Mock provider throughout (no network in CI), mirroring the existing embedding
tests.
## Rollout
1. **PR 1 — `embed_documents` batch method** + reconciler-internal use. Pure
addition to the client.
2. **PR 2 — `ensure_embeddings` reconciler** + `optimize` integration + the
pending-row scan. Read-only effect until something is pending.
3. **PR 3 — `omnigraph embed --graph`** (graph target for the existing CLI) +
the pending-count surface in `status`/`optimize`.
4. **PR 4 — write-path staleness** (NULL-on-source-change) + the
`--wait`/synchronous opt-in. This is the only write-path touch; lands last,
behind the reconciler that consumes its output.
5. *(follow-up)* background reconciler loop (pgai-style), if/when operators want
automatic freshness without a cron.
PRs 13 deliver "add data, then reconcile, then it's searchable" with **no
write-path change at all**; PR 4 adds automatic staleness + the opt-in.
## Drawbacks & alternatives
- **Read-after-write delay on vectors.** The cost of keeping writes fast: a
freshly-mutated node is not a `nearest()` hit until reconciled. Mitigated by
the pending-count surface and the synchronous opt-in. This is the same
trade-off pgai ships and documents [U].
- **Explicit-trigger first** means freshness depends on `optimize`/cron cadence,
not seconds. Accepted as the lower-liability start (matches index reconcile);
the background loop is the convergence.
- **Rejected — synchronous inline (HelixDB regime).** The latency and
failure-coupling this RFC exists to avoid; on the deny-list.
- **Rejected — embed-on-read (lazy).** Doesn't fit a vector column the ANN index
must contain.
## Reversibility
High. The vector column, `@embed` annotation, and on-disk format are unchanged;
the reconciler is additive. The one observable contract change (a pending window
before vectors are searchable) is the documented partial-coverage semantics, and
the synchronous opt-in covers callers that cannot tolerate it.
## Open questions / decisions to ratify
- **§A Trigger model:** explicit (`optimize` + `omnigraph embed --graph`) first,
background loop later — confirm the staged approach.
- **§B Read-after-write:** ratify the "present but not vector-searchable until
reconciled" contract + the synchronous opt-in shape.
- **§C Staleness signal:** NULL-on-source-change (recommended) vs embedded-from
marker.
- **Batching granularity / provider limits:** max batch size + handling a
provider's per-request token cap (chunk within `embed_documents`).
- **Chunking:** pgai also *chunks* long source text before embedding [U]. Out of
scope here (one source property → one vector), but note it as a future
extension if long-text columns appear.
## Relationship to prior work
- **RFC-012 (embedding provider config):** provides the client, model identity,
same-space validation, retry/backoff this RFC consumes. RFC-015 is the
write-path/reconciler integration RFC-012 deferred.
- **Invariant 7 / the deferred-vector-index path** (`table_ops.rs`
`PendingIndex`, `optimize.rs` [S]): the mechanism this extends from
index-coverage to row-level embedding coverage.
- **RFC-013 (write-path latency) / step 3b:** the cost-and-failure-bounding
contract that makes synchronous inline embedding unacceptable and the
reconciler necessary.