Skip to content

Every vector search reads every chunk in the knowledge base #512

Description

@roycegeo

documents::vector_search orders by distance with nothing underneath it:

// crates/utopia-store/src/documents.rs:1255
"SELECT id FROM chunks c
 WHERE c.kb_id = $1 AND c.embedding IS NOT NULL AND {live}
   AND vector_dims(c.embedding) = vector_dims($2)
 ORDER BY c.embedding <=> $2
 LIMIT $3"

grep -rn 'hnsw\|ivfflat' migrations/ returns nothing. Across 37 migrations there is no vector index of any kind, so every hybrid query computes cosine distance against every embedded chunk in the knowledge base and sorts the lot to take ten.

This is not news. 0002_ingest.sql:123 says so in as many words:

The dimension varies with the chosen embedding model. P1 scans sequentially; at volume, build an HNSW index for the configured dimension.

This issue is that note coming due. The ontology-side scans in ontology.rs are a different case and should stay as they are — 0003_graph.sql:22 argues that thousands of rows do not need an index, and it is right. chunks is the table that grows without bound.

Why it cannot just be added

The column is dimensionless. embedding vector with no (N). pgvector cannot build HNSW on a column of unknown dimension, and the dimension here follows the workspace's configured embedding model, which is not known when the migration runs. The P1 decision was correct; it just left the index unbuildable without a second step.

The query is filtered, and HNSW post-filters. {live} expands via record_axis::chunk_live_at to

c.created_at <= coalesce($4, now())
AND (c.superseded_at IS NULL OR c.superseded_at > coalesce($4, now()))

on top of kb_id, embedding IS NOT NULL and the dimension guard. pgvector walks the index for ef_search candidates and then applies the WHERE, so a selective filter returns fewer than LIMIT rows. In a table keyed by kb_id across tenants that is the ordinary case, not a corner: a small knowledge base sharing a table with a large one can get zero hits from a perfectly good index.

The running container is pgvector/pgvector:pg16 at extension version 0.8.6 (checked, not assumed), so hnsw.iterative_scan is available. That is the mechanism for exactly this failure — it keeps pulling from the index until LIMIT is satisfied or hnsw.max_scan_tuples is reached. Any fix that adds the index without setting it will look correct in a single-tenant test and silently under-return in a real deployment.

The dimension guard is not indexable. vector_dims(c.embedding) = vector_dims($2) on line 1258 is evaluated per candidate row and cannot narrow anything. It needs to become part of the index predicate or move.

What to build

  • Record the embedding dimension a knowledge base's chunks were written at, so the index has a number to be built for.
  • A partial HNSW index per dimension in use, vector_cosine_ops to match the <=> operator, built CONCURRENTLY or the migration takes an ACCESS EXCLUSIVE lock on chunks for the length of the build.
  • IF NOT EXISTS, because the migrations CI job replays every migration twice.
  • hnsw.iterative_scan = relaxed_order on the retrieval path, with max_scan_tuples chosen deliberately and commented with the reasoning.
  • Migration 0038_, after checking the latest number on dev — two branches each adding an 0038_ merge cleanly and then neither runs.
  • Decide in the same change whether entities.profile_embedding gets the same treatment. It has the same shape and, per the type-resolution issue, worse caller pressure.

Not this

ivfflat. It needs a representative sample at build time to pick its lists, and a corpus that grows past the sample degrades recall quietly — no error, just worse answers. For a system whose claim is traceable evidence, silent recall loss is the wrong failure mode.

Dropping the record-axis filter so the index applies cleanly. Replay is the product (0019). The index accommodates the filter, not the other way round.

Unit tests

From the SME's side the index is invisible. The contract is recall and the record axis, so every test here must fail if the index changes answers and stay quiet if it only changes plans. Each test is run twice — once with enable_indexscan = off to force the exact scan, once with the index live — and the two must agree.

crates/utopia-store/tests/a_search_reads_the_base_as_it_was.rs already pins the record axis on the vector path and is the primary guard. It must pass unchanged with the index present. If it needs editing, the index changed semantics and the change is wrong.

New, in crates/utopia-store/tests/the_nearest_chunk_is_found_however_it_is_reached.rs:

  • the_nearest_neighbour_is_returned_whatever_the_plan — seed chunks with hand-built vectors and one unambiguously nearest. Exact and indexed paths return the same first row.
  • a_filtered_search_still_fills_its_limit — the rule that actually matters. One knowledge base holding a single matching chunk beside another holding many thousand; LIMIT 10 returns the one. This is the post-filter failure, and without iterative_scan it returns nothing.
  • a_search_never_crosses_a_knowledge_base — tenancy, not performance. The ANN path must not leak a neighbouring base's chunk in.
  • chunks_of_another_dimension_are_not_candidates — a workspace that changed embedding model leaves two dimensions in one table. The guard holds and the operator does not error.
  • a_superseded_chunk_is_not_a_hit — a reparse displaces a chunk; the indexed path agrees with the exact one.
  • a_search_with_a_moment_sees_what_was_alive_then — 0019 restated directly against the index.

All of these are database-gated. Use test_db::url(), never the env var directly, and run them with UTOPIA_TEST_REQUIRE_DB=1 — without it they return ok while doing nothing, which on this change would be worse than no test at all.

Acceptance testing

  1. docker compose up -d db, then run the migrations twice. A CONCURRENTLY index that is not IF NOT EXISTS fails the second pass, and that is the CI job's whole point.
  2. Seed a corpus large enough for the question to mean anything — at least tens of thousands of embedded chunks in one base, with a second small base alongside it to exercise the filter.
  3. EXPLAIN (ANALYZE, BUFFERS) on the retrieval query before and after, both pasted into the PR. Seq Scan on chunks must become an index scan, with the wall-clock figures beside it.
  4. Recall@10 measured and stated. Take 200 real queries, compute exact top-10 with enable_indexscan = off, compute top-10 with the index, report the overlap. This repository has agreed no thresholds — there is no docs/engineering-standards.md — so the PR proposes a bar and the reasoning for it rather than quietly adopting one.
  5. bash scripts/smoke.sh against a throwaway deployment. Register, upload, ingest, search still works end to end. Point it at a database you can discard: the first account registered becomes the administrator.
  6. Ask five questions in the chat before and after and confirm each answer still cites the sentence its fact came from. An index that improves latency and loses a citation has failed.
  7. cargo test --workspace with UTOPIA_DATABASE_URL and UTOPIA_TEST_REQUIRE_DB=1 both set.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions