Skip to content

Search

Full-text and vector search over a SearchSpec. It splits into a query port (run ranked searches) and a command port (maintain the index); the conceptual surface is Reading data → Searching.

q = ctx.search.query(spec)    # search
c = ctx.search.command(spec)  # index maintenance

ctx.search also exposes .hub(spec) and .federated(spec) (a query port over composed indexes, declared with HubSearchSpec / FederatedSearchSpec) and .snapshot(spec) for result-set snapshots.

Spec

SearchSpec[M] — the searchable model, its indexed fields, and ranking/encryption policy:

Field Type Default Meaning
name str \| StrEnum required logical name / index route
model_type type[M] required the searchable Pydantic model
fields Sequence[str] required indexed fields (non-empty, unique, never field-encrypted — the index would store ciphertext and content search would silently miss)
default_weights Mapping[str, float] \| None None per-field relevance weights
fuzzy SearchFuzzySpec \| None None fuzzy-matching configuration
default_sort QuerySortExpression \| None None sort when a caller omits sorts (required if the model has no id)
materialized frozenset[str] @computed_field names that are real columns on the search relation, so results can be filtered/sorted by the derived value (mirror of DocumentSpec.materialized; relational in-place only, not startup-validated)
facetable_fields frozenset[str] fields a query may compute term (value) facet distributions over (must be real, non-lenient, non-encrypted columns)
highlightable_fields frozenset[str] \| None None searchable fields a query may highlight; None = all searchable fields, = none
highlight_scan_limit int \| None None cap on the characters of a field scanned for in-process highlighting (relational search) — a match beyond the cap isn't highlighted; the hit is unaffected
max_results int \| None None server-side cap on an offset search with no caller limit (an explicit limit is honoured as-is) — guards fetching the whole matched set
read_conformity "strict" \| "lenient" "strict" "lenient" auto-derives lenient_read_fields (every statically-defaulted, non-identity, non-indexed, non-materialized field); explicit fields added on top
lenient_read_fields frozenset[str] returned read-model fields with no backing column: dropped from the result projection, hydrated from their default, and excluded from sort keys (mirror of DocumentSpec.lenient_read_fields; must not be an indexed fields member)
snapshot SearchResultSnapshotSpec \| None None result-ID snapshotting defaults (stable re-pagination)
encryption FieldEncryption \| None None field encryptionthe same policy as the document table's, so in-place search reproduces its AAD
sensitive bool False model carries secrets; generated surfaces refuse to project it
read_codec ModelCodec \| None None row codec override (auto-derived otherwise)

HubSearchSpec carries the same read_conformity / lenient_read_fields / materialized over its hub-row model (a hub has no index fields of its own). FederatedSearchSpec inherits these from each member spec.

materialized is for filtering and sorting search results by a derived value — the column must already exist (typically written by the document side over the same table). Returning a computed field needs no materialized: it recomputes from the row on decode.

Query port

Same search / project_search / select_search flavors and _page / _cursor containers as the document query port. The query text is the first argument; everything else mirrors the document side:

Method Result
search(query, filters=None, pagination=None, sorts=None, *, options=None, snapshot=None) CountlessPage[R]
search_page(...) Page[R] (with .count)
search_cursor(query, filters=None, cursor=None, sorts=None, *, options=None) CursorPage[R]
project_search / project_search_page / project_search_cursor (fields, query, …) pages of JsonDict
select_search / select_search_page / select_search_cursor (return_type, query, …) pages of T
search_stream / project_search_stream / select_search_stream (query, …, chunk_size=500) AsyncGenerator of chunks

query is a string (or a sequence of strings); filters and sorts use the query DSL. options: SearchOptions is the backend- and topology-agnostic per-request surface — relevance weights, fuzzy matching, a per-request fields narrowing, multi-term combination (phrase_combine: "any" / "all"), the count policy (search_count), an advisory candidate cap (max_candidates), and the facet / highlight requests below (facets, facet_size, highlight). Hub and federated searches resolve to a MultiSourceSearchOptions port that also carries member selection (member_weights / members), a post-merge cap (merge_candidates), and the fusion strategy ("rrf" / "weighted"); passing those keys to a single-index query(...) port is a type error.

