Skip to content

Graphic preview

100%
Page type: concept

Plan vector indexes

Which index to build, when to build it, and why the numbers a query returns depend on settings the index does not carry.

Without an index, every search is a sequential scan over the whole corpus. With one, it is approximate — and how approximate depends on settings your query supplies at run time.

target:
index_method: hnsw
index_options:
m: "16"
ef_construction: "64"

ptah inference index builds it. A specification that declares no index_method has no index to build, and the command says so rather than failing — every query over that generation is then a sequential scan, which is what its author asked for.

Method Build Query Choose when
hnsw Slower, more memory Faster and more accurate at the same recall The default for most corpora
ivfflat Faster, less memory Needs tuning to reach the same recall Very large corpora where the HNSW build is prohibitive

hnsw is the answer unless the build time or memory is a real constraint.

YDB has one vector index, vector_kmeans_tree, and a generation declares its shape with two options, both required:

target:
representation: float
metric: cosine
index_method: vector_kmeans_tree
index_options:
levels: "2"
clusters: "128"

clusters is how many clusters each level of the tree splits into, between 2 and 2048, and levels is how deep the tree goes, between 1 and 16. clusters to the power of levels is at most 2^30. The metric becomes the index’s distance or similarity, and vector_type and vector_dimension come from the representation and the provider’s dimension. ptah inference index builds it with ALTER TABLE ... ADD INDEX ... GLOBAL USING vector_kmeans_tree, which returns once the index holds every row.

What the index can do depends on the YDB line, and Ptah reads it from the server’s capabilities:

Line Builds the index Takes rows written after the build
25.1 with EnableVectorIndex on no
25.2 yes no
25.3 and later yes yes

On a line whose index does not take later writes, a write into a generation whose index exists is refused rather than left for the index to miss. The lifecycle builds the index after the backfill, so the refusal appears only when the source changed after the index was built.

ptah inference index comes after catchup in the lifecycle, and the ordering matters for ivfflat: it trains its lists on the data present when it is built, so one built over an empty column is valid and useless.

On PostgreSQL the build runs concurrently, because the table is one your application is reading and writing throughout. That is also why the command reads the index back afterwards — a concurrent build that fails leaves an index PostgreSQL will not use, and reporting that as done would report a generation ready while every query over it is a sequential scan.

Running it again is the finished state:

Terminal window
$ ptah inference index --spec spec.yaml --db-url "$DB" --run-id "$RUN"
generation 3b2b6d04c204 already has a valid index

An invalid index left by a failed build is dropped and built again, and the command says which of those it did.

This is the part that surprises people, and it is why Ptah records it.

The index settings above affect build cost and the ceiling on quality. What your users actually get depends on parameters the query supplies — ivfflat.probes, hnsw.ef_search — which are session settings, not properties of the index.

Measured on one corpus, recall at a fixed k moved from 26.5% to 100% by changing ivfflat.probes alone, over the same index and the same vectors.

So a recall number without the parameters it was taken under means nothing. ptah inference evaluate records the query parameters alongside every number for exactly this reason:

- recall 1.000, MRR 1.000, NDCG 1.000 over 3 cases
- the index agrees with an exhaustive search on 1.000 of results, over 3 cases
- measured under hnsw.ef_search=40,ivfflat.probes=8

Comparing two generations means comparing them at the same settings. The evaluation’s --baseline does that for you.

On YDB the setting is ydb.KMeansTreeSearchTopSize, the number of clusters a search descends into. Ptah does not set it, so evaluate reports it as ydb.KMeansTreeSearchTopSize=(server default). The indexed search reads the generation through VIEW <index>, and the exhaustive one reads the table without it.

evaluate can also compare the index against an exhaustive search over the same vectors:

Terminal window
ptah inference evaluate --spec spec.yaml --db-url "$DB" \
--corpus corpus.yaml --min-exact-agreement 0.9

The corpus file is described in Evaluation corpus reference. That measures the index rather than the model: how often the approximate search returns what a full scan would have. A low number means the index or its query parameters need attention, not that the embeddings are wrong.

Index options are not part of the identity

Section titled “Index options are not part of the identity”

Changing m or ef_construction produces a different index over the same corpus, not a different corpus. Ptah excludes them from the generation identity deliberately, so retuning an index does not force a re-embed.