OwnSona stores every memory's text alongside a vector embedding of that text. Recall, dedup-on-write, and conflict surfacing all rely on those vectors. Sooner or later you will want to switch to a different embedding model or a different embedding provider, and the existing vectors will need to be replaced — vectors from different models live in different spaces, and cosine distance between them is noise.
This document explains when and why you would do that, and the exact mechanics of doing it cleanly.
- When and why you might switch
- What is and isn't affected
- How re-embedding works inside the server
- Procedure A: same-dimension switch
- Procedure B: different-dimension switch
- Monitoring and recovery
- Cost
- Gotchas
A few realistic triggers:
- Cost. A new model from the same provider, or a different provider entirely, comes in at half the per-token price. At thousands of memories this matters less than at millions, but it matters.
- Quality. A newer / larger model produces measurably better recall on your kind of facts. Embedding-model leaderboards (e.g. MTEB) move every few months.
- Provider deprecation. Your current provider end-of-lifes the model you embedded under. Migrating before they yank the API is cheaper than scrambling after.
- Vendor diversity / data sovereignty. You decide you no longer want your memory texts traveling to a third party for embedding. You move to a self-hosted model (Ollama, sentence-transformers, BGE, etc.).
- Local privacy / air-gap. Same as above: the entire pipeline becomes on-host.
- You changed your mind about the dimension. You started with a 1536-dim model because that was the default and now want to drop to 768 to save disk / index memory.
You are not changing the LLMs that use the memory store — Claude /
ChatGPT / Gemini connect over MCP and don't care which embedding model
produced the vectors they're searching. The CLI's teach subcommand
uses an independent LLM for fact extraction (separate config, separate
endpoint, separate API key) and has no data-shape effect on the server.
Switching the server's embedding provider has no effect on the CLI's
teach LLM, and vice versa.
Every memory row carries embedding, embedding_provider,
embedding_model, and a stored text. A re-embed pass:
- Replaces every row's
embeddingwith a vector from the new provider/model. - Replaces every row's
embedding_providerandembedding_modelto match the active config. - Leaves untouched:
text,normalized_text,tags,importance,metadata,created_at,updated_at,deleted_at,expires_at,last_confirmed_at,forget_reason,replaced_by_id,record_version,user_id.
Soft-deleted rows (tombstones) are re-embedded. Tombstones
participate in dedup-on-write via findSimilarTombstones, so their
vectors have to move into the new model's space along with everything
else. If the walker skipped them, the dedup-on-write check would
start returning gibberish for previously-corrected facts.
The walker is ReembedJob (src/main/precompiled/ai/ownsona/embeddings/ReembedJob.java).
It runs from MCPServer.<clinit> after DbMigrator and
RecordMigrator, and only when REEMBED_ON_STARTUP=true in
application.ini. On clean completion it flips that flag back to
false so a routine restart doesn't accidentally re-trigger it.
Order at startup:
1. DbMigrator — applies any new schema migrations
2. RecordMigrator — runs per-row upgraders
3. ReembedJob — re-embeds stale rows (only if REEMBED_ON_STARTUP=true)
That ordering matters because a different-dimension switch ships a
migration that resizes the embedding column. The migration
runs first, then the walker fills the now-resized column with new
vectors.
The walker:
- Selects rows where
embedding IS NULLorembedding_provider IS DISTINCT FROM <active>orembedding_model IS DISTINCT FROM <active>, paginating by id. - Reads each batch's
textin a single query. - Calls
EmbeddingProvider.embedBatch()once per batch (default batch size 50). - Writes new vector + new provider + new model back, one row at a time, in a single transaction per batch.
A crash mid-walk loses at most one batch. The next restart re-runs from exactly the rows still showing the old provider/model.
Use when: the new model produces vectors of the same dimension as the
old one (e.g. you stay at 1536 and switch from
text-embedding-3-small to text-embedding-3-large@1536 — OpenAI
supports a dimensions parameter that truncates output of the larger
model — or you swap to a self-hosted 1536-dim model).
Code/config change: none beyond editing application.ini on the
deployed server.
-
Back up the database.
sudo systemctl stop ownsona.service pg_dump -h localhost -U ownsona ownsona > /var/backups/ownsona-pre-reembed-$(date +%F).sql sudo systemctl start ownsona.service(Or rely on your nightly backup — but a fresh dump immediately before re-embed is safer.)
-
Edit the deployed
application.iniat<tomcat>/webapps/ROOT/WEB-INF/backend/application.ini:EMBEDDING_PROVIDER = openai EMBEDDING_MODEL = text-embedding-3-large EMBEDDING_DIMENSIONS = 1536 EMBEDDING_API_KEY = sk-... # if it changed EMBEDDING_ENDPOINT = https://api.openai.com/v1/embeddings REEMBED_ON_STARTUP = true
-
Restart the service.
sudo systemctl restart ownsona.service -
Watch the log.
journalctl -u ownsona.service -fYou'll see, in order:
migrator: db_version at N, target N, nothing to applyrecord_migrator: done upgraded=0 failed=0(or whatever)reembed: starting active_provider=... active_model=... dims=...reembed: progress count=50,count=100, … (one line per batch)reembed: done count=NApplicationIniWriter: set REEMBED_ON_STARTUP = false in <path>
The server is serving requests during the walker's run. Recall on rows already updated uses new-model vectors; recall on rows not yet updated uses old-model vectors. Within either cohort, results are correct; across cohorts, scores are not meaningfully comparable. For Blake's single-user store this window is minutes, not hours.
-
Verify and propagate.
SELECT embedding_provider, embedding_model, count(*) FROM memories GROUP BY 1, 2;
Should show one row, with the new provider/model and a count matching
count(*)overall.The walker auto-flips
REEMBED_ON_STARTUP=falsein the deployedapplication.ini, but the source tree still has it attrue. Before your next WAR build, updatesrc/main/backend/application.iniandsrc/main/backend/application.ini.exampleto match, so the next deploy doesn't accidentally re-trigger the walker.
Use when: the new model's output dimension differs from the existing
column (e.g. moving from 1536 to 3072, or 1536 to 768). The
vector(N) column type must be resized first — pgvector cannot store
a 3072-element vector in a vector(1536) column.
The schema change goes in an additive migration that runs before the walker. The walker then fills the now-resized column.
-
Write a new migration class at
src/main/precompiled/ai/ownsona/migrations/MigrationNNN_resize_embedding_to_N.java:package ai.ownsona.migrations; import org.kissweb.database.Connection; // The version numbers in this example assume the next free version // is 6 (v5 is taken by Migration005AddKeep). Use whatever // CURRENT_DB_VERSION + 1 is when you actually write this. public final class Migration006ResizeEmbeddingTo3072 implements Migration { @Override public int version() { return 6; } @Override public String name() { return "resize embedding to vector(3072)"; } @Override public void apply(Connection db) throws Exception { // Relax NOT NULL so we can null out via the type change. db.execute("ALTER TABLE memories ALTER COLUMN embedding DROP NOT NULL"); // Resize. USING NULL = discard old vectors (the walker // rebuilds them from each row's text in the immediately // following ReembedJob pass). db.execute("ALTER TABLE memories ALTER COLUMN embedding TYPE vector(3072) USING NULL"); } }
Why this is OK to ship as a migration despite the "additive-only" rule: the embedding column is derived data. The texts that produced the old vectors are still in the
textcolumn. Nulling out the embedding column and then re-filling it from the texts in the same startup sequence loses nothing the system can't rebuild. This is a narrow, deliberate exception to guardrail #2 — dim-change migrations may clear the embedding column iff the same commit setsREEMBED_ON_STARTUP=true. -
Optional: drop the HNSW index first. If you have thousands of rows, dropping the index before the walker and recreating it after avoids paying incremental HNSW insertion cost on every batch. Add to the migration:
db.execute("DROP INDEX IF EXISTS memories_embedding_idx");
The matching
CREATE INDEXstep at the end is one-time operator work (a follow-uppsqlcommand after the walker finishes); we don't bake it into the migration because it must run after the walker, not before it. For small stores (a few hundred rows), you can skip this entirely. -
Register the migration and bump the version. In
src/main/precompiled/ai/ownsona/migrations/MigrationRegistry.java:public static final int CURRENT_DB_VERSION = 6; ... m.add(new Migration006ResizeEmbeddingTo3072());
-
Update
application.ini(source tree, and on the server) with the newEMBEDDING_MODEL,EMBEDDING_DIMENSIONS = 3072,EMBEDDING_API_KEY(if it changed), andREEMBED_ON_STARTUP = true. -
Back up the database.
pg_dump -h localhost -U ownsona ownsona > /var/backups/ownsona-pre-reembed-$(date +%F).sql -
Build, deploy, restart. Normal WAR-swap deploy.
-
Watch the log. In order:
migrator: applied v=5 name="resize embedding to vector(3072)" ms=…record_migrator: done …reembed: starting active_provider=... active_model=... dims=3072reembed: progress count=…(repeats)reembed: done count=…ApplicationIniWriter: set REEMBED_ON_STARTUP = false ...
-
If you dropped the HNSW index in step 2, recreate it now that the walker has filled every row:
sudo -u ownsona psql -d ownsona -c \ "CREATE INDEX memories_embedding_idx ON memories USING hnsw (embedding vector_cosine_ops);" -
Optional cleanup: restore
NOT NULLon the embedding column in a follow-up migration (Migration007). Strictly optional — the server always setsembeddingon insert / update. If you do, ship it in a later commit, after you've confirmed every row has a non-NULL embedding. -
Propagate the source-tree
application.iniflip (see step 5 of Procedure A).
To see what state the store is currently in:
SELECT embedding_provider, embedding_model, count(*)
FROM memories
GROUP BY 1, 2
ORDER BY count(*) DESC;Mid-walk this returns two rows (old and new), shifting as the walker progresses. After completion it returns one row.
To see how many rows still need re-embedding:
SELECT count(*) FROM memories
WHERE embedding IS NULL
OR embedding_provider IS DISTINCT FROM 'openai'
OR embedding_model IS DISTINCT FROM 'text-embedding-3-large';The walker is resumable by design. Kill the JVM mid-run, then
systemctl restart ownsona.service, and it picks up exactly the rows
still showing the old provider/model. No state to clean up.
Look at the exception in journalctl -u ownsona.service. Common
causes:
- Embedding endpoint unreachable / API key wrong. Fix
application.iniand restart. - Rate-limit / 429. The walker treats this as a fatal error and
aborts the run; the next restart resumes. For a one-off run on a
small store you'll usually outrun the rate limit; for a large store
you may want to lower
BATCH_SIZEinReembedJob.java. - Different-dimension migration ran but
EMBEDDING_DIMENSIONSin config doesn't match the column type. The walker will try to write wrong-size vectors and fail at the database with a vector dimension mismatch error. Fix the config and restart.
If the new model is producing terrible recall and you want to revert:
- Stop the service.
- Restore from the pre-deploy backup:
sudo -u postgres psql ownsona < /var/backups/ownsona-pre-reembed-<date>.sql - Revert
application.inito the oldEMBEDDING_MODEL/EMBEDDING_DIMENSIONS/EMBEDDING_API_KEY. - If you also ran a dim-change migration, redeploy a WAR built from
the pre-Phase-6-dim-change commit (i.e. one whose
CURRENT_DB_VERSIONis lower).DbMigratorwill refuse to start against a higher-version DB, so you must either restore the DB first or ship a code build matching the DB version. - Start the service.
This is the "data is not cheap" branch — the backup is the only thing that gets you back to the pre-switch state cleanly. Take backups before the deploy, not after.
Embedding APIs are priced per token. For a single-user store of a few thousand short memories, a full re-embed costs cents to single-digit dollars. Rough estimates (OpenAI list prices, may have changed):
| Memories × avg tokens | text-embedding-3-small |
text-embedding-3-large |
|---|---|---|
| 1,000 × 40 tokens | ~$0.001 | ~$0.005 |
| 10,000 × 40 tokens | ~$0.01 | ~$0.05 |
| 100,000 × 40 tokens | ~$0.10 | ~$0.50 |
Self-hosted models (Ollama, sentence-transformers) cost zero per token but you pay in compute time on the host.
- The auto-flip writes to the deployed copy of
application.ini, not the source tree. After a successful run, the file at<tomcat>/webapps/ROOT/WEB-INF/backend/application.inihasREEMBED_ON_STARTUP = false, butsrc/main/backend/application.ini(the source you build from) still has it attrue. Update the source-tree copy before your next WAR build, or the next deploy will silently kick off a no-op walker on startup (which is harmless but wastes a bit of time and log noise). application.iniis gitignored, so you won't catch a stale source copy via PR review. The example file (application.ini.example) shipsREEMBED_ON_STARTUP = falseso fresh installs start in the right state.EMBEDDING_DIMENSIONSmust match thevector(N)column type. If they disagree, every write fails at the database with a vector dim mismatch. This is enforced by Postgres, not by Ownsona — the error surfaces at insert/update time, not at startup.- Vendor neutrality is preserved. The walker doesn't care
whether the provider is OpenAI, Cohere, Voyage, BGE, Ollama, or
anything else. It only knows about the
EmbeddingProviderinterface. To add a new provider, write a newEmbeddingProviderimplementation and update theOpenAIEmbeddingProviderinstantiation inMCPServer.<clinit>— the rest of the system (migrations, walker, repository, tools) is provider-agnostic. - Hard-deleted rows mid-walk don't break the walker. If a row is hard-deleted between the id scan and the per-batch text fetch, it silently drops out of the batch. No re-embed, no error.
- There's no MCP tool to trigger a re-embed. Deliberate: admin
operations stay outside the MCP surface so any LLM holding the
bearer can't initiate them. The trigger is editing
application.iniand restarting, which is an operator action.