Concepts

What is faceted search?

Searching is an important part of any business database function, whether through internal databases, document stores, or the content of a website. The more documents are indexed, the more important it becomes to refine the search query and filter results. This can be done by adding extra keywords to the query, field search — which restricts the search to certain specified fields — or faceted search.

Faceted search vs. field search

Faceted search is much more than a field search. Field search lets you restrict a query to specified fields, but requires you to already know both all available fields in the indexed documents and all unique values per field. That leads to trial and error, leaving people with no or unsatisfactory results.

Faceted search is not just a filter — a complete set of filters is automatically generated from the indexed documents. It automatically clusters the indexed documents (or the current search results) into categories, and the user filters and refines results by selecting specific values or numeric ranges for each facet field. For every facet field, the number of results matching each distinct facet value or numeric range is shown alongside it. The difference between field filtering and faceted search is like the difference between an open question and multiple choice.

Because faceted search supports counting and filtering by specific field values, it's widely used in product search to narrow down and count results by features like brand, manufacturer, rating, or price.

Index facets vs. query facets

Index facets list all available facet fields, their unique values, and how often each value occurs within the entire index — a complete overview of every filtering option, together with the potential result count for each one. Because they don't depend on a query, index facets in SeekStorm are simply obtained by querying with enable_empty_query: true and an empty query string.

Query facets list all available facet fields, their unique values, and how often each value occurs within the documents that match a given query — a complete overview of the filtering options for the current result set, together with quantitative information for each option.

For numeric facet fields specifically, the "Get index info" endpoint separately exposes a facets_minmax property with the minimum and maximum value observed across the whole index — useful for picking sensible default range boundaries before a query is even issued.

Facet filtering, counting & sorting

A facet filter narrows the returned results to documents that both match the query and match, for every specified facet filter field (boolean AND), at least one of the specified values for that field (boolean OR).

If the query changes and/or the facet filter changes, both the search results and the facet counts change with it.

String facets & numerical range facets

Faceting isn't a separate field type in SeekStorm — it's a flag. Any schema field can be marked "facet": true, regardless of whether it's String16 / String32, a numeric type (u8f64, Timestamp), or a multi-value StringSet16 / StringSet32 field. A faceted field can still be indexed and stored like any other field — facet: true additionally stores it in a compact binary layout for fast counting and sorting, without touching the document store.

String facets (String16 / String32)

One value per field per document — e.g. a language field with English, French, German… Each distinct value is counted across the whole index (index facet) or the current results (query facet), sorted by occurrence count in descending order. Distinct values per field are capped at 65,535 for String16 and ~4.29 billion for String32.

Multi-value facets (StringSet16 / StringSet32)

Like String facets, but multiple values can be assigned to the same field on the same document at once — genres, authors, tags, categories — anywhere an item legitimately belongs to more than one value.

In contrast to string facets, which define themselves from the values already present, numerical range facets require you to explicitly define the ranges to count — e.g. for a price field: 0–10, 10–20, 20–50, 50–100, 100–1000. Ranges can be redefined per query, which is what makes dynamic buckets like "last hour", "last day", or "last week" possible on a Timestamp field. The lower boundary of a range is inclusive; the upper boundary is defined implicitly by the next range's lower boundary (or the type's maximum). Up to 65,536 distinct ranges are supported per range facet field, and a range_type of CountWithinRange, CountAboveRange, or CountBelowRange controls how each bucket counts.

Besides counting, results can also be filtered to a specific range (independent of the ranges defined for counting), and sorted by any faceted field, ascending or descending — including a special _score pseudo-field for relevancy, which can be combined with other sort fields as a tie-breaker. If no sort field is specified, results are sorted by rank (descending) by default.

Performance

Faceted search is known to weigh heavily on query performance. Performance and scaling are always paramount for SeekStorm, faceting included — the index architecture is built so that faceting has only a low impact on search latency, regardless of index size, number of facet fields, or number of unique facet values per field.

Relevant API endpoints

The examples below use the REST API directly, the generated Rust seekstorm_client SDK, and the embeddable seekstorm Rust library — pick whichever matches how you're integrating.

1. Create index

Facet fields are defined in the schema array of create_index — any field gets faceting by adding "facet": true, on top of whichever field_type it already has.

