For AI agents: this page is also available as Markdown at /docs/vector-search.md. Every docs page is: add .md to its URL. llms.txt lists them all.

Vector search

WarpSearch stores vectors next to your documents and searches them with the same filters, facets and paging as text queries. There is no separate vector store to run or keep in sync.

Three things to know up front:

Define a vector field

A vector field needs a dimension and a similarity metric.

Availability: collections are created through Elasticsearch-style mappings (dense_vector or knn_vector), which cover the dimension and the metric. The engine-level options shown below (truncate_dims, ann, multi and the commit option build_aux_indexes) are implemented but are not exposed through the public API yet. Treat them as coming soon, and see Feature status. The query examples that follow use the Elasticsearch-compatible form unless they say otherwise.

The full field definition looks like this:

{
  "fields": {
    "embedding_v": {
      "type": "vector",
      "dims": 768,
      "metric": "cosine"
    },
    "passages_vs": {
      "type": "vector",
      "dims": 768,
      "metric": "cosine",
      "multi": true
    }
  }
}
Property Meaning
type vector. A field named with the _v suffix is also a vector, but it is storage only until you give it a metric.
dims Number of components. Fixes the dimension for every written and queried vector. If you leave it out, the first vector in each segment sets it, so set it explicitly.
metric l2, ip (inner product) or cosine.
normalize_on_write Cosine fields only. On by default: vectors are stored at unit length. Set false to keep the raw vector, in which case candidates are normalized at query time.
multi true (or the _vs suffix) lets one document hold several vectors. See Multi-vector fields.
truncate_dims Keep only the first N components of each vector, for Matryoshka models. See Matryoshka embeddings and dimensions.
ann Approximate index settings, see Approximate search.

If you use the Elasticsearch or OpenSearch API, declare the field as dense_vector (dims, similarity) or knn_vector (dimension, space_type) instead. See Elasticsearch compatibility and OpenSearch compatibility. Those mappings cover the dimension and metric only.

See Schemas and field types for the rest of the schema.

Write vectors

A vector is a plain array of numbers. A multi-valued field takes an array of arrays.

{
  "id": "b1",
  "title_t": "Dune",
  "kind_s": "fiction",
  "embedding_v": [0.8, 0.1, 0.1],
  "passages_vs": [[0.7, 0.2, 0.1], [0.1, 0.8, 0.1]]
}

Integers are fine. Every element must be a finite number that fits in a float32. A NaN, an infinity or a value out of range fails that document. A vector of the wrong length fails too. Cosine vectors with a zero norm have no direction and are skipped. Vector fields are not returned in search results unless you name them in fields. See Ingesting documents.

Run a kNN query

In the engine's own request form, a kNN query is a query like any other. Over the API today, send the same query as an Elasticsearch-style knn section (see Elasticsearch compatibility):

{
  "query": {
    "knn": {
      "field": "embedding_v",
      "query": [0.75, 0.15, 0.10],
      "k": 20
    }
  },
  "filter": ["kind_s:fiction"],
  "limit": 10,
  "fields": ["id", "title_t"]
}

The same search through the Elasticsearch-compatible API, which works with your existing client:

curl -s https://api.warpsearch.io/compat/es/books/_search \
  -H "Authorization: Bearer $WARPSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "knn": {
      "field": "embedding_v",
      "query_vector": [0.75, 0.15, 0.10],
      "k": 20,
      "num_candidates": 200,
      "filter": { "term": { "kind_s": "fiction" } }
    },
    "size": 10,
    "_source": ["title_t"]
  }'

k is how many nearest documents the query produces. limit (size in Elasticsearch terms) is how many the response returns. A collection is searchable as soon as its vectors are indexed, and until you build approximate indexes every search is exact.

Parameter Meaning
field The vector field. Required.
query The query vector, with the field's dimension. Required (query_vector in the Elasticsearch form).
k Neighbors to produce. Required, positive.
unit docs (default) or groups. See Nearest groups.
exact true forces a full-precision scan and returns the true top k. Ignores the tuning knobs below.
similarity Minimum similarity of a hit. See Similarity thresholds.
refine_candidates How many candidates are collected before the full-precision rescore. Unset, it is sized from k. If set, it is raised to at least k.
ivf.nprobe How many index lists to scan. 0 or absent means adaptive.
ivf.min_scan_fraction A floor on the fraction of vectors scanned, from 0 to 1. 0.001 is 0.1%.

