Metadata-Version: 2.4
Name: stapel-search
Version: 0.2.0
Summary: Search index, facets, geo-radius and ranking for the Stapel framework
License: MIT
Project-URL: Homepage, https://github.com/usestapel/stapel-search
Project-URL: Repository, https://github.com/usestapel/stapel-search
Project-URL: Documentation, https://github.com/usestapel/stapel-search#readme
Project-URL: Changelog, https://github.com/usestapel/stapel-search/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/usestapel/stapel-search/issues
Keywords: django,stapel,search,facets,postgres,meilisearch
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: stapel-core<1.0,>=0.31.0
Requires-Dist: stapel-attributes<1.0,>=0.4.5
Requires-Dist: stapel-geo<1.0,>=0.3.5
Requires-Dist: djangorestframework>=3.14
Requires-Dist: drf-spectacular>=0.27
Provides-Extra: meili
Requires-Dist: meilisearch>=0.31; extra == "meili"
Provides-Extra: all
Requires-Dist: stapel-search[meili]; extra == "all"
Dynamic: license-file

<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->

# stapel-search

[![CI](https://img.shields.io/github/actions/workflow/status/usestapel/stapel-search/ci.yml?branch=main&logo=github&label=CI)](https://github.com/usestapel/stapel-search/actions/workflows/ci.yml?query=branch%3Amain)
[![coverage](https://img.shields.io/codecov/c/github/usestapel/stapel-search?branch=main&logo=codecov&label=coverage)](https://app.codecov.io/gh/usestapel/stapel-search)
[![pypi](https://img.shields.io/pypi/v/stapel-search?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-search/)
[![downloads](https://static.pepy.tech/badge/stapel-search/month)](https://pepy.tech/project/stapel-search)
[![python](https://img.shields.io/pypi/pyversions/stapel-search?logo=python&logoColor=white)](https://pypi.org/project/stapel-search/)
[![license](https://img.shields.io/github/license/usestapel/stapel-search)](https://github.com/usestapel/stapel-search/blob/main/LICENSE)
[![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-search/blob/main/docs/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](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.

## Install

```bash
pip install stapel-search
```

## At a glance

| Fact | Value |
|---|---|
| Version | `0.2.0` |
| Python | `>=3.11` (3.11, 3.12, 3.13) |
| Django | `djangorestframework>=3.14` |
| HTTP operations | 5 |
| Config axes | 8 |
| Usage surface | 39 |
| Extension points | 6 |
| Error codes | 54 |
| Fleet dependencies | [`stapel-attributes`](https://github.com/usestapel/stapel-attributes) · [`stapel-categories`](https://github.com/usestapel/stapel-categories) (optional) · [`stapel-core`](https://github.com/usestapel/stapel-core) · [`stapel-geo`](https://github.com/usestapel/stapel-geo) · [`stapel-listings`](https://github.com/usestapel/stapel-listings) (optional) |

## Documentation

[OpenAPI](https://github.com/usestapel/stapel-search/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-search/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-search/blob/main/docs/llms.txt)

## 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](https://github.com/usestapel/stapel-search/blob/main/LICENSE).

---

<sub>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`.</sub>
