Feature
Full-text search
query_text is scored with Okapi BM25 against a per-segment
inverted index. Terms are matched with a strict AND by default, the scoring
parameters are tunable per request, and the hit count is capped unless you ask
for an exact one.
{
"query_text": "breach notification policy",
"max_results": 10,
"operator": "and",
"bm25_params": {"k1": 1.2, "b": 0.75}
}
What gets indexed
Only fields declared Text enter the inverted index. Every other
field type is stored and made filterable, but contributes nothing to a BM25
score — a Keyword field will never match a query term.
The term dictionary is per segment, not per field. Tokens from
every Text field on a document land in one postings space, so
query_text searches all of a document's text at once and there is
no field-scoped text query. Restricting a match to one field is what
wildcard and
match_phrase are for — both take an explicit
field.
Tokenization
The analyzer is deliberately minimal, and identical at index time and query time:
- split on whitespace,
- trim ASCII punctuation from both ends of each token,
- lowercase.
So "Breach, notification." indexes as breach and
notification. There is no stemming, no stop-word list, and no
per-field analyzer configuration — running will not match
run. If you need linguistic normalisation, do it upstream and index
the normalised text.
Document length
BM25's length normalisation uses the token count summed across all of a
document's Text fields. A document with a short title and a long
body is treated as one long document, which is worth knowing when a title-only
match scores lower than you expected.
AND and OR
The default is a strict intersection: a document must contain every query term
to match at all. Set operator to "or" for a union,
scored by summing only the terms a document actually matched.
operator | Matches | Notes |
|---|---|---|
"and" (default) |
Documents containing every term | Postings-list intersection with skipping; the fast path. |
"or" |
Documents containing any term | Union, scored on matched terms only. Much broader result sets. |
Omitting the key entirely is the same as "and". This mirrors
OpenSearch's match query operator, so a migrated query
behaves the way it did.
Tuning BM25
bm25_params is per-request, not per-namespace, so you can A/B two
scorings against the same segments without reindexing.
- k1 float · default 1.2
-
Term-frequency saturation. Lower values make repeated occurrences of a term
matter less;
0reduces scoring to presence-or-absence, ignoring frequency entirely. - b float · default 0.75
-
Length normalisation strength.
0disables it, so long documents stop being penalised for their length;1applies it fully.
Hit counts
The response carries total_hits alongside
total_hits_relation, which is either "eq" (the number
is exact) or "gte" (the true number is at least this).
{
"results": [ … ],
"total_hits": 10000,
"total_hits_relation": "gte"
}
Counting exactly is not free. An exact AND count forces the join to visit every member of the intersection long after the top-k page has settled — for a broad query that is the difference between walking millions of candidates and answering "at least 10,000" for nothing. So the count is capped by default.
- exact_total_hits bool · default false
-
Force an exact count for this query, whatever the engine default is. With
operator: "or"this also disables the union's skipping fast path — a skipped stretch of postings is never visited, so it can never be counted — which makes an exact OR count correct but slow. - total_hits_cap int · default 10000
- Where the capped count stops. Only consulted when the count is not being taken exactly.
Both have engine-wide defaults, KOSHA_EXACT_TOTAL_HITS and
KOSHA_TOTAL_HITS_CAP, that a per-query value overrides. This is the
same trade as OpenSearch's track_total_hits, with the same
reasoning: nobody reads past page one, but everybody renders the count.
How a query executes
- Manifest fetch. The namespace's current manifest is read once, giving the query a fixed, point-in-time list of segments.
- Segment pruning. If the request carries a filter, each segment's footer statistics and bloom filters are checked first, and segments that cannot contain a match are skipped before any of their content is touched.
- Per-segment scoring. Surviving segments are scored in parallel across the node's worker pool, reading their files through the NVMe cache — a hit is a local read, a miss is an S3 GET that then populates the cache.
- Merge. Per-segment candidates are merged into one ranking, the page is cut, and stored fields are materialised for the hits that survive.
The practical consequence is that latency depends on cache warmth and on how
many segments the namespace is spread across. Both are visible in
GET /v1/namespaces/{ns}/stats, and segment count is what compaction
exists to keep bounded.
Response shape
{
"results": [
{
"doc_id": "d1",
"score": 2.4079,
"fields": [
{"name": "title", "field_type": "Text", "value": "Breach notification policy"}
],
"highlights": ["<b>Breach</b> notification policy"]
}
],
"total_hits": 1,
"total_hits_relation": "eq",
"aggregations": { … }
}
highlights is present only when the request asked for it, and
aggregations only when the request carried aggs.
Bypassing the result cache
Set "no_cache": true to execute the search in full and skip storing
its response. This exists for measurement and for read-after-write tests, where
a cached response would hide exactly the thing you are trying to observe. Leave
it off in production traffic.