REST · POST /api/v1/index
{
  "index_name": "demo_index",
  "schema": [
    {"field": "title", "field_type": "Text",  "store": true,  "index_lexical": true, "boost": 10.0},
    {"field": "body",  "field_type": "Text",  "store": true,  "index_lexical": true},
    {"field": "url",   "field_type": "Text",  "store": true,  "index_lexical": false},
    {"field": "town",  "field_type": "String16", "store": true, "index_lexical": false, "facet": true},
    {"field": "price", "field_type": "F32",      "store": true, "index_lexical": false, "facet": true},
    {"field": "date",  "field_type": "Timestamp","store": true, "index_lexical": false, "facet": true}
  ],
  "similarity": "Bm25fProximity",
  "tokenizer": "UnicodeAlphanumeric",
  "synonyms": []
}
Rust client · seekstorm_client
use seekstorm::index::{
    CreateIndexRequest, LexicalSimilarity, TokenizerType, StemmerType,
    StopwordType, FrequentwordType, NgramSet, DocumentCompression, Clustering,
};
use seekstorm::vector::Inference;
use seekstorm_client_rs::api_endpoints::RestClient;

let schema_json = r#"[
    {"field":"title","field_type":"Text","store":true,"index_lexical":true,"boost":10.0},
    {"field":"body","field_type":"Text","store":true,"index_lexical":true},
    {"field":"url","field_type":"Text","store":true,"index_lexical":false},
    {"field":"town","field_type":"String16","store":true,"index_lexical":false,"facet":true}
]"#;
let schema = serde_json::from_str(schema_json).unwrap();

let create_index_request = CreateIndexRequest {
    index_name: "demo_index".into(),
    similarity: LexicalSimilarity::Bm25f,
    tokenizer: TokenizerType::UnicodeAlphanumeric,
    stemmer: StemmerType::None,
    stop_words: StopwordType::None,
    frequent_words: FrequentwordType::English,
    synonyms: Vec::new(),
    ngram_indexing: NgramSet::NgramFF as u8,
    document_compression: DocumentCompression::Snappy,
    spelling_correction: None,
    query_completion: None,
    clustering: Clustering::None,
    inference: Inference::None,
    schema,
};

let client = RestClient::new();
let index_id = client
    .create_index(BASE_URL, API_KEY, &create_index_request)
    .await?;
Rust library · seekstorm (embedded)
use seekstorm::index::{create_index, IndexMetaObject, Clustering, LexicalSimilarity,
    TokenizerType, StopwordType, FrequentwordType, StemmerType, NgramSet, DocumentCompression, AccessType};
use seekstorm::vector::Inference;
use std::path::Path;

let schema_json = r#"[
    {"field":"title","field_type":"Text","store":false,"index_lexical":false},
    {"field":"body","field_type":"Text","store":true,"index_lexical":true},
    {"field":"url","field_type":"Text","store":true,"index_lexical":false},
    {"field":"town","field_type":"String16","store":false,"index_lexical":false,"facet":true}
]"#;
let schema = serde_json::from_str(schema_json).unwrap();

let meta = IndexMetaObject {
    id: 0,
    name: "demo_index".to_string(),
    lexical_similarity: LexicalSimilarity::Bm25f,
    tokenizer: TokenizerType::UnicodeAlphanumeric,
    stemmer: StemmerType::None,
    stop_words: StopwordType::None,
    frequent_words: FrequentwordType::English,
    ngram_indexing: NgramSet::NgramFF as u8,
    document_compression: DocumentCompression::Snappy,
    access_type: AccessType::Mmap,
    spelling_correction: None,
    query_completion: None,
    clustering: Clustering::None,
    inference: Inference::None,
};

let index_arc = create_index(Path::new("./demo_index"), meta, &schema, &Vec::new(), 11, false, None).await?;

2. Index document(s)

The facet field's field_type is fixed at index creation; its per-document value is set like any other field value with index_document / index_documents. Faceted values are also indexed for full-text search alongside their facet role.

REST · POST /api/v1/index/{index_id}/doc
[
  {"title": "title1 test", "body": "body1", "url": "url1", "town": "Berlin",   "price": 9.90},
  {"title": "title2",      "body": "body2 test", "url": "url2", "town": "Warsaw",  "price": 14.50},
  {"title": "title3 test", "body": "body3 test",  "url": "url3", "town": "New York","price": 22.00}
]
Rust client · seekstorm_client
use seekstorm::index::Document;

let documents_json = r#"[
    {"title":"title1 test","body":"body1","url":"url1","town":"Berlin","price":9.90},
    {"title":"title2","body":"body2 test","url":"url2","town":"Warsaw","price":14.50}
]"#;
let documents: Vec<Document> = serde_json::from_str(documents_json).unwrap();

client.index_documents(BASE_URL, API_KEY, index_id, &documents).await?;

3. Get index info — numeric facet min/max