Streaming exports

search_stream (and the project_ / select_ variants) iterate the whole matching set in bounded-memory keyset chunks — peak memory is one chunk, there is no total count. Use it to export a ranked result set without loading it all at once:

async for chunk in ctx.search.query(spec).search_stream("annual report", chunk_size=1000):
    await write_rows(chunk)

Streaming is capability-gated (spec's adapter must advertise SearchCapabilities.supports_stream): Postgres FTS / PGroonga and Mongo text / Atlas stream; Meilisearch (offset-only), the top-k vector engines, and hub search (each leg is capped at per_leg_limit, so a full walk isn't guaranteed) refuse rather than truncate. Pick the right export tool for the shape:

  • Ranked live exportsearch_stream. A concurrent write may shift a hit between chunks.
  • Filter-only export (no query terms) → the document port's find_stream — it's a plain keyset read with no ranking overhead (server-side cursor on Postgres).
  • Stable / point-in-time / Meilisearch / very deep → a result snapshot (build the ordered-id pool once, page it by id); works where a live cursor cannot.

Facets and highlights

A query requests term facet distributions with options={"facets": [...]} and per-hit match snippets with options={"highlight": True} (or a HighlightOptions mapping to narrow fields / customize the <em> markers), over the spec's facetable_fields / highlightable_fields. Results ride the page as optional page.facets (one set per query, over the full matching set) and page.highlights (per hit, index-aligned with hits), None when not requested. (Pages also carry optional per-hit relevance page.scores, index-aligned the same way.) A field or backend that cannot serve a request fails closed with query_feature_unsupported.

Support is per backend (single-index) and per topology (hub / federated). fail-closed raises query_feature_unsupported; means not applicable.

Backend / engine Facets Highlights
Mock
Meilisearch ✅ (facetDistribution) ✅ (_formatted)
Postgres — PGroonga
Postgres — FTS ✅ (ts_headline)
Postgres — vector
Mongo fail-closed fail-closed
Topology Facets Highlights
Hub — mock
Hub — Postgres ✅ (sql exec) / fail-closed (parallel)
Federated — RRF merge (mock, Postgres, Meilisearch) fail-closed ✅ (per hit)
Federated — Meilisearch native fail-closed fail-closed
  • PGroonga / hub highlights are marked in process (case-insensitive substring over the raw field text), so they fold case for any script and keep the original casing; FTS uses ts_headline for language-aware stemming.
  • Vector ranks by distance with no lexical match, so there is no snippet to highlight (facets still group over the ranked set).
  • Hub highlights apply in both sql and parallel execution; hub facets run as a companion GROUP BY over the merged set under sql execution and fail closed under parallel (the Python merge can't dedup-count the facet field).
  • Federated facets are deferred — per-member distributions don't compose under the reciprocal-rank merge, so every federated search fails closed on facets. Highlights survive only the RRF-merge path (each merged hit keeps its originating leg's snippet), not Meilisearch native federation.
  • Requesting facets or highlights bypasses result-snapshot replay (snapshots store hit ids only), so a sidecar request always runs against a live query.

Command port

Data-plane document writes — ctx.search.command(spec).

Method Signature Notes
upsert upsert(documents) add or update documents
upsert_many upsert_many(documents) batch add / update
delete delete(ids) remove by id

Management port

Control-plane index provisioning — ctx.search.management(spec). Kept separate from the command port (provisioning mutates shared topology / wipes are destructive admin, run outside the request path); acquired via the command path, so a read-only operation cannot provision or wipe an index.

Method Signature Notes
ensure_index ensure_index() create / update the index settings
delete_all delete_all() empty the index

Implemented by

Backend Mode Integration
Meilisearch external index Meilisearch
Postgres in-place (FTS + pgvector) over the document table Postgres
Mongo in-place over the document collection Mongo

An external index seals encrypted fields in the index document; in-place search decrypts out of the document table's results — which is why the encryption policy must be shared with the DocumentSpec.