Skip to content

feat(platform): store knowledge vectors in a table per width - #4445

Merged
larryro merged 3 commits into
mainfrom
feat/knowledge-vector-widths
Oct 8, 2026
Merged

larryro merged 3 commits into
mainfrom
feat/knowledge-vector-widths

Conversation

@larryro

@larryro larryro commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A knowledge database held every vector in one column of one declared width, narrowed by the first embedding model used with it. From then on a model of another width was refused for the whole database. An organization that set Vector width to 1024 under Settings > Data residency had every document fail with "produced 1024-dimensional vectors, but this knowledge database stores 1536-dimensional ones", on a corpus that held no vector at all, and organizations sharing the database had to agree on one width.

Vectors now live beside the chunks, in a table per supported width, created by the knowledge-db migrations and never at runtime. An organization's vectors are written to, and searched in, the table of the width its model states.

Before Now
Where a vector is stored chunks.embedding, one width per database chunk_vectors_<width>, one table per width, in both corpus schemas
Supported widths whatever the first model fixed 256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096
A second organization with another width refused indexed and searched beside the first
Changing an organization's width refused, even on an empty corpus saved; what it had indexed is embedded again
A width with no table n/a refused by the form, by the save and by the indexer, naming the list

What changes

  • Migrations 15 (private_knowledge) and 16 (public_web) create the tables, copy the existing vectors and build the HNSW indexes (none above pgvector's 2000 dimensions, as before). A guard test holds the tables the migrations create equal to KNOWLEDGE_VECTOR_WIDTHS.
  • The per-database pin is gone (pinDimensions). dimensions.ts now only routes a width to its table and keeps the check that a vector is as wide as stated.
  • Documents. A document indexed under another width counts no chunk as stored and is embedded again from its first chunk. An identical document is copied only when it is embedded at this width, vectors included.
  • Websites. A site's chunks are shared by every organization that added it, so each organization gets its own vectors for the same chunks. A scan gives the stored chunks the missing width and leaves the other widths alone; a page whose text changed is chunked again, which removes its vectors of every width. storePageText no longer reports vectorless: the sweep at the end of each link asks for the scanning organization's width, which also covers the pages fix(platform): render a crawled page again only when it changed #4439 no longer stores when they did not change.
  • Saving the embedding settings re-queues the indexed documents and email bodies that have no vector of the saved width, in bounded batches, as it already scans the websites in that state. Without it they would stay findable by keyword only, and nothing in the app re-indexes in bulk. This calls the embedding provider for the whole corpus after a change of width.
  • Settings form. The width is validated against the list, the hint names it, the note that every organization on the shared database shares one width is removed, and switching provider clears the width with the model.

Found on a real database

The first version wrote a chunk and its vector in one statement, with the chunk's INSERT in a CTE. On ParadeDB that fails for a repeated passage (push_insert_state: ExecutorRunEntry should have already been pushed onto the stack): a CTE nothing reads from runs after the main statement, where the BM25 index does not expect an insert. They are two statements in one transaction now. The unit doubles could not have shown it; the integration suite did.

Rollout

  • chunks.embedding, its HNSW index and create_chunks_hnsw_index() stay for one release. The previous image keeps reading and writing them while a deployment rolls; a trigger copies what it writes into the table of that width, and this image writes the column too whenever it is declared at the width being written (legacyColumnWidth, read from the catalog per document slice and per crawl link) — the one width the previous image can read, and the one it refused every other model for. So what this image indexes is found by the previous image during the roll, and after a rollback. The trigger names no column: the previous image pins an undeclared column with ALTER COLUMN … TYPE vector(<width>), which Postgres refuses for a column a trigger definition uses. The debt ledger has the entry for dropping all of it, the compatibility write included.
  • The copy and the index build take time in proportion to the corpus, and a migrated corpus stores the vectors of the column's width twice — and maintains the old HNSW index for them — until the column is dropped. I did not measure this on a large corpus.
  • A deployment whose column is declared at a width outside the list keeps its vectors in the old column and gets a warning from the migration: its organizations have to choose a listed width, and the save then embeds their documents again.
  • A database the previous image never pinned (a deployment that starts on this release) has an undeclared column, which this image does not write. Rolling such a deployment back leaves what it indexed without a vector the previous image can read — the state that image is in on any database it never used.

Decisions a reviewer may want to overrule

  • The re-queue on save is automatic and costs provider calls. The alternative is the state before: a change of width that silently leaves search by meaning empty.
  • 2560 (Qwen3-Embedding-4B) is not in the list. Adding a width is the list and one migration per schema.

Not in this PR

  • A document that is being indexed while the width changes finishes at the old width and is not picked up until it is indexed again or the settings are saved again (spec, Not yet).
  • A change of model that keeps the width is still not noticed.
  • The CLI's own guard on model changes for an organization that holds documents is unchanged.

Checklist

  • bun run check: not green end to end on this machine. What ran on this tree: oxfmt --check (whole repo), conflict markers, lint:links, lint:manual, and turbo run lint typecheck test test:ui, where 45 of 49 tasks pass, including platform lint, typecheck and its 82,594 unit tests. @tale/platform#test:ui failed with 5 s timeouts in several suites while 49 tasks ran at once, and passes alone (630 files, 6,160 tests). Three tasks fail in workspaces this diff does not touch: @tale/cli#test (jq is not installed here), @tale/sandbox#test and @tale/sandbox-runtime-daemon#test. ruff format --check did not run (uvx is not installed); the diff has no Python file.
  • bun run lint:sast (Opengrep): 0 findings on the files this PR changes, run through tools/opengrep/run.sh and again by the pre-commit hook. I did not run the whole-repository scan.
  • Translations updated in services/platform/messages/{en,de,fr}/settings.yml: dimensionsHint and errors.dimensionsInvalid reworded, sharedDbNote removed. de-CH overrides none of them. The German and French are mine and have had no native review.
  • Docs updated in docs/{en,de,fr}/self-hosted/configuration/data-residency.md.
  • READMEs: N/A, no setup step, env var or command changed.

Test plan

  • bunx vitest --run --project server backend/core/knowledge backend/domains/knowledge backend/domains/websites tests/guards in services/platform: the routing of a width to its table and both refusals, the dense leg of both corpora reading one width, the indexer's stored state, copy and write per width, the crawler's sweep and its per-width page embed, the re-queue on save, the write door's refusal, and the migration guard ([KNOW-R11], [KNOW-R17]).
  • bunx vitest --run --config vitest.ui.config.ts app/features/settings/data-residency: the form refuses a width outside the list and clears the width on a provider switch.
  • bun run backend:integration against a throwaway tale-db container with this branch's services/db/migrations mounted, so dbmate itself applied migration 13 and the two of this PR (numbered 14 and 15 then; 15 and 16 since the rebase onto fix(platform): prevent unsafe crawl fast-path skips #4451, which took 14): 1,615 of 1,615 checks across 234 lanes, without ITEST_REQUIRE_ALL_LANES. That run was on the tree rebased onto fix(platform): render a crawled page again only when it changed #4439; feat(platform): polish task agent UI and retry flows #4438 landed afterwards and touches none of these files. The fixtures now state a supported width (256 in place of 8). The new checkVectorWidths lane proves, on the migrated tables:
    • two organizations with models 256 and 384 wide index in one database, each search finds its own document and nothing of the other's;
    • one of them moves to the other's width and is embedded again at it, and a second pass finds nothing left to do;
    • the save's follow-up re-queues an indexed file that has no vector of the stated width, once, and none when the width is the one its vectors have;
    • the previous image, played on the column (pinned at 256 as that image did on its first index, read with that image's dense statement, written as it writes): it finds the document indexed at 256, the organization at 384 holds nothing in the column, its own write into the column is mirrored into the 256 table, and the column is cleared with the chunks when the organization moves to 384. A later full run on a fresh database with the compatibility write: 1,671 of 1,671 checks across 235 of 235 lanes.
  • Migrations on a scratch database seeded in the old shape (column declared vector(1536), with and without vectors): the copy is exact, a write by the previous image is mirrored into the table of its width, deleting a chunk deletes its vectors, a vector of another width is refused by the table, and a second application changes nothing.
  • Query plans on 67,000 vectors across three organizations: a scope of 2,000 chunks takes the exact path (7.7 ms), a scope of 50,000 takes the HNSW index on the width's table, and for an organization holding 22 % of the index the iterative scan keeps reading through the join until the limit is met (145 index rows for 30 hits).
  • Not verified: the settings page in a browser (the component tests cover the form), and the manual box DATA-F13 added for the live flow.

@yannickmonney

Copy link
Copy Markdown
Contributor

REQUEST CHANGES — exact head d519ccc

[blocker][DATA] Rolling rollback loses vectors. In services/platform/backend/core/knowledge/indexing.ts:940-977 and services/platform/backend/core/knowledge/crawl_action.ts:2263-2305, new writers insert chunks without chunks.embedding and write only chunk_vectors_<width>. Migrations 14/15 install only a legacy-to-new trigger. Thus a previous-image replica (or a rollback) reads NULL legacy embeddings for documents/pages indexed by this image, silently losing semantic retrieval; changed pages also delete the old vector. The PR acknowledges this rollback state, but the migration contract requires the old image to remain functional during a rolling deploy. Add a compatibility write path, or provide and enforce an operational sequencing contract that makes rollback impossible, with integration coverage for old readers after new writes and recovery after a failed/rolled-back deployment.

The per-width joins and schema-qualified tables otherwise preserve corpus/tenant scoping, and the migration copy/FK cascades are mostly idempotent for supported widths. HNSW is created only through 1536; wider tables scan, while the synchronous copy/index build doubles storage and can hold locks for corpus-scale time.

CI evidence at this SHA: Backend integration passed. The three failures are Knip (isProgramLimitExceeded unused export), Browser (dashboard shell expected 3 strips but got 2), and CI ready (Checks) aggregating those failures; these appear unrelated to this change but keep the PR red.

@larryro

larryro commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

@yannickmonney Addressed in 235162d: the new writers now also write chunks.embedding whenever the column is declared at the width being written — the one width the previous image can read — so what this image indexes is found by the previous image during the roll and after a rollback; the column is NULL for any other width, as it was for those organizations before.

While playing the previous image in the checkVectorWidths lane I found that the mirror trigger's UPDATE OF embedding / WHEN clause blocked the ALTER COLUMN … TYPE vector(N) the previous image uses to pin an undeclared column, which would have failed that image mid-roll on a fresh database; the trigger names no column now. The lane pins the column as that image did, reads with its dense statement, writes as it writes, and holds the column cleared after a move to another width; the full suite passed 1,671/1,671 on a fresh database.

7fea8f5 drops the export Knip flagged, which this PR had orphaned. The Browser failure was the dashboard shell test fixed on main in #4446; the branch is rebased onto it.

@yannickmonney

Copy link
Copy Markdown
Contributor

ACCEPT at head 7fea8f5.\n\nDATA blocker CLOSED. The migration creates the per-width tables before copying legacy vectors and installs the legacy mirror trigger (private migration lines 51-66, 68-105); the new writer also mirrors the legacy width (indexing.ts lines 603-608). The regression lane covers the required sequence: old image pins chunks.embedding at 256, new image indexes Acme at 256 and Globex at 384, the old image reads Acme during the roll/back after rollback, an old-image write is mirrored into chunk_vectors_256, then Acme moves to 384 and clears the legacy column (vector-width.integration.ts lines 262-321). The old code would fail the two-width same-database assertion and the mirrored-write/previous-reader assertions. Forward-only down sections preserve vectors.\n\nDATA/SECURITY delta review:\n- Forward-only and existing-row safe: both migrations use CREATE TABLE IF NOT EXISTS, copy existing rows with conflict protection, and retain chunks.embedding for the roll. The down migration is deliberately empty.\n- Mixed-version readers/writers: covered by the old-column pin/read/write and trigger assertions above; unsupported legacy widths remain in the old column with an explicit warning and require re-indexing.\n- Tenant isolation: private queries bind org_slug and the chunk/document relationship; public-web queries join website_org_memberships. The integration creates two organizations on one database and asserts each sees only its own width/document. Per-width tables have chunk foreign keys with cascade deletion.\n\nCICD CI ready aggregators at this head: Build — success; CLI — success; E2E — success; Checks — success; SAST — success; Commitlint — success; Security — success.\n\nReal PostgreSQL execution is NOT_RUN in this coordination-only review. It must run the vector-width.integration.ts scenario against PostgreSQL with migrations 15/16 applied: two organizations sharing one database at 256/384, mixed old/new reader-writer roll and rollback with trigger mirroring, move one organization to the other width, verify no cross-tenant hits, and verify migration interruption/re-run leaves existing vectors intact.

@larryro
larryro force-pushed the feat/knowledge-vector-widths branch from 7fea8f5 to bd645cf Compare October 8, 2026 08:47
larryro added a commit that referenced this pull request Oct 8, 2026
The reaper finds a process that left an exec's group by the exec id in its
environment. A process in the middle of an execve has no environment to
read: from the moment the new image's memory replaces the old until its
arguments and environment are laid out, the kernel answers an empty one
(a probe reading /proc/<pid>/environ of a process that execs in a loop got
an empty answer in 3,341 of 20,000 reads). A leftover is often right there
when a round comes: `cmd &` and the command's exit are an instant apart,
and `setsid cmd` is two execs. The SIGTERM round counted such a process as
untagged and skipped it; only the SIGKILL round, SIGKILL_GRACE_MS later,
ended it.

That is how exec-completion.test.ts failed on CI (#4445, Checks run
37752204357): its final barrier gave the reaper five seconds to end the
`setsid sleep 60` a finished exec left, and the SIGKILL round came a few
milliseconds after that. The test now waits for the reaper's own bound (the
SIGKILL grace plus the scan deadline), and the scan reads an empty
environment again, four times ten milliseconds apart, before the process
counts as untagged; a process whose environment really is empty costs a
scan that long.

No user-visible change: no messages, docs under docs/ or READMEs.
@larryro
larryro force-pushed the feat/knowledge-vector-widths branch from bd645cf to 7fa9fcc Compare October 8, 2026 13:08
larryro added a commit that referenced this pull request Oct 8, 2026
The reaper finds a process that left an exec's group by the exec id in its
environment. A process in the middle of an execve has no environment to
read: from the moment the new image's memory replaces the old until its
arguments and environment are laid out, the kernel answers an empty one
(a probe reading /proc/<pid>/environ of a process that execs in a loop got
an empty answer in 3,341 of 20,000 reads). A leftover is often right there
when a round comes: `cmd &` and the command's exit are an instant apart,
and `setsid cmd` is two execs. The SIGTERM round counted such a process as
untagged and skipped it; only the SIGKILL round, SIGKILL_GRACE_MS later,
ended it.

That is how exec-completion.test.ts failed on CI (#4445, Checks run
37752204357): its final barrier gave the reaper five seconds to end the
`setsid sleep 60` a finished exec left, and the SIGKILL round came a few
milliseconds after that. The test now waits for the reaper's own bound (the
SIGKILL grace plus the scan deadline), and the scan reads an empty
environment again, four times ten milliseconds apart, before the process
counts as untagged; a process whose environment really is empty costs a
scan that long.