get_index_info does not return the string facet value list itself (use an empty-query /query call for that, see below) — but its facets_minmax property does return the minimum and maximum value observed for every numeric facet field across the whole index, which is exactly what you need to build sensible default range-facet buckets (histogram bounds, slider min/max) before issuing a single query.

REST · GET /api/v1/index/{index_id}
{
  "id": 0,
  "name": "demo_index",
  "schema": { "…": "…" },
  "indexed_doc_count": 58215,
  "committed_doc_count": 58215,
  "operations_count": 58215,
  "query_count": 421,
  "version": "0.12.0",
  "facets_minmax": {
    "price": {"min": 0.5, "max": 999.0},
    "date":  {"min": 831306011, "max": 1730901447}
  }
}
Rust client · seekstorm_client
let info = client.get_index_info(BASE_URL, API_KEY, index_id).await?;

for (field, minmax) in &info.facets_minmax {
    println!("{field}: {:?} .. {:?}", minmax.min, minmax.max);
}
Rust library · seekstorm (embedded)
// HashMap<String, MinMaxFieldJson> — one entry per numeric facet field
let facets_minmax = index.index_facets_minmax().await;

if let Some(price_range) = facets_minmax.get("price") {
    println!("price: {:?} .. {:?}", price_range.min, price_range.max);
}

4. Query index — with query facets, facet filter & sort

A single search request carries the query, which facets to return (query_facets), which facet values to filter results by (facet_filter), and how to sort (result_sort). String facets go through the String16 / String32 variants and take a prefix and a length cap; numerical facets go through the type-specific variant (U8F64, Timestamp) and take explicit ranges.

REST · POST /api/v1/index/{index_id}/query
{
  "query": "bm25",
  "offset": 0,
  "length": 10,
  "realtime": false,
  "query_facets": [
    {"String16": {"field": "town", "prefix": "", "length": 10}},
    {"F32": {"field": "price", "range_type": "CountWithinRange",
      "ranges": [["0-10", 0], ["10-20", 10], ["20-50", 20], ["50-100", 50]]}},
    {"Timestamp": {"field": "date", "range_type": "CountWithinRange",
      "ranges": [["2024", 1704063600], ["2025", 1735689600]]}}
  ],
  "facet_filter": [
    {"String16": {"field": "town", "filter": ["Berlin", "Warsaw"]}},
    {"F32": {"field": "price", "filter": {"start": 0, "end": 20}}}
  ],
  "result_sort": [
    {"field": "price", "order": "Ascending", "base": "None"}
  ]
}
Rust client · seekstorm_client
use seekstorm::index::SearchRequestObject;
use seekstorm::search::{QueryFacet, FacetFilter, ResultSort, RangeType, SortOrder,
    FacetValue, QueryRewriting, QueryType, ResultType, SearchMode};

let query_facets = vec![
    QueryFacet::String16 { field: "town".into(), prefix: "".into(), length: 10 },
    QueryFacet::F32 {
        field: "price".into(),
        range_type: RangeType::CountWithinRange,
        ranges: vec![("0-10".into(), 0.0), ("10-20".into(), 10.0), ("20-50".into(), 20.0)],
    },
];

let facet_filter = vec![
    FacetFilter::String16 { field: "town".into(), filter: vec!["Berlin".into(), "Warsaw".into()] },
];

let result_sort = vec![
    ResultSort { field: "price".into(), order: SortOrder::Ascending, base: FacetValue::None },
];

let request = SearchRequestObject {
    query_string: "bm25".into(),
    query_vector: None,
    enable_empty_query: false,
    offset: 0,
    length: 10,
    result_type: ResultType::TopkCount,
    query_type_default: QueryType::Intersection,
    search_mode: SearchMode::Lexical,
    realtime: false,
    query_rewriting: QueryRewriting::SearchOnly,
    highlights: Vec::new(),
    fields: Vec::new(),
    field_filter: Vec::new(),
    distance_fields: Vec::new(),
    query_facets,
    facet_filter,
    result_sort,
};

let result = client.query_index(BASE_URL, API_KEY, index_id, request).await?;
println!("{}", serde_json::to_string_pretty(&result.facets).unwrap());

Index-wide facets: the dedicated "get index facets" call from earlier SeekStorm versions is gone — the same /query endpoint now returns index-wide facets when you pass an empty query string together with "enable_empty_query": true, which is also handy for index browsing, export, and audits.

Query facets response (SearchResultObject.facets)
{
  "town": [["Berlin", 41912], ["Warsaw", 8760], ["New York", 6531]],
  "price": [["0-10", 5210], ["10-20", 3040], ["20-50", 1874]]
}