Ctrl K
ComunidadGuías…

docs/operations/intel-history-embedding-migration-runbook.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
---
title: "Intel-history embedding migration runbook"
description: "Operator procedure for changing the embedding model or dimension behind the intelHistory vector index without mixing vector spaces. Public Mintlify docs never publish this procedure."
---

# Intel-history embedding migration runbook

Use this when the embedding model or its dimension behind `intelHistory`
has to change: a provider retires `text-embedding-3-small`, a better model is
adopted, or the dimension is tuned. **This is a table migration, not an edit
of a number.** Follow-up to #5694 / #5737; written for #5742.

Related: [Account deletion runbook](./account-deletion-runbook.md) for the
trusted-checkout conventions used below.

## The hazard, stated plainly

Every stored vector was produced by one model at one dimension. A vector
search ranks the query vector against stored vectors by cosine similarity,
and that number only means something when both come from the same space.
Mixing vectors from two models in one index is worse than a hard failure:
`search` keeps returning results, ranked against a scale that no longer
applies, and nothing errors.

A dimension change cannot even reach that state: Convex rejects a vector whose
length differs from the index's `dimensions`, so every insert fails after
deploy. A same-dimension model swap is the dangerous case, because every
insert succeeds.

## What pins the current space

Six places must agree. The parity suite `tests/intel-history-endpoints.test.mts`
pins the first four together. `CACHE_VERSION` requires a manual bump.
Only `EMBED_CACHE_PREFIX` changes automatically with the model and dimension.