No user-visible change: no messages, docs under docs/ or READMEs.
@larryro
larryro force-pushed the feat/knowledge-vector-widths branch from 7fa9fcc to 912a6dd Compare October 8, 2026 14:24
@larryro
larryro enabled auto-merge (squash) October 8, 2026 14:24
larryro added a commit that referenced this pull request Oct 8, 2026
The reaper finds a process that left an exec's group by the exec id in its
environment. A process in the middle of an execve has no environment to
read: from the moment the new image's memory replaces the old until its
arguments and environment are laid out, the kernel answers an empty one
(a probe reading /proc/<pid>/environ of a process that execs in a loop got
an empty answer in 3,341 of 20,000 reads). A leftover is often right there
when a round comes: `cmd &` and the command's exit are an instant apart,
and `setsid cmd` is two execs. The SIGTERM round counted such a process as
untagged and skipped it; only the SIGKILL round, SIGKILL_GRACE_MS later,
ended it.

That is how exec-completion.test.ts failed on CI (#4445, Checks run
37752204357): its final barrier gave the reaper five seconds to end the
`setsid sleep 60` a finished exec left, and the SIGKILL round came a few
milliseconds after that. The test now waits for the reaper's own bound (the
SIGKILL grace plus the scan deadline), and the scan reads an empty
environment again, four times ten milliseconds apart, before the process
counts as untagged; a process whose environment really is empty costs a
scan that long.

