Kosha docs
Documentation menu

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.

POST /v1/namespaces/{namespace}/search
{
  "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.

operatorMatchesNotes
"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; 0 reduces scoring to presence-or-absence, ignoring frequency entirely.
b float · default 0.75
Length normalisation strength. 0 disables it, so long documents stop being penalised for their length; 1 applies 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).

response
{
  "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

  1. Manifest fetch. The namespace's current manifest is read once, giving the query a fixed, point-in-time list of segments.
  2. 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.
  3. 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.
  4. 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

response
{
  "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.