ADR 0002: Search stack and evaluation
Hybrid retrieval on PostgreSQL with a public evaluation harness.
Status: accepted internally · 2026-08-08
Context
The Bazaar discovery layer must serve GET /discovery/resources (structured filters) and
GET /discovery/search (natural-language queries, cursor pagination, partialResults).
The corpus is small - the entire global Bazaar index is ~14,000 short, structured entries
today - so ranking quality, not scale, is the engineering problem. Queries are
agent-issued intents ("weather API that takes USDC on stellar"), which mix topical
matching with hard structured constraints.
Decision
Store: one PostgreSQL instance holds the catalog, both discovery endpoints, and both
retrieval arms - pgvector for embeddings, a true BM25 extension for lexical search
(plain tsvector ranking lacks IDF and misranks tag-heavy short documents). At this
corpus size, exact vector scan is used deliberately - an ANN index adds complexity for
zero recall gain.
Retrieval: two arms in parallel - lexical (BM25 over name/tags/descriptions/parameter descriptions) and semantic (a local, permissively-licensed embedding model, run in-process so the self-hosted path has no external dependency). Results merge with reciprocal rank fusion, then query-derived structured constraints apply: a network named in the query is a hard filter, asset/price mentions become boosts. At index time we also generate synthetic agent-intent queries per resource and embed those alongside the metadata - published tool-retrieval results show schema vocabulary and query vocabulary diverge, and indexing synthetic queries closes that gap cheaply.
Ranking signal: settlement history. A resource recently seen in successful settled payments ranks above an untested listing, so agents can tell proven endpoints from stale ones. No Stellar catalog surfaces settlement history today; facilitator integration makes it cheap and real-time rather than requiring a separate chain-indexing pipeline.
Evaluation - the part that is a deliverable, not a detail:
- a versioned golden-query set (target 100-200 queries) with graded relevance judgments, built by pooling results from all rankers and judged with a documented rubric;
- LLM-assisted labeling with a tracked human audit sample (judge model ≠ generation model);
- nDCG@10, MRR, and Recall@20 run in CI - ranking changes ship only if they do not significantly regress, measured against a held-out split;
- the harness lives in
packages/eval-harness, public, so the quality claims are reproducible by anyone; - online signal: an agent's actual paid call is the strongest relevance label there is, and it is logged (rank of the invoked result) with caller identity excluded.
Degraded mode maps to the spec: if the semantic arm is down, serve lexical-only with
partialResults: true; verify/settle never depend on the index.
Alternatives considered
- Elasticsearch - rejected: license family (AGPL/SSPL/Elastic) is incompatible with this project's no-copyleft requirement.
- Dedicated vector database - rejected: a second stateful service for a corpus that fits in RAM.
- Managed embedding/search APIs - rejected: breaks the self-hosted and self-facilitation paths and inserts a vendor into the trust story.
- Cross-encoder reranking, tuned fusion weights - deferred, data-triggered: added only if the eval harness shows top-rank precision problems fusion cannot fix.
Consequences
- Infrastructure stays boring: one Postgres (primary + replica), the facilitator service, in-process embedding. Cheap to run, easy to self-host, honest 99%+ availability story.
- Every future ranking claim this project makes is backed by a public, re-runnable number.
Implementation notes (2026-08-12) - what was actually built, and where it differs
This ADR described the target; this section records what landed and the honest gaps, rather than quietly editing the decision above to match reality after the fact.
- BM25 "extension" → BM25 in application code. No Postgres BM25 extension (e.g.
ParadeDB's
pg_search) was installed.packages/discovery/src/search/bm25.tsimplements real BM25F (Robertson/Zaragoza, named k1/b/field-weight constants) in TypeScript; Postgres'stsvectorstill does tokenization/stemming, andts_statsupplies corpus-wide document frequency. Functionally equivalent for this ADR's purpose (real IDF-aware ranking instead of barets_rank), just not via an extension. vector, notvector(n). pgvector's embeddings column is deliberately dimension- unconstrained. A fixed-widthvector(n)can't hold more than one embedding dimension, but the generation-versioning design (this ADR's own "old and new generations never compared") requires multiple dimensions to coexist across a model swap. Every real query scopes bygeneration_idfirst, so any two vectors actually compared always share a dimension by construction.- Structured constraints: network is a real, tested hard filter. Asset and price are a lighter-weight v1 (a small known-asset vocabulary; a simple "under $N" pattern comparing the raw stored amount, not currency/unit-normalized) - present and honestly scoped, not as rigorously covered as network.
- Synthetic queries are template-based, not LLM-authored. No query-generation LLM was available in the environment this was built in. A documented, fixed set of natural- language-ish questions is generated per resource from its own metadata. This is the piece most worth upgrading once LLM access exists for it - same gap as the judging side.
- Eval harness is v1-scoped, not yet at target. 15 queries (not 100-200) against an
18-resource seed catalog (not the ~14,000-entry production index), judged in a single
pass by the same model that built the harness (no separate judge model, no human-audit
sample). Real infrastructure, real numbers, honestly smaller scope - see
/docs/benchmarksfor the current numbers. - Online signal (agent's actual paid call as a relevance label) is not built. Nothing yet logs the rank of an invoked result. Deferred, not attempted.