Kosha docs
Documentation menu

Feature

Vector search

Send a query vector in the knn block and Kosha walks each segment's HNSW graph for approximate nearest neighbours, scored by cosine similarity. Embeddings are supplied by you — Kosha stores and searches vectors, it does not generate them.

POST /v1/namespaces/{namespace}/search
{
  "query_text": "",
  "max_results": 10,
  "knn": {
    "field": "embedding",
    "vector": [0.021, -0.184, 0.077, …],
    "k": 10
  }
}

Indexing vectors

A vector is a field like any other: type Vector, value a JSON-encoded array of floats. It is sent as a string, the same as every other field value.

POST /v1/namespaces/{namespace}/documents
{
  "documents": [{
    "id": "d1",
    "fields": [
      {"name": "body",      "field_type": "Text",   "value": "Report any breach within 72 hours."},
      {"name": "embedding", "field_type": "Vector", "value": "[0.019,-0.203,0.088]"}
    ]
  }]
}

A value that does not parse as an array of floats is skipped silently rather than failing the write, so a malformed embedding shows up as a document that never appears in neighbour results. Keep dimensions consistent within a namespace — mixed-width vectors are not rejected at write time and will not compare meaningfully.

One vector per document

A segment holds a single vector store, so a document contributes one embedding to it. To search several embeddings per source object — per-chunk vectors, say — index each chunk as its own document and carry the parent id in a Keyword field you can filter or group on.

The knn block

field string · required
Name of the vector field to search.
vector float[] · required
The query embedding, as a JSON array of numbers.
k int · default 10
Neighbours to take per segment. The final page is cut from the union of every segment's k, so a namespace spread over eight segments considers up to 8 × k candidates before ranking.
num_candidates int · default 100
Accepted for OpenSearch compatibility and forward-compatibility, but not yet wired to the graph traversal — the search depth is currently the HNSW implementation's own default. Setting it changes nothing today.
filter filter clause · optional
Accepted but not yet applied. To constrain a vector search, use the top-level filter and read Filtered vector search below, because the combination does not behave the way a pre-filtered kNN would.

Scoring and recall

Distances come from an HNSW graph over cosine distance, and the score reported is cosine similarity — 1 − distance, clamped at zero. Higher is closer. Pure-kNN scores are then rescaled so they sit on a comparable footing with BM25 scores in the same response, so do not read a kNN score as a raw similarity value.

If a segment has no HNSW graph — an older segment written before the vector index existed, for instance — the query falls back to an exhaustive scan of that segment's vector store. Results stay correct, in fact exactly correct, but that segment costs a full linear pass.

Recall is approximate, and measured

Mean recall@10 on the published 10M-document benchmark is 0.947 over a 500-query sample. Graph traversal trades a small amount of recall for a large amount of speed; if your workload cannot accept a missed neighbour, kNN over a graph index is the wrong tool regardless of engine.

Combining text and vectors

Sending query_text and knn together produces a blended ranking, but a specific and limited one: the lexical query establishes the result set, and neighbour scores are folded into documents that are already in it, at equal weight. A document that is a close neighbour but matches no query term is not added.

That is a deliberate Phase 1 shape, not the end state. Reciprocal Rank Fusion — which merges two independently retrieved candidate lists by rank rather than by score, and would surface exactly those vector-only documents — is Phase 2, along with the cross-encoder rerank hook. Until then, if you want a true union of lexical and semantic candidates, issue the two searches separately and fuse in your application.

A filter sent with knn and no query_text currently seeds the result set from every filter-passing document in the segment, then blends neighbour scores into the ones that are also nearest. The neighbour search itself is not restricted to the filtered set beforehand.

Two consequences worth planning around:

  • The result set is as large as the filter's match count, not k. Cap it with max_results.
  • Documents outside the filter are excluded, so results are never wrong — but a narrow filter over a large namespace does not get the speedup that pre-filtered ANN would give it.

Pre-filtered graph traversal, and honouring knn.filter, is outstanding work. Until it lands, the fastest filtered vector search is a narrower namespace: namespaces are cheap by design, and one per tenant, matter, or collection removes the filter entirely.

What a vector query costs

Vector data is only loaded when a request actually needs it — a lexical query never pays to hydrate vector.idx. A kNN query does, per segment it touches, which makes the first vector query against a cold namespace materially slower than the ones after it.

The published benchmark shows this: on 10M documents, warm and cold kNN latencies are close to each other (p50 124 ms vs 123 ms) and both well above warm BM25, because graph traversal — not object storage — dominates the cost.