Ask Sunny's production retrieval target is hybrid search across the backend's three source families:
- Directorist listings.
- Optional approved Directorist reviews, classified by their parent directory type.
- Optional WordPress post types enabled by an administrator.
Each eligible query combines:
- ParadeDB
pg_searchBM25 over deterministic source text (listings.embedding_textfor listings andsearch_documentfor the other kinds). - pgvector cosine similarity over that source kind's vector storage (
listings.embeddingfor listings). - Structured filters applied consistently to both candidate paths.
- Weighted Reciprocal Rank Fusion (RRF) followed by application ranking.
Hybrid retrieval is the intended normal production mode. A new or upgraded deployment must start with hybrid disabled, however, and may enable it only after the package-compatibility and verification gates in this plan pass.
- Recover exact names, categories, locations, amenities, metadata values, and site-specific terms through BM25.
- Recover conceptually related content and natural-language intent through vector similarity.
- Search only persisted
allowed_data_source_keysand apply status and structured constraints before fusion. - Keep listing, review, and WordPress-content repositories separate while producing one comparable ranked result set.
- Preserve stable source IDs, URLs, source classification, and evidence for grounded chat citations.
- Continue safely in explicit vector-only mode when BM25 cannot be enabled.
- Query rewriting by an LLM.
- Neural reranking.
- Inferring hidden filters that are not supported by retrieved content or configured metadata.
- Allowing a chat caller or model tool call to expand the backend's persisted source allowlist.
- Returning review records as listing recommendation cards; reviews can support and be cited for their parent listing.
HYBRID_SEARCH_ENABLED must remain false until the installed pg_search artifact is proven to match the PostgreSQL process that will load it.
Record and compare all of the following:
| Compatibility field | PostgreSQL host/container evidence | Package/image evidence | Required result |
|---|---|---|---|
| PostgreSQL major | SHOW server_version_num |
Package name/dependency or pinned database image manifest | Exact major match |
| Operating system | /etc/os-release ID and release/codename |
Package release target or database image base | Supported exact target |
| CPU architecture | dpkg --print-architecture or uname -m |
Package metadata/image platform | Exact compatible architecture |
pg_search version |
Requested release | Installed package and pg_extension.extversion |
Pinned and recorded |
For a native deployment, the operating system is the host running PostgreSQL. For Docker, it is the operating system and architecture inside the database container, not merely the Docker host. The PostgreSQL major version must also be read from the running container rather than inferred from an image tag.
Native evidence commands:
psql -d ask_sunny -tAc "SHOW server_version;"
psql -d ask_sunny -tAc "SHOW server_version_num;"
cat /etc/os-release
dpkg --print-architecture || uname -m
dpkg-query -W 'postgresql-*-pg-search' 2>/dev/null || trueBefore installing a downloaded Debian package, inspect rather than trust its filename:
dpkg-deb -f /tmp/pg_search.deb Package Version Architecture DependsDocker evidence commands:
docker compose exec paradedb psql -U ask_sunny -d ask_sunny -tAc "SHOW server_version_num;"
docker compose exec paradedb sh -lc 'cat /etc/os-release; dpkg --print-architecture 2>/dev/null || uname -m'
docker compose exec paradedb sh -lc "dpkg-query -W 'postgresql-*-pg-search' 2>/dev/null || true"
docker compose images --digests paradedbStop the hybrid rollout if any field is missing, ambiguous, unsupported, or mismatched. Do not create the BM25 indexes, mark hybrid ready, or issue BM25 traffic. Keep HYBRID_SEARCH_ENABLED=false, retain vector-only retrieval, and report the detected values, expected values, and blocker. Never force-install a mismatched package.
Use this order for native and Docker deployments:
- Inspect the deployment state and preserve existing local changes and secrets.
- Back up PostgreSQL and verify that the backup exists and is non-empty.
- Stop application code that is incompatible with pending migrations.
- Set the safe rollout value
HYBRID_SEARCH_ENABLED=false. - Collect the compatibility evidence from Section 3.
- Install or select the pinned
pg_searchpackage/image only after every field matches. - Preserve existing
shared_preload_libraries, addpg_searchwhen required, and restart PostgreSQL. - Create and verify
vector,pg_search, andpgcryptowith an extension-capable role. - Run the versioned application migrations that add search keys and all three BM25 indexes.
- Run
ANALYZEon the content and vector-storage tables after a large import or index build. - Run a direct BM25 query against listings, reviews, and WordPress content using a known local term.
- Run application checks and a scoped hybrid integration test.
- Set
HYBRID_SEARCH_ENABLED=trueonly when every preceding gate passes. - Start or restart the API, confirm effective hybrid readiness, and record the final state.
The environment flag is an operator request, not proof of capability. Startup/readiness must compute an effective search mode from the flag plus extension, migration, index, and smoke-check results. If the flag is true while a requirement is missing, the application must refuse hybrid execution and report a degraded or not-ready state; it may continue serving explicit vector-only retrieval.
BM25 and vector retrieval run separately for each source kind:
| Source kind | BM25 table/index | Vector storage | Required scope |
|---|---|---|---|
| Directorist listing | listings / listings_bm25_idx |
listings.embedding |
Allowed directory key, deleted_at IS NULL, listing filters |
| Directorist review | directorist_reviews / directorist_reviews_bm25_idx |
directorist_review_embeddings |
Allowed classified review key, active status, parent context |
| WordPress post | wordpress_content / wordpress_content_bm25_idx |
wordpress_content_embeddings |
Allowed post-type key, active status, taxonomy/meta filters |
The same allowed keys and structured predicates must constrain both BM25 and vector candidates. Applying a filter to only one branch makes fusion incorrect and can leak disabled content into the candidate set.
For each normalized query:
- Load
installation_config.allowed_data_source_keys; an empty list fails closed. - Intersect model-selected keys with the persisted list.
- Extract validated structured constraints and build kind-specific predicates.
- Generate or load the query embedding.
- Retrieve bounded vector candidates that meet the configured similarity threshold.
- Retrieve bounded BM25 candidates for the exact normalized keyword query.
- Convert each path to deterministic ranks using score followed by a stable ID tie-breaker.
- Fuse candidates with weighted RRF.
- Apply final relevance, structured-match, date, location, configured metadata, review, freshness, and eligible promotion signals.
- Attach source identity, URLs, matched evidence, and diagnostics for downstream citations.
RRF avoids blending incomparable raw BM25 and cosine score ranges:
fused_score = vector_weight / (rrf_k + vector_rank)
+ bm25_weight / (rrf_k + bm25_rank)
Initial settings:
HYBRID_SEARCH_ENABLED=false
HYBRID_VECTOR_WEIGHT=0.65
HYBRID_BM25_WEIGHT=0.35
HYBRID_RRF_K=60
HYBRID_CANDIDATE_MULTIPLIER=3
HYBRID_MAX_CANDIDATE_LIMIT=100
HYBRID_VECTOR_MIN_SIMILARITY=0.25
MAX_ALLOWED_SEARCH_IDS=1000
MAX_RETRIEVAL_RESULTS=12The first value is the safe installation and upgrade value. A verified normal production deployment changes it to true. Weight changes require evaluation and a ranking-version bump so cached or historical results are not confused with a new policy.
The application accepts weights from 0 through 1, requires at least one positive weight, and
normalizes them by their sum before fusion. HYBRID_RRF_K accepts 1..10000, the multiplier accepts
1..20, the maximum candidate limit accepts 1..1000, minimum cosine similarity accepts 0..1,
MAX_ALLOWED_SEARCH_IDS accepts 1..10000, and MAX_RETRIEVAL_RESULTS accepts 1..100. For a requested result limit n, each branch receives
min(HYBRID_MAX_CANDIDATE_LIMIT, n * HYBRID_CANDIDATE_MULTIPLIER) candidates. A request cannot
exceed MAX_RETRIEVAL_RESULTS.
SV-US-008 exposes an application boundary, not a new public HTTP route. Its input contains a
non-empty normalized query, optional requested data_source_keys, optional filters, and optional
limit. The policy accessor intersects requested keys with the persisted allowlist before any
repository is called. The filter object may contain:
categories,locations, andamenities: non-empty bounded arrays of normalized strings; matching is case-insensitive exact membership.price: optional finite non-negativemin/maxand an optional three-letter upper-case currency.rating_min: a finite value from0through5.taxonomies: a bounded map of stable taxonomy keys to non-empty string arrays.metadata: a bounded array of{key, operator, value}entries. Operators areeq,in,gte, andlte; values are bounded public primitives or primitive arrays appropriate to the operator.date:{key, from, to, timezone}wherekeyis a permitted date/datetime metadata field,fromandtoare valid RFC 3339 instants with explicit offsets andfrom <= to, andtimezoneis a valid IANA zone. Comparisons use the represented instants; the zone records the user's local interpretation and is not used to reinterpret an already offset timestamp.distance:{latitude, longitude, radius_miles}with latitude-90..90, longitude-180..180, and a finite positive radius no greater than 500 miles. Distance applies to listing coordinates and to reviews through their active parent listing.
Arrays/maps use the existing metadata count/string bounds. Unknown keys, operators, shapes, NaN or
infinite numbers, reversed ranges, and invalid zones/instants reject the entire retrieval input before
embedding or SQL. Core categories, locations, amenities, price, rating, coordinates, and public
taxonomy fields are filterable. A generic metadata/date key is permitted only when that source's
stored data_sources.context_metadata.retrieval_filter_keys contains the stable key. SQL identifiers
are never derived from these keys: repositories select a server-owned JSON column/path and pass the
validated key/value as parameters. A source that cannot apply a requested constraint does not match;
the constraint is never silently dropped from one branch.
Each source repository applies the identical active-row, non-empty safe URL, allowlist, and structured
predicate builder to its BM25 and vector statements. BM25 uses the exact normalized query and returns
the raw non-negative pdb.score; vector results use cosine similarity
1 - (embedding <=> query_embedding) and apply the configured threshold before ranking. Branches
sort score descending and then the stable tuple (source_kind, data_source_key, source_id) ascending.
ParadeDB requires pdb.score to run in its supported simple search shape. Each source BM25 repository
therefore first selects only (search_key, score) for the exact query, ordered by score and bounded
by a validated integer MAX_ALLOWED_SEARCH_IDS literal (ParadeDB does not support a bind parameter
or secondary key expression in this score-query shape). It passes those keys/scores as typed array parameters to a second
hydration statement that applies the persisted allowlist, active/URL rules, and the same structured
predicate builder used by vector search. Only rows surviving hydration are candidates; the repository
then applies the branch candidate limit. The raw key stage returns no content and cannot bypass the
filtered hydration boundary. Reaching the search-ID cap is reported in safe diagnostics so operators
can evaluate recall without logging the query or identities.
Hydrated rows receive the stable source-identity tie-breaker before fusion; the simple score query is
the only ParadeDB-specific exception to applying that tie-breaker inside SQL.
The common candidate contains source_kind, data_source_key, data_source_label, source_id,
title, url, result_role, compact public matched_metadata, and branch score/rank. Review
candidates use result_role=review_evidence and additionally contain their active parent listing's
source key, source ID, title, and URL. Both the classified review key and its parent listing key must
be allowed. A review is never represented as a listing/recommendation card.
Fusion deduplicates by the stable source tuple and merges the two branch contributions. It computes
weighted RRF with normalized weights, then divides the raw fused value by the maximum possible value
(normalized_vector_weight + normalized_bm25_weight) / (rrf_k + 1) to expose fused_score in
0..1. Results sort by fused score descending and the stable tuple ascending, and are truncated to
the requested result limit. The response reports mode=hybrid|vector_only, a nullable
degradation_reason, the allowlist version, bounded results, and safe branch/fused counts and
latencies. Raw query text, embeddings, SQL, and private/raw payloads are never returned.
Detail lookup is a separate application boundary accepting one source key and source ID. It uses the
same policy accessor and active-row/URL/parent rules, returns the candidate's public identity and
compact evidence without scores, raw payloads, or vectors, and returns null for missing, inactive,
URL-less, or disallowed content. It has no public HTTP route in this story.
flowchart TD
Query[Normalized query and validated filters] --> Allowlist[Load persisted allowed source keys]
Allowlist --> Intersect[Intersect requested and allowed keys]
Intersect --> Embed[Generate or load query embedding]
Embed --> Vector[Scoped pgvector candidates]
Intersect --> BM25[Scoped ParadeDB BM25 candidates]
Vector --> Threshold[Apply vector threshold]
Threshold --> Fusion[Weighted reciprocal-rank fusion]
BM25 --> Fusion
Fusion --> Rank[Final relevance and policy ranking]
Rank --> Evidence[Attach source evidence and URLs]
Evidence --> Results[Return compact retrieval results]
- Package, OS, architecture, or PostgreSQL-major mismatch: do not install or enable; use vector-only mode.
pg_searchmissing from required preload configuration: do not enable until PostgreSQL is restarted and verified.- Extension missing or wrong version: do not enable; report extension diagnostics.
- Any required BM25 index missing: do not enable; repair through a reviewed migration.
- Direct BM25 smoke query fails: do not enable; capture the SQL error and extension/index state.
- Application contract or integration check fails: do not report the service healthy in hybrid mode.
- Runtime BM25 failure after a verified deployment: fail that BM25 operation closed, emit high-signal diagnostics, and use the documented vector-only path without claiming a hybrid score.
- Vector or embedding failure: do not present BM25-only output as normal hybrid output unless an explicit product fallback policy is added and tested.
A runtime BM25 statement failure after a successful readiness gate is request-local. Discard all BM25
candidates for that retrieval, return only already successful thresholded vector candidates with
mode=vector_only and degradation_reason=bm25_runtime_error, and record the failure. A later request
tries BM25 again; only the readiness probe changes startup/health capability state. If query embedding
or vector retrieval fails, return the stable embedding/retrieval error and do not serve BM25-only
results. Requested-but-ineffective startup readiness uses its exact existing readiness reason, not the
runtime reason.
Health and diagnostics must distinguish requested, effective, and reason, for example:
{
"hybrid_search": {
"requested": true,
"effective": false,
"status": "degraded",
"reason": "pg_search_package_postgresql_major_mismatch"
}
}Required automated coverage:
- Allowlist and structured filters are identical in BM25 and vector branches.
- Disabled review and WordPress sources cannot contribute candidates.
- All three source kinds participate when allowed.
- RRF ordering is deterministic, including one-branch-only candidates and ties.
- Vector thresholds are applied before fusion.
- Review evidence links to its parent listing without becoming a listing card.
- Missing extensions/indexes or failed smoke checks prevent effective hybrid mode.
- A package compatibility mismatch keeps hybrid disabled.
- Vector-only fallback uses vector scores and identifies its mode accurately.
- Invalid filters are rejected before embeddings/SQL, and unsupported filters cannot be dropped from only one source or branch.
- Detail lookup fails closed for disallowed, inactive, URL-less, or review-parent-invalid records.
Evaluation set segments:
- Exact title or name queries.
- Category, location, amenity, and metadata-value queries.
- Natural-language intent and synonym queries.
- Date-sensitive Event Directory queries.
- Review and rating questions.
- Editorial questions answered by optional WordPress content.
- Cross-source questions and zero-result cases.
Track top-1/top-3 relevance, citation correctness, zero-result rate, BM25-only wins, vector-only wins, fused wins, latency, candidate counts, fallback rate, and source-filter violations. Tune weights only after reviewing this segmented evidence.
Consider structured query rewriting only after production query logs and a labeled evaluation set exist. It must preserve names, numbers, dates, locations, prices, categories, and the original query; it must not invent structured filters. On low confidence or provider failure, use the original query.
Consider reranking only after BM25 + vector quality is measured. Bound it to the top fused candidates, keep its score separate in diagnostics, set a strict timeout, and fall back to fused order on error.
The hybrid phase is complete when:
- Native and optional Docker compatibility gates are documented and tested.
- The installed
pg_searchartifact matches PostgreSQL major, execution OS, and architecture. - All source-kind BM25 and vector indexes exist and are healthy.
- Direct BM25, vector, and fused smoke tests pass within allowed source scopes.
- Health reports the requested and effective search modes accurately.
- The evaluation set meets agreed relevance and latency thresholds.
- Backup, rollback, vector-only fallback, and final deployment reporting are rehearsed.
The exact production-like workload, latency/resource thresholds, recovery smokes, resilience and
security approval, and secret-free final-report fields are fixed in
PRODUCTION_RELEASE_CONTRACT.md.