An embedding is a cached derivation of a row's text — and OLTP rows change. Without refresh machinery, vectors drift stale and search quietly degrades. This playbook builds the lifecycle: trigger-driven re-embed queues, drift detection that proves nothing was missed, and the model-upgrade runbook with a cost budget attached.
Tier: Advanced · Workflow area: Vector & AI Features · ~45 minutes
Semantic search that was accurate at launch decays as products get renamed, tickets get resolved and descriptions get rewritten — because the vector still encodes last quarter's text. Lifecycle management is the difference between operating vector search and having once installed it. The pattern is pure DBA craft: change capture, an outbox queue, a reconciliation check and a rehearsed migration plan.
Make staleness detectable: store a hash of what was embedded. The single highest-leverage design move — alongside every vector, record a fingerprint of the exact text it encodes:
ALTER TABLE products
ADD COLUMN embedded_text_hash text,
ADD COLUMN embedded_at timestamptz,
ADD COLUMN embedding_model text;
Drift is now a comparison, not a mystery: current text hash vs. stored hash.
Capture changes with a trigger writing to a re-embed queue. Never call a model from inside a trigger — enqueue instead, keeping the transaction fast:
CREATE TABLE embed_queue (
id bigint PRIMARY KEY, -- product id; PK dedupes bursts
enqueued_at timestamptz NOT NULL DEFAULT now()
);
CREATE OR REPLACE FUNCTION enqueue_embed() RETURNS trigger AS $$
BEGIN
IF (NEW.name, NEW.category, NEW.description) IS DISTINCT FROM
(OLD.name, OLD.category, OLD.description) THEN
INSERT INTO embed_queue (id) VALUES (NEW.id)
ON CONFLICT (id) DO UPDATE SET enqueued_at = now();
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_products_embed
AFTER UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION enqueue_embed();
Note the guard: only text-relevant column changes enqueue. A price update must not trigger a re-embed.
Drain the queue with a worker that claims rows safely. The worker pattern is FOR UPDATE SKIP LOCKED — ask AI to draft it, then review for the failure cases:
Write a worker loop (Python + psycopg): claim up to 200 ids from
embed_queue with SELECT ... FOR UPDATE SKIP LOCKED; fetch the rows'
embed_text; call Ollama for embeddings; update embedding,
embedded_text_hash (sha256 of embed_text), embedded_at,
embedding_model; delete claimed queue rows; commit. Handle: model
endpoint down (leave queued, back off), row deleted since enqueue
(drop silently), text changed again mid-flight (hash check decides).
The third failure case is the subtle one: if the text changed after the worker read it, the stored hash will not match current text, and step 4 catches it next cycle — self-healing by design.
Run drift reconciliation on a schedule — trust nothing, verify weekly. Triggers can be dropped, workers can die, backfills can race. The reconciliation query is the safety net:
SELECT count(*) AS drifted
FROM products p
WHERE p.embedding IS NOT NULL
AND p.embedded_text_hash IS DISTINCT FROM
encode(sha256(convert_to(
'Name: ' || name || '. Category: ' || category ||
'. Description: ' || coalesce(description, ''), 'UTF8')), 'hex')
AND NOT EXISTS (SELECT 1 FROM embed_queue q WHERE q.id = p.id);
Expected result: drifted = 0. Anything else is an incident with a known remediation: enqueue the drifted ids. Alert on it like replication lag.
Write the model-upgrade runbook before the upgrade exists. A new embedding model invalidates every vector — old and new vectors are not comparable. Have AI draft the runbook, then pressure-test the numbers:
Draft a model-upgrade runbook for 4.2M embedded rows, ~40 rows/sec
embedding throughput on our worker. Cover: total re-embed wall time,
batch plan and off-peak windows, dual-column strategy (embedding_v2
populated in background, index built, one-transaction cutover,
old column dropped later), search behavior mid-migration, rollback
point, and an eval-set gate (pb-29 queries) before cutover.
Check the arithmetic: 4.2M rows ÷ 40/sec ≈ 29 hours of embedding — that number drives the whole plan. The dual-column pattern means search never serves mixed-model results; the cutover is the atomic swap of which column the query reads.
Budget the lifecycle in money and load. Count last month's text-changing updates; multiply by embedding cost (API pricing, or local GPU time) and write the figure down. Re-embedding is a permanent operating cost of the feature — surface it before finance discovers it.
The re-embed worker continuously exports changed text to the embedding endpoint. If that endpoint is a hosted API, every product rename and ticket edit flows out automatically, forever — a standing data transfer that belongs in the processing register and in the vendor's zero-retention terms, reviewed before the trigger goes live.
Vectors are derived data, and derived data drifts unless something owns the derivation. Hash what you embedded, queue what changed, reconcile on a schedule, and rehearse the model upgrade — that is the whole lifecycle, and it is ordinary DBA discipline applied to a new column type.