Kosha docs
Documentation menu

Feature

Filtering

A filter narrows which documents a query is allowed to match. It never contributes to the score, it is evaluated against columnar per-segment storage rather than the inverted index, and it is the one input that can skip whole segments before they are opened.

POST /v1/namespaces/{namespace}/search
{
  "query_text": "breach",
  "filter": {
    "bool": {
      "must": [
        {"term":  {"status": "published"}},
        {"range": {"year": {"gte": "2024"}}}
      ],
      "must_not": [
        {"terms": {"tenant": ["internal", "sandbox"]}}
      ]
    }
  }
}

The same clause shape is accepted by delete by query, so a filter you validated on a search can be reused verbatim to delete exactly what it matched.

Clause types

term

Exact match on a single value.

{"term": {"status": "published"}}

Matching is exact and case-sensitive — no analysis is applied to either side. Put one field in one term clause: naming several fields in the same clause is a union, not an intersection, which is almost never what the shape suggests. Use bool.must for an intersection.

terms

Exact match against any value in a list — the IN of this DSL.

{"terms": {"status": ["published", "archived"]}}

range

Bounded comparison, with any combination of gte, gt, lte, and lt. Bounds are always written as strings; how they are compared depends on the field's column type.

{"range": {"year":       {"gte": "2024", "lt": "2027"}}}
{"range": {"created_at": {"gte": "2026-01-01"}}}

bool

Composition. Clauses nest to any depth.

must clause[]
Every clause must match. Applied left to right, each narrowing the set the next one sees.
must_not clause[]
Documents matching any of these are removed.
should clause[]
Optional clauses. A document passes when it matches at least minimum_should_match of them.
minimum_should_match int · default 1
How many should clauses are enough. Ignored when should is empty.

match_all

Passes everything. Useful as an explicit "no constraint" placeholder in generated queries, and as the filter for a delete that is meant to empty a namespace.

{"match_all": {}}

What each field type is filterable with

Filters read columnar stores built at flush time, and which store a field lands in is decided by its field_type. This is the single most important table on this page: a clause aimed at a column that does not exist matches nothing, silently.

Field typeColumnterm / termsrange
KeywordstringYesYes, lexicographic
DatestringYesYes, lexicographic
BooleanstringYes — "true" / "false"Not meaningful
TextstringYes, against the whole untokenized valueYes, lexicographic
IntegerintegerNoYes, numeric
FloatfloatNoYes, numeric
Vector—NoNo

Two consequences that catch people out:

  • Numbers cannot be term-filtered. term and terms only consult the string column, so {"term": {"year": "2026"}} against an Integer field matches nothing. Express it as a range — {"gte":"2026","lte":"2026"} — or index the field as Keyword if you filter it by equality more often than you compare it.
  • Dates are strings. They compare lexicographically, which is correct for zero-padded ISO-8601 (2026-01-09) and wrong for anything else. Store dates in a format that sorts as text, or store an epoch integer and use a numeric range.

Segment pruning

Every segment's footer carries bloom filters over its filter values as well as its terms. Before a segment's inverted index, filter columns, or vectors are opened at all — that is, before any S3 GET or NVMe read for its contents — the filter is tested against those blooms, and a segment that provably cannot contain a match is skipped.

This is the same skipping trick Parquet and Lucene use, and it is what makes a selective filter over a large namespace cheap rather than merely correct. It also means the shape of a filter has real performance consequences:

  • A selective term on a high-cardinality Keyword field prunes hard — most segments never open.
  • A range prunes far less, because a bloom answers "is this exact value present", not "does this interval overlap".
  • A must_not-only filter prunes nothing: proving a segment lacks a value cannot prove it lacks documents that need to be checked.

Filter-only queries

A request with a filter and an empty query_text is valid, and returns every document matching the filter. There is no relevance signal in that case, so pair it with an explicit sort — otherwise the order is whatever ranking the scorer produced for a set that has no ranking to speak of.

Filter-only queries also cannot use term-bloom pruning to skip segments, since there are no query terms to test. They are bounded by the filter blooms alone.