No user-visible change: no messages, docs under docs/ or READMEs.
A knowledge database held every vector in one column of one declared
width, narrowed by the first embedding model used with it. From then on a
model of another width was refused for the whole database: an organization
that changed its vector width had every document fail with "this knowledge
database stores 1536-dimensional ones", even on an empty corpus, and
organizations sharing the database had to agree on one width.

Vectors now live beside the chunks, in a table per supported width
(`chunk_vectors_<width>`: 256, 384, 512, 768, 1024, 1536, 2048, 3072,
4096), created by the knowledge-db migrations and never at runtime. An
organization's vectors are written to, and searched in, the table of the
width its model states, in both corpora.

- Migrations 15 (private_knowledge) and 16 (public_web) create the tables,
  copy the existing vectors and build the HNSW indexes. `chunks.embedding`
  stays for one release: the previous image keeps using it while a
  deployment rolls, and a trigger copies what it writes into the table of
  that width.
- The per-database pin is gone. A width with no table is refused by the
  settings form, by the save and by the indexer, naming the list; a vector
  that is not as wide as stated is still not written.
- A document indexed under another width is embedded again from its first
  chunk, and an identical document is copied only when it is embedded at
  this width.
- On a website several organizations added, each has its own vectors for
  the same chunks: a scan gives the stored chunks the missing width and
  leaves the other widths alone.