How scores work

Metric Score Higher means
cosine Cosine similarity, in the range -1 to 1 More similar
ip The dot product More similar
l2 1 / (1 + squared distance), in the range 0 to 1 Closer

The scale depends on your embedding model and corpus, so calibrate any cut-off on your own data. The Elasticsearch-compatible API returns these scores as they are, not rescaled to the (1 + cosine) / 2 form Elasticsearch uses, so compare scores across engines with care.

Filters apply while neighbors are collected, not after. k: 20 with a filter means twenty filtered neighbors, not twenty minus whatever a later filter removed. If a filter or a multi-vector collapse leaves too few results, an approximate search automatically goes deeper.

Vector queries also compose under boolean clauses and facet buckets, so you can facet over the neighbors a query found. There is no automatic switch to exact search when a very selective filter starves an approximate search. Use exact: true when you need the exact filtered top k.

Exact search scans the full-precision vector column once per query. It is perfectly accurate and fast for small collections, and it needs no setup.

Approximate search builds an IVF index per segment of your data (a segment is a batch of documents that is indexed together). K-means centroids route a query to a few inverted lists, and each list holds one compact code per vector. Index data stays on memory-mapped storage and is paged in by the operating system, so the index does not need to fit in RAM. Finally, candidates are rescored from the original float32 vectors, so returned scores are exact even when candidate selection was approximate.

Codecs

PQ (product quantization) is the default. RaBitQ is available as an opt-in and needs an explicit "codec": "rabitq".

{
  "fields": {
    "embedding_v": {
      "type": "vector",
      "dims": 768,
      "metric": "cosine",
      "ann": { "codec": "rabitq", "rabitq": { "bits": 1, "query_bits": 4 } }
    }
  }
}
Setting Default Meaning
codec pq pq or rabitq. Omitting it selects PQ, even if you pass rabitq settings.
nlist Square root of the vectors in the segment, capped at 4096 Number of IVF lists. 0 means automatic.
pq.m Automatic Sub-quantizers. Must divide the stored dimension. Automatic picks the largest divisor that leaves at least 4 dimensions each, under a cap.
pq.bits 8 Bits per PQ code, 1 to 8.
rabitq.bits 1 Bits per dimension, 1 to 9.
rabitq.query_bits 4 Bits the query is quantized to while scanning, 0 to 8. 0 scans with the float query.

A few rules: an ann object replaces the parent's definition instead of merging with it. Changing ann applies to segments built afterwards, not to existing indexes, and a query serves each segment with its own index, so different settings mix freely.

PQ scans faster. RaBitQ offers a different size and recall trade-off. With 1-bit RaBitQ, recall is usually limited by how many lists you probe, so raise ivf.nprobe before raising bits.

When indexes get built

Plain commits never build approximate indexes. Ask for them when you commit:

{ "commit": { "build_aux_indexes": ["*"] } }

Use ["vec.embedding_v"] to build one field only. On the Elasticsearch-compatible API, a dense_vector mapped with an index_options type other than flat (or an OpenSearch knn_vector with an ivf or hnsw method) marks the field for indexing, and _refresh builds the missing indexes. After a bulk load made of many small batches, _forcemerge merges them first and then builds. Build parameters such as m or ef_construction are not mapped, and the compatibility layer warns that they are ignored.

An index is built per segment, and only when the segment is big enough: about 10,000 vectors for PQ and 8,192 for RaBitQ. Smaller segments stay on exact scan, which is faster than an index at that size. A single query routinely uses indexes for large segments and exact scan for small ones, with the same result contract. Once a segment has an index, later commits carry it forward, and merged segments get fresh indexes when they pass the same thresholds.

Memory and storage per vector

The float32 column always stays, because rescoring needs it. The index is added on top of it.

Item Size per vector
float32 column 4 x dims bytes (3,072 bytes at 768 dims)
PQ codes at 8 bits m bytes, where automatic m is 48, 96, 120, 192 and 384 at 384, 768, 960, 1536 and 3072 dims for cosine and inner product, and at most 32 for L2
RaBitQ at 1 bit About 104 bytes at 768 dims, roughly 1/30 of the float vector

Add a few bytes per vector for ids and index headers. Use truncate_dims to shrink all of the above at once if your model supports it.

Tune recall and latency

Recall is how many of the true nearest neighbors an approximate search returns. Measure it by running the same query twice, once with "exact": true, and comparing the lists. Everything else about the query is identical, so the exact run is your ground truth.

Recipes, in the order to try them:

  1. Raise ivf.nprobe. It scans more lists. Recall and latency both rise. This is the main knob.
  2. Raise refine_candidates. It rescores more candidates at full precision. This helps most when the codes are coarse (small pq.m, 1-bit RaBitQ) and k is small.
  3. Use more PQ code bytes. A larger pq.m improves recall at the cost of scan time and storage.
  4. Try RaBitQ. It stores about as many bytes as PQ at 768 dims with different recall behavior.
  5. Need a guarantee? Use exact: true, or ivf.min_scan_fraction: 1 to probe every list.

The numbers below come from a benchmark on 485,659 Cohere-768 cosine embeddings of Simple English Wikipedia, recall@10 averaged over 200 held-out queries. They used an earlier default probe policy and serial single-query latency on one workstation. Treat them as an illustration of the trade-offs, not as a promise for your data, because recall depends heavily on your embedding model and corpus.

Codec nprobe default 32 64 128 Latency at default nprobe
RaBitQ, 1 to 4 bits 0.893 0.911 0.960 0.986 1.2 ms
PQ, 96 bytes per vector 0.886 0.902 0.950 0.970 0.66 ms

Since that benchmark, the default probe effort has been scaled up with dimension, which lifted recall@10 on 768 to 960 dimension corpora from roughly 0.82 to 0.87 to about 0.95 in RaBitQ measurements. Run your own recall check before you rely on a setting.

Nearest groups

When each entity (a product, an article) has several vector-bearing documents, set "unit": "groups" and collapse on the entity key. k then counts groups, and repeated near neighbors from one product do not crowd out other products.

{
  "query": {
    "knn": { "field": "embedding_v", "query": [0.75, 0.15, 0.10], "k": 20, "unit": "groups" }
  },
  "collapse": "product_id_s",
  "limit": 10
}

A group's score is its best member's score. unit: "groups" without a collapse is a request error. If fewer than k groups exist, the response carries a knn_group_underfill warning that says whether the candidates ran out or a search limit stopped the search.

Similarity thresholds

similarity drops neighbors that are not similar enough. Its value is in the metric's own space, the same way Elasticsearch's knn.similarity works:

Metric similarity means Hit kept when
l2 Maximum L2 distance (not squared), at least 0 distance <= similarity
ip Minimum dot product dot >= similarity
cosine Minimum cosine similarity cosine >= similarity
{ "query": { "knn": { "field": "embedding_v", "query": [0.75, 0.15, 0.10], "k": 20, "similarity": 0.8 } } }

The threshold trims the k nearest documents after rescoring. It does not search further, so you can get fewer than k hits. Trimmed documents are removed from totals, facets and metrics too. To set a cut-off on a combined text and vector score, see the minimum-score options in Searching.

Not supported: radius search without a k (returning every vector within a distance), binary or bit vectors, and OpenSearch neural queries that embed query text for you.

Elasticsearch, OpenSearch and Solr

You can keep your existing client for kNN. These forms are accepted: the Elasticsearch top-level knn section and knn query, the OpenSearch knn query, and the Solr {!knn f=field topK=n} parser.

Client parameter WarpSearch behavior
num_candidates, ef_search Sets refine_candidates. It sizes the rescoring pool but cannot add candidates beyond the lists probed, unlike HNSW.
rescore_vector.oversample, oversample_factor refine_candidates of ceil(k x factor)
method_parameters.nprobes ivf.nprobe
similarity similarity, in the metric's own space
filter Applied inside the search
boost Boosts the kNN clause in a hybrid query

Extensions "ivf": {"nprobe": 32} and "exact": true also work inside the compatible forms. The maximum vector dimension on the compatibility layer is 8,192. See Elasticsearch compatibility, OpenSearch compatibility and Hybrid search for combining kNN with text.

What's coming

These are in development and not yet released. We give no dates.

Coming soon: per-field float16 and int8 vector encodings to cut column storage, faster exact scan kernels that use SIMD instructions, and sparse vectors for learned sparse retrieval such as SPLADE. See Roadmap and status.

View this page as Markdown