| Literal | Where | Value today |
|---|---|---|
| `EMBED_MODEL`, `EMBED_DIMS` | `scripts/lib/brief-dedup-consts.mjs` | `openai/text-embedding-3-small`, `512` |
| `INTEL_HISTORY_EMBED_DIMS` | `convex/intelHistory.ts` | `512` |
| `INTEL_HISTORY_EMBED_MODEL` | `convex/intelHistory.ts` (#9885) | `openai/text-embedding-3-small@512`, stamped on every row |
| `dimensions` of `by_embedding` | `convex/schema.ts`, table `intelHistory` | `512` |
| `CACHE_VERSION` | `scripts/lib/brief-dedup-consts.mjs` | seeder-side embedding cache namespace; bump on any model or dimension change |
| `EMBED_CACHE_PREFIX` | `server/_shared/intel-history-embed.ts` | query-vector cache; derived from `EMBED_MODEL` and `EMBED_DIMS`, so it moves by itself |

Producers: `scripts/_seed-history.mjs` (`appendSeedHistory`, at most 150 rows
per run in chunks of 50) from `seed-conflict-intel`, `seed-energy-intelligence`
and `seed-cross-strait-activity`, through the secret-guarded relay route
`/relay/intel-history` in `convex/http.ts` into `internal.intelHistory.append`.
Readers: `search` (vector) and `timeline` (chronological), consumed by the
`get-similar-events`, `search-intel-history` and `get-intel-timeline` edge
handlers and the MCP tools that wrap them. Retention: `prune` ages rows out
180 days after `ingestedAt`, daily at 04:30 UTC.

## Read the inventory first

Use the paginated inventory from #9885 after that change is available in the
target deployment. From a trusted checkout with the production deploy key:

```sh
npx convex run intelHistory:embeddingModelInventory '{"limit": 200}'
```

Add each page's `models` counts to the census. If `partial` is true, pass its
`nextCursor` as `cursor` in the next call. Continue until `partial` is false.
The byte limit can end a page before `limit` rows. Do not raise the row limit
to bypass pagination or treat one page as the full table.

Expect the current model stamp plus `unstamped` for legacy rows. Unstamped
rows always belong to `openai/text-embedding-3-small@512`, even after the
current model changes. Any other stamp needs investigation before migration.
Use `scripts/measure-intel-history.mjs` to measure retained rows and bytes for
the cost estimate. Accept the totals only when the output reports
`complete: true`. The default `--max-rows 100000` can stop early with exit code
2. If incomplete, rerun with a larger `--max-rows` until the scan completes.
It does not measure which deployments read the table.

## Cutover policy

Use a versioned table for both same-dimension model changes and dimension
changes. Keep the old vectors intact. Search serves the complete old space
until the new one passes validation, then all readers switch in one deploy.
Never put vectors from different models into one ranked result list.

The dual-write path, table-specific queries, retraction changes and backfill
helper below do not exist yet. Implement and test them in the migration PR
before starting this procedure. Changing only the shared constants is unsafe.

The migration must add `intelHistoryV2:embeddingModelInventory` with the same
`limit` and `cursor` inputs and `current`, `scanned`, `models`, `partial`, and
`nextCursor` outputs as the old-table query. Before backfill validation, deploy and test both queries. Then inventory
each table separately, paging each cursor to completion:

```sh
npx convex run intelHistory:embeddingModelInventory '{"limit": 200}'
npx convex run intelHistoryV2:embeddingModelInventory '{"limit": 200}'
```

The V2 command is a required migration addition, not an existing endpoint.

## Prepare two independent vector spaces

1. Record the old and new model IDs and dimensions in the migration PR.
   Estimate the embedding cost from the retained row count, plus new events
   during the backfill. Budget calls for both models during dual-write.
2. Add `intelHistoryV2` with the same event fields and secondary indexes as
   `intelHistory`. Give its `by_embedding` index the new dimension. Keep the
   old table, index, model constants and query cache namespace unchanged.
3. Give each space its own model, dimension, row stamp and embedding-cache
   namespace. Keep the existing `EMBED_MODEL`, `EMBED_DIMS`,
   `INTEL_HISTORY_EMBED_MODEL` and query-vector cache on the old space until
   the read flip. Add separate new-space configuration for the migration.
   Even two models with 512 dimensions need separate vectors and caches.
4. Generate two embeddings for each new event from the same input text.
   Send both vectors with explicit model identities through a versioned,
   secret-guarded relay contract. Validate each vector against its own model
   and dimension. Write the old vector only to `intelHistory` and the new
   vector only to `intelHistoryV2`. Do not copy one vector into both tables.
   Reject an incomplete pair so a retry can complete the event atomically.
5. Serialize dual append, backfill and retraction with the same append-lock
   document. Keep the existing append lock and dedupe checks in the write
   transaction. Check `intelHistoryRetractions` before every target insert.
   A tombstone must prevent both a live append and a delayed backfill from
   recreating a retracted event.
6. Update `retract` to delete matching `dedupeKey` rows from both tables in
   the same transaction that writes the tombstone. Use `dedupeKeys` for
   records returned from V2. If `ids` remain supported, update the resolver
   to resolve IDs from both tables before cutover. A tombstone alone does
   not hide a row already copied to V2. Keep `restore` explicit. It removes
   the tombstone but does not restore previously deleted rows by itself.
7. Apply the 180-day retention policy to both tables. Preserve each event's
   original `ingestedAt` and `occurredAt` during backfill. Copying a row must
   not restart its retention window.
8. Deploy dual-write with all reads still on the old table. Verify a new
   event appears with different, correctly stamped vectors in both tables.
   Verify retraction deletes it from both tables and blocks reinsertion.

## Backfill and validate

The helper must live in `scripts/`, load credentials with `loadEnvFile`, and
use only packages allowed by the Railway boundary. It must page source rows
in bounded batches and save a resumable cursor. Follow the pagination pattern
in `scripts/measure-intel-history.mjs`; do not load the table into one query.

For each source row, generate the new-space vector from the same text used
by the producer. Submit the full event record and new vector to a dedicated
secret-guarded migration route. Its mutation must insert a complete,
validated V2 row, keyed by `dedupeKey`. Patching only `embedding` and
`embeddingModel` in the source row is not this migration.

The target mutation must check retention eligibility and the retraction
tombstone under the append lock before insertion. Skip a target row already
present with the expected model and dimension. Stop on a conflicting row.
Advance the saved cursor only after the chunk commits. A retry must not
rewrite vectors already accepted in V2 or recreate deleted source rows.

Before the read flip:

- Complete the paginated inventory of each table. Parity checks must assert
  all output fields, including `current` (the old or new model stamp for that
  table) and `scanned` (the page row count, equal to the sum of its model
  tallies). Sum `scanned` across pages and reconcile the eligible source-row
  total with V2 after accounting for concurrent writes, retractions and expiry.
  Every target row must have the new stamp and dimension. No unstamped row
  belongs in V2.
- Compare eligible `dedupeKey` sets and event fields in bounded pages.
  Matching totals alone do not prove completeness. Account for concurrent
  appends, retractions and retention expiry before accepting the comparison.
- Test `search`, `timeline` and hydration against V2 through a separate
  validation path. Generate validation queries with the new model. Check
  known historical answers and foreign-model rejection.
- Test a retraction during backfill, a retry after a committed chunk, and
  pruning. Confirm no deleted or expired event becomes visible in V2.

## Flip readers and retain rollback

1. Switch `search`, `timeline` and `getByIds` to V2 in one deploy. Switch
   query embedding configuration and its cache namespace to the new space
   in that same deploy. Version or purge the successful-read cache namespace
   `intel-history:read:v1` too. Its 30-minute cached responses do not include
   the table or embedding space in their keys. Update the parity tests to pin each table's own
   producer, validator, query model and index dimensions.
2. Keep dual-write and pruning active for both tables during the rollback
   window. Keep retraction deletion active for both tables too. Stopping old
   writes at the flip would make a later rollback omit new events.
3. Verify the edge handlers and MCP consumers read V2 and produce expected
   historical results. Record the deployed revision and observed checks.
4. If validation fails, switch all readers and query configuration back to
   the old space. Version or purge the successful-read cache again so V2
   responses cannot survive rollback. The old table still contains its original vectors and
   events written during the rollback window. No reverse re-embedding is
   needed because this procedure never overwrites the old space.
5. Close rollback only after explicit operator acceptance. Record that
   choice before stopping old-space embedding and writes. After old writes
   stop, a later rollback requires reconciliation of missing events and
   retractions; reverting constants alone is no longer sufficient.
6. Before retiring the old table, take and verify a recoverable backup.
   Audit deployed readers, workers, relay routes, scheduled functions and
   clients for old-table references. Observe access telemetry over the
   agreed drain window and remove old read paths. The storage measure
   script cannot prove that no reader remains. Retain the table for the
   planned 180-day window unless an operator separately approves earlier
   retirement with this evidence.
7. After retirement, keep `retract`, `restore`, append and prune on V2 and
   keep the shared tombstones. Remove obsolete dual-write code and old
   configuration in the retirement change.

## Drill

This procedure has not been exercised against a live deployment. Do not
start a production model swap until the migration code and helper exist.
Run a dev-deployment drill for both a same-dimension model change and a
dimension change. Record the revisions, row reconciliation, retraction race,
read-flip results, rollback results, elapsed time and embedding spend here.
Unit tests of model stamps and inventory pagination do not prove this drill.