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, on offset and cursor pages alike (required if the model has no id)
filter_limits QueryFilterLimits \| None None bounds on the filters a search accepts; None keeps the defaults (see Limits). HubSearchSpec takes its own, applied once to the hub rows; members' limits don't apply to a hub filter
default_search_count "exact" \| "approximate" \| "none" \| None None how a page counts its total when the request sets no search_count; None keeps exact. Postgres honours it; other backends report totals their own way. HubSearchSpec takes its own for the hub's page (members' don't apply to it); FederatedSearchSpec has none
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 encryption — the same policy as the document table's, so in-place search reproduces its AAD
sealed_by str \| None None in-place search only: the document spec whose port sealed the table's fields, so the read codec binds its name in the AAD. HubSearchSpec takes its own, which governs its hub rows (a member's must be unset or equal to it); Meilisearch and index sync/rebuild refuse it (an index seals its own)
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), and takes extra_aggregatable_fields: hub-relation columns outside that model an aggregate may group by or measure (see Aggregating matches). FederatedSearchSpec inherits these from each member spec. A federated search merges thin by default (thin_merge=True): each member returns its ids (and the fields of any requested sort), and just the page is re-read from its member. thin_merge=False fuses full hits in memory instead.

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
aggregate_search / aggregate_search_page (aggregates, query, filters=None, pagination=None, sorts=None, *, options=None) CountlessPage / Page of JsonDict groups

query is a string (or a sequence of strings); filters and sorts use the query DSL. A page from a single index is ordered by relevance, then by sorts (or the spec's default_sort when the request has none), then by id, in the sort's direction when it has a single one. A sort that names id ends there. A blank query has no relevance, so the sort alone orders it, and the id gives rows that tie on every other key one place, so offset pages neither repeat nor skip them. Meilisearch is the exception for a query with search text: it sorts by the request's sorts only, and leaves relevance ties in the engine's order. 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), the facet / highlight requests below (facets, facet_size, highlight), and a precomputed query_vector. A query_vector ranks a vector index (pgvector, Atlas Vector Search) in place of embedding the query. It must come from the same embedding model as the indexed documents, its length must match the index's dimensions, and query must be blank; None reads as no vector. Every other backend, and hub and federated search, refuse it with query_feature_unsupported. 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. A federated search also takes member_filters: a filter per member name, joined with $and to filters on that member's leg alone. A hub refuses it, since a hub filter applies to the hub rows. A key neither type defines is refused as a precondition naming it, at a request and at the port alike.

An authorization hook on a hub or federated search operation authorizes the operation, not each member it reads. Attach AuthzSearchMembers to check every member against its own read action. It binds a SearchMemberGrant, which every hub and federated adapter applies where it reads its options, after any request mapper. The registry refuses to freeze such an operation without the hook (see Enforce on operations).

