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.
{
"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.
{
"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 to8 × kcandidates 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
filterand 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.
Filtered vector search
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 withmax_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.