- Saving the embedding settings re-queues the indexed documents and email
  bodies that have no vector of the saved width, as it already scans the
  websites in that state.
- A chunk and its vector are written as two statements in one transaction:
  the BM25 index (pg_search) fails an INSERT that runs in a CTE nothing
  reads from, which is the case of a repeated passage.

KNOW-R11 is rewritten and KNOW-R17 added; the docs and the settings copy
follow in en/de/fr. The backend integration fixtures state a supported
width (256), and a new lane proves two widths in one database, the move to
another width and the re-queue on save.
The new writers stored a vector in the table of its width alone and left
`chunks.embedding` NULL, while the previous image reads that column alone.
During a rolling deployment, and after `tale rollback`, every document and
page this image had indexed was missing from that image's search by
meaning, with nothing reporting it. The migrations' trigger covered the
other direction only.

A vector is now written to the previous release's column as well whenever
the column is declared at exactly that width (`legacyColumnWidth`, read
from the catalog per document slice and per crawl link): the one width the
previous image can read, and the one it refused every other model for. The
document indexer carries it in the chunk row, and sets the column to NULL
otherwise, so a vector of another width never outlives its text; the
identical-document copy takes it along with the chunk; the crawler's page
write and its vectorless sweep write it the same way. The width table's
write replaces a vector that differs and touches nothing else, since the
trigger mirrors the column into it first.

The mirror trigger names no column any more. `UPDATE OF embedding` and a
WHEN on the column made the column part of the trigger's definition, and
Postgres then refuses the `ALTER COLUMN embedding TYPE vector(<width>)`
with which the previous image pins an undeclared column on its first index
— a hard failure for that image during the roll on a database nothing had
pinned yet. The checks moved into the function, which also returns at once
for an unchanged vector.

The `checkVectorWidths` lane plays the previous image on real Postgres:
pins the column as that image did, reads with that image's statement after
this image indexed at the column's width, holds the column NULL for the
organization at another width and after a move, and finds that image's
own write in the width's table.
`isProgramLimitExceeded` answered the HNSW build above pgvector's
dimension ceiling, which `pinDimensions` ran at runtime; the per-width
tables build their indexes in the migrations, below the ceiling, and
nothing calls it since. Knip flagged the export.
@larryro
larryro force-pushed the feat/knowledge-vector-widths branch from 912a6dd to 09138a5 Compare October 8, 2026 16:55
@larryro
larryro merged commit ba3f848 into main Oct 8, 2026
90 checks passed
@larryro
larryro deleted the feat/knowledge-vector-widths branch October 8, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants