Skip to main content

stapel-search

CI coverage pypi downloads python license llms.txt

A materialized search index with one swappable engine seam: full-text with per-language configs and a typo-tolerant second arm, drill-down facet counts with a benchmark-calibrated candidate cap and an approximate fallback, indexed range filters over a numeric side table, geo radius and bounding-box search including the antimeridian, five sorts with an opaque keyset cursor whose envelope matches AnchorPagination, a registry-generated P2B Art. 5 ranking disclosure with a mandatory promoted marker on every result, and an event-to-pull indexer carrying the four Projection guarantees (idempotency, ordering, rebuild, drift_check). Ships three engines behind the same conformance suite: Postgres FTS (default, zero new infrastructure), Meilisearch (the [meili] extra) and a declared naive backend for SQLite demos.

Part of the Stapel framework — composable Django apps that deploy as a monolith or as microservices without changing module code.

Install

pip install stapel-search

At a glance

Fact Value
Version 0.16.5
Python >=3.11 (3.11, 3.12, 3.13)
Django djangorestframework>=3.14
HTTP operations 5
Config axes 8
Usage surface 42
Extension points 6
Error codes 55
Fleet dependencies stapel-attributes · stapel-categories (optional) · stapel-core · stapel-geo · stapel-listings (optional)

Documentation

OpenAPI · capabilities.json · llms.txt (for agents)

What this is

stapel-search is the fleet's search module: a materialized index table it owns, one dotted-path backend seam, and a hard rule that every indexed field has a source, a read path and a test.

It knows nothing about listings. Documents arrive through an open source registry that starts empty, and the composite that is allowed to know both the corpus and the index declares the entry. That is why the same module indexes a catalogue, a chat archive or a profile directory without a fork.

Why the index is its own table

Projection's local mode has, by definition, no table and only a keyed batch read. Search answers "find the matching ones", not "give me fields for these keys" — there is no tsvector, no GIN and no facet aggregate to build on a keyed lookup. So the index is a real table in both topologies, and all four Projection guarantees are borrowed by name instead: idempotency on redelivery, ordering by sequence, rebuild from the owner's snapshot, and drift_check.

An event is a signal, not a document. The listing.* payloads are additionalProperties: false and carry identity only, so the indexer pulls the document through a comm Function — in-process in a monolith, over the bus in a split, the same call() either way.

Three engines, one conformance suite

engine typo tolerance facet counts exact total geo synonyms
postgres (default) pg_trgm, second arm exact to the cap, then sampled exact to the cap, then a floor geohash prefilter + haversine query expansion
meili ([meili] extra) native exact exact, a floor past the window native _geoRadius native
naive (tests, SQLite demos) none, declared exact exact python haversine query expansion
opensearch a pointer, not a promise

Differences are never hidden. Each backend declares BackendCapabilities, and every response carries degraded: [...] naming what this engine could not do for this query.

The count is one of those differences, and it says which one it is. count is nullable, count_is_lower_bound marks a floor ("at least N", rendered N+), and exact_total describes THIS answer rather than the engine class — a Postgres candidate set below the cap is counted exactly, and saying otherwise teaches a frontend to distrust a number that is right. The invariant the service enforces for every backend: the answer may never claim fewer matches than the page shows, so count: 0 beside a non-empty items[] is unreachable. Unknown is spelled null, never 0. stapel_search.testing exposes the suite publicly: a new backend without a green conformance run does not merge, and a scenario may only be skipped when the matching capability is False.

The gate: declared ⇒ wired ⇒ covered

The legacy this replaces died in one specific way — features_search, description_en and geohash were written, half-indexed, and read by no query, for years. Three mechanical layers stop that here:

  1. index_schema.py::INDEX_FIELDS is the contract as data, emitted to docs/index.json. The dataclass refuses a field with no read path and no test, so nothing can be declared "for later".
  2. tests/test_index_contract.py runs a round trip per field, against every configured engine, with a mandatory negative half — without one, "finds everything" passes.
  3. stapel-index-lint (in stapel-tools, composed into stapel-verify) enforces the same rules statically across the fleet.

The boundary is stated as plainly as SUR004 states its own: these gates prove the promise was not dropped on the floor, not that the branch is right. Only the round-trip assertions do that.

Ranking is disclosed from the code that ranks

GET /search/api/v1/ranking and docs/ranking.json are rendered from the scorer registry, under the same drift gate as every other artifact — so the P2B Art. 5 disclosure cannot drift from the behaviour the way a paragraph copied into terms of service always does. promoted is serialized on every result item under every sort, including when false (DSA Art. 26), and an explicit sort receives no promotional boost: not a setting, but a structural property of the registry, since promotion_boost declares relevance and nothing else.

Numbers that came from a benchmark, not a feeling

FACET_CANDIDATE_CAP = 15000. Counting remaining facet options is a scan of the candidate set, and the measured curve (tasks/search-facet-benchmark.md) says a single connection stays comfortable to ~130k candidates while eight concurrent clients breach the 200ms target at ~31k. The cap is set with room for concurrency, the TABLESAMPLE fallback is live from day one rather than "when needed", and MAX_FACET_FIELDS = 12 keeps a wide category page from becoming a dozen sequential scans.

License

MIT — see LICENSE.


This page is assembled by stapel-readme from docs/readme.md plus the contract artifacts in docs/. Edit the prose in docs/readme.md; the badges, facts and links above and below it are generated — do not hand-edit README.md.

Metadata

Release files for stapel-search 0.16.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for stapel-search 0.16.5
File Size Uploaded
stapel_search-0.16.5.tar.gz 384.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for stapel-search 0.16.5
File Interpreter ABI Platform
stapel_search-0.16.5-py3-none-any.whl Python 3 none any Details

Total release size: 701.0 kB

Release files / stapel_search-0.16.5.tar.gz

Download URL stapel_search-0.16.5.tar.gz
Size 384.9 kB
Tags Source
SHA-256 checksum
How to use checksums
e62946a606c4835d4f09ee3c2c1725098c5b48d810131400bfa89792fe9e6d57
BLAKE2b-256 checksum
How to use checksums
dc540d397acff76b4f01bda95f3e34e9f2741a41800f75a9eb534a3fdab6030e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 11, 2026.

Transparency log

Release files / stapel_search-0.16.5-py3-none-any.whl

Download URL stapel_search-0.16.5-py3-none-any.whl
Size 316.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9491aab0115f2578a702df2af6738fb69c51f952685c73998878ea9b01eaecef
BLAKE2b-256 checksum
How to use checksums
d927dfca8231a0f2f1d83d21911e970e674a0f69ad485224b67d67d22b53872f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 11, 2026.

Transparency log

Release history Release notifications | RSS feed

0.18.1

2 release files

0.18.0

2 release files

0.17.2

2 release files

0.17.1

2 release files

0.17.0

2 release files

0.16.8

2 release files

0.16.7

2 release files

0.16.6

2 release files

This release

0.16.5 This release

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page