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:
- You bring your own embeddings. WarpSearch does not call an embedding model for you, at ingest or at query time. Compute vectors with the model you prefer and send them as numbers.
- Vectors are stored as float32 today. Smaller encodings are in development, see What's coming.
- There is no HNSW graph. Approximate search uses an inverted-file (IVF) index over quantized codes, scanned from memory-mapped storage, and every candidate is rescored with the full-precision vector before it is returned. This keeps memory low and makes filtered search behave well.
#Define a vector field
A vector field needs a dimension and a similarity metric.
Availability: collections are created through Elasticsearch-style mappings (
dense_vectororknn_vector), which cover the dimension and the metric. The engine-level options shown below (truncate_dims,ann,multiand the commit optionbuild_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.
#Filter inside the search
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 and approximate search
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:
- Raise
ivf.nprobe. It scans more lists. Recall and latency both rise. This is the main knob. - Raise
refine_candidates. It rescores more candidates at full precision. This helps most when the codes are coarse (smallpq.m, 1-bit RaBitQ) andkis small. - Use more PQ code bytes. A larger
pq.mimproves recall at the cost of scan time and storage. - Try RaBitQ. It stores about as many bytes as PQ at 768 dims with different recall behavior.
- Need a guarantee? Use
exact: true, orivf.min_scan_fraction: 1to 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 OpenSearchneuralqueries 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
float16andint8vector 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.