# 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](#whats-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_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](roadmap-and-status.md). The query examples that follow use the
> Elasticsearch-compatible form unless they say otherwise.

The full field definition looks like this:

```json
{
  "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](multi-vector-fields.md). |
| `truncate_dims` | Keep only the first N components of each vector, for Matryoshka models. See [Matryoshka embeddings and dimensions](matryoshka-and-dimensions.md). |
| `ann` | Approximate index settings, see [Approximate search](#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](compat-elasticsearch.md) and
[OpenSearch compatibility](compat-opensearch.md). Those mappings cover the dimension and metric
only.

See [Schemas and field types](schemas-and-fields.md) 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.

```json
{
  "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](ingesting-documents.md).

## 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](compat-elasticsearch.md)):

```json
{
  "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:

```bash
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](#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](#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"`.

```json
{
  "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:

```json
{ "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`](matryoshka-and-dimensions.md)
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.

```json
{
  "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` |

```json
{ "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](searching.md).

> **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](compat-elasticsearch.md), [OpenSearch compatibility](compat-opensearch.md)
and [Hybrid search](hybrid-search.md) 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](roadmap-and-status.md).