A sort on a field the read model lacks is refused as the caller's error, a key after id included. On Postgres, a ranked page reads a capped pool of candidates (max_candidates, or the route's candidate_limit; on a hub, its merge cap), always widened to cover the page plus a margin of 50 rows (offset + limit + 50). The pool is the first rows of the page order itself, so a capped offset page holds the rows the uncapped cursor puts there; a cursor walk is uncapped, except on a vector index, whose cap bounds it too. Where FTS or a PGroonga filter_first plan applies its filters before the cap, the pool is cut to exactly offset + limit rows (the spec's max_results stands in for a missing limit), since the page cannot reach further. That does not happen when the page writes a snapshot or asks for an approximate total, which counts the pool. A vector search keeps its cap as its top-k bound.

Page totals

On Postgres, search_count (or the spec's default_search_count) picks how a _page call counts its total:

Policy Total
exact COUNT(*) over every match (the default)
approximate blank query: the planner's estimate of the rows the filters match, which can be off by a multiple and is never 0. With search text: the capped candidates the page ranks — exact while the matches fit the cap, the cap above it, zero for no match
none no total

The planner can't estimate a text match: it gives a PGroonga predicate the same estimate whatever the query. A hub's cap is its merged-rows cap (combo_limit, or per_leg_limit by default), and each leg brings at most per_leg_limit rows, so a leg cut there makes the approximate total a lower bound. The cap widens with a deeper page, so that page's approximate total can be larger, never past the exact one. A hub usually counts it in the page's own query, so it costs about what none does; exact runs the whole match again.

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 export → search_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.

Aggregating matches

aggregate_search groups and measures the rows a search matches, with the same AggregatesExpression ($groups, $computed, $having) the document port's aggregate_many takes over the rows a filter matches:

page = await ctx.search.query(spec).aggregate_search_page(
    {"$groups": {"status": "status"}, "$computed": {"n": {"$count": None}, "total": {"$sum": "amount"}}},
    "overdue invoice",
)

It reads the rows an exact page total counts: every match, never a candidate cap, or for a blank query the rows the page counts by its filters alone. sorts name output aliases, and the group keys order the groups after them, so pages of groups neither repeat nor skip one; without a limit every group comes back, as aggregate_many returns them. aggregate_search_page adds the number of groups, after $having. Groups, measures and per-metric filters may name only the spec's aggregatable_fields — stored, not field-encrypted — and the facets and highlight options are refused, as they describe hits, as are max_candidates and a hub's merge_candidates, which would cut the set.

A hub aggregates its merged rows with every leg uncapped: per_leg_limit and the merge cap bound a page, not an aggregate, so the groups add up to the exact page total. Its measures often live in columns of the hub relation that its read model leaves out, such as a view's computed totals; name them in HubSearchSpec.extra_aggregatable_fields and an aggregate may group by, measure and metric-filter on them too. Postgres checks they exist on the first aggregate. The search's own filters stay on the read model. A query that reaches a vector leg is refused, since an uncapped vector leg matches every embedded row.

An aggregate names an extra column by its own value: a dotted path into one, in a group, a measure or a per-metric filter, is refused, as is a filter's element quantifier over one. Postgres checks a metric filter's operators against the column's type, as it checks a search filter against the read model, so $null, ranges and $in work on a numeric column, and $like or $empty on one is refused. A declared precision (numeric(12,2)) keeps the type, and a column of a type with no value rule (jsonb, time, …) takes only $null. The in-memory hub types an extra column by the values its matched rows hold, so a string range bound casts there as it does on Postgres:

await hub.aggregate_search(
    {
        "$computed": {
            "total": {"$sum": "upd_or_quantity"},
            "missing": {"$count": {"filter": {"$values": {"upd_or_quantity": {"$null": True}}}}},
        }
    },
    "catheter",
)

Aggregation is capability-gated (SearchCapabilities.supports_aggregates): Postgres FTS / PGroonga, hub search (Postgres and in-memory) and the in-memory adapter aggregate; vector search (every row is some embedding's neighbour), federated search, Mongo and Meilisearch refuse with query_feature_unsupported rather than measure a partial set.

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; on pgvector a score is the negated distance, so higher is nearer.) 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; idempotent
delete_all delete_all() empty the index; a no-op if it was never provisioned

Both are postconditions rather than operations on an existing thing, so both are safe to repeat and safe to call first: delete_all() on a surface nothing has created yet succeeds, because an index that does not exist already holds no documents.

Rebuilding an index

An external index is kept in step by the index-sync bindings (AggregateKit(search=…), or bind_search_sync / bind_search_sync_outbox directly), and those are incremental: they carry a row into the index when that row is written. Nothing carries a row that was never written since the index existed. So an aggregate that gained search=… after it already held rows, an index provisioned fresh, one restored onto new infrastructure, and one that drifted while its sync was broken all end up the same way — correct-looking, and empty or stale for every untouched row.

rebuild_search_index is the backfill. It streams the document plane and applies each row to the index under the same rule the incremental syncs apply, so a rebuilt index holds exactly what an unbroken sync would have produced:

from forze_kits.integrations.search import rebuild_search_index

report = await rebuild_search_index(
    ctx.doc.query(CUSTOMER_SPEC),
    ctx.search.command(CUSTOMER_INDEX),
    document=CUSTOMER_SPEC,
    search=CUSTOMER_INDEX,
)
report.indexed, report.removed, report.scanned   # live rows, soft-deleted rows, total

An AggregateKit already holds both specs, so it exposes the same sweep as await kit.rebuild_search(ctx).

Keyset-paged, so memory is bounded by chunk_size whatever the collection's size; idempotent, so an interrupted sweep is re-run rather than repaired. A soft-deleted row is removed from the index rather than upserted — a sweep that merely upserted everything it read would resurrect every soft-deleted row as a hit that GET then 404s. Run it once per tenant (under bind_identity(tenant=…)) on a tenant-aware route.

Source-driven: it converges the index, it does not replace it

Every row the document plane still holds ends up correct. An id the document plane no longer holds at all — hard-deleted while the index kept it — is invisible to a sweep that reads only the source, and survives it. Where that matters (an index of unknown provenance, rather than a fresh one), delete_all() first and rebuild into the empty index. That is a deliberate two-step: the wipe leaves search returning nothing until the sweep finishes, and that outage is yours to choose.

For an exact result, sweep a source nothing is writing — a fresh import, or a quiesced runtime. Against live traffic the sweep is best-effort: a row hard-deleted between being read and being upserted comes back as a ghost. On an aggregate whose sync is working this converges anyway, because the sync is applying the same rule to those same writes.

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.