Skip to the content.

BM25 sparse-search integration — planning doc

Status: ✅ Shipped — archived planning doc. The pg_search (ParadeDB BM25) integration this document planned has landed in full (BM-1…BM-5): pg_search_run, pg_search_more_like_this, pg_search_parse_query, hybrid_bm25_vector_search, create_pg_search_index, reindex_pg_search_index, plus the observability/advisor tools — see the tool index. This doc is kept for its design rationale and the deferred-alternatives (pg_textsearch, vchord_bm25) return conditions; it is no longer a live roadmap. Current gaps live in feature-shortlist.md.

Target: pg_search (ParadeDB, Tantivy-based) — selected after a three-way comparison. The other two candidates (pg_textsearch from Tiger Data, vchord_bm25 from VectorChord) are deferred with documented return conditions.

1. Decision rationale

Why pg_search first. Concrete reasons, in priority order:

  1. PG version coverage matches MCPg’s existing footprint — 14, 15, 16, 17, 18 supported with pre-built binaries. pg_textsearch is PG 17/18-only today; vchord_bm25 is pre-1.0 and ships primarily as Docker images.
  2. Stable surface, deep release cadence (as of 2026-06). Many releases on the parent ParadeDB monorepo with a healthy star count and active commit history. The v2 API (pdb.* schema) is the documented stable contract. Numbers shift; the durable signal is the active release cadence on a stable schema.
  3. Documented hybrid with pgvector. ParadeDB’s “Hybrid Search Missing Manual” documents the v2 pattern — pdb.score(key) from the BM25 side, weighted-summed against a pgvector distance expression. The exact arithmetic (linear blend, RRF, or a tunable weight knob) is the remaining open question after the §2 investigation — see §2.5; gates BM-3 only, not BM-1/BM-2. MCPg’s RAG efficiency suite (analyze_vector_search_efficiency) composes naturally with either shape.
  4. Compositional richness. pdb.score, pdb.snippet, pdb.snippets, pdb.agg, pdb.more_like_this, pdb.regex, pdb.parse — the surface is rich enough to wrap as distinct MCPg tools, not just one omnibus bm25_search.
  5. Existing MCP integration upstream. ParadeDB ships their own MCP integration; MCPg’s wrapper is incremental rather than novel.

Why pg_textsearch is deferred. Strong second candidate — zero Rust at install, native PG full-text-config compatibility (29+ languages including zhparser, Jieba via existing dictionaries), delta-encoding for ~41% smaller indexes than GIN. But PG 17/18-only today, and no phrase queries (no positions stored). Returns to the front of the queue when PG 14–16 support lands or when MCPg’s TimescaleDB integration motivates the Tiger Data lineage match.

Why vchord_bm25 is deferred. Pre-1.0 (0.3.0 latest, Dec 2025). Strong CJK tokenizer story (Jieba, Lindera, BERT, custom-model trainer with trigger), but: split-extension install surface (pg_tokenizer + vchord_bm25), no documented hybrid pattern with pgvector, ~370 stars. Returns when CJK / multilingual becomes a top-line MCPg goal.

2. Pre-implementation investigation — results

The focused investigation agent (one run, same shape as the pg_turboquant pre-implementation read) has completed. Items 1-4 below are resolved with verbatim upstream-source citations; item 5 is deferred to BM-3 (does not block BM-1/BM-2/BM-4).

2.1 pdb.* function signatures — resolved

Verbatim from pg_search/src/bootstrap/ Rust declarations (pgrx generates the CREATE FUNCTION SQL from these):

Snippet source — resolved (post-BM-0 follow-up investigation). The first sweep missed pdb.snippet / pdb.snippets because they do not live in pg_search/src/bootstrap/. A targeted follow-up agent located them in pg_search/src/postgres/customscan/basescan/projections/snippet.rs (paradedb/paradedb@8bb9a64). Verbatim signatures:

Both are #[pg_extern(stable, parallel_safe)] Rust functions; the pgrx-generated SQL declarations register them under schema pdb. Two independent functions, not wrappers of each other — pdb.snippets adds sort_by and returns the multi-snippet array.

Caveat. These are pgrx stubs marked #[allow(unused_variables)]; the actual highlight generation happens during custom-scan projection rewriting (see pg_search/src/postgres/customscan/projections.rstantivy::snippet::SnippetGenerator). Calling pdb.snippet(...) outside a pg_search-driven SELECT executes the stub, not real highlight code. BM-2’s pg_search_run wires the snippet projection together with the @@@ predicate so this is transparent for callers; bare-call wrappers (if ever needed) must document the constraint.

pdb.snippet_positions(field anyelement, "limit" int4, "offset" int4) → int[][] also lives in the same file via an explicit sql = r#"..."# override. Deferred — the int[][] return shape needs extra marshaling and there’s no current MCPg consumer for character positions.

2.2 bm25 index WITH (...) options — resolved

Verbatim from pg_search/src/api/index.rs IndexOptions struct. Thirteen documented options:

  1. key_field (required) — primary-key column.
  2. text_fields (jsonb) — per-text-field tokenizer/analyzer config.
  3. numeric_fields (jsonb).
  4. boolean_fields (jsonb).
  5. json_fields (jsonb).
  6. range_fields (jsonb).
  7. datetime_fields (jsonb).
  8. layer_sizes (text) — segment-merge tier sizes.
  9. background_layer_sizes (text) — async-merge tier sizes.
  10. target_segment_count (int).
  11. mutable_segment_rows (int).
  12. sort_by (text) — pre-sorted segment hint.
  13. search_tokenizer (jsonb) — index-wide default tokenizer.

BM-4’s create_pg_search_index exposes the small subset MCPg operators are likely to want (per-column text config, k1/b analogue via text_fields); the rest are reachable via a generic WITH (…) passthrough or deferred to a tuning-helper tool.

2.3 Pre-built binary distribution coverage — resolved

ParadeDB ships pre-built pg_search binaries for:

Not available out-of-the-box on AWS RDS, Google Cloud SQL, Azure Database for PostgreSQL, or Tiger Data / Timescale Cloud. Self-managed PG and Docker are the broad deployment story; managed-PG operators need to either run ParadeDB’s image or compile from source (Rust + pgrx).

MCPg’s wrappers are unaffected — enable_extension falls through to a clear “not available on this server” error when the binary isn’t installed.

2.4 AGPL-3.0 redistribution implications — resolved

Decision (project owner, 2026-06): ship the wrappers; document the operator-side AGPL obligations clearly in README.md.

Rationale and scope:

2.5 v2 hybrid-search arithmetic — resolved

A focused follow-up agent (2026-06-10) pinned the canonical v2 arithmetic from two living upstream sources:

Both agree on Reciprocal Rank Fusion (RRF) with the formula sum(1.0 / (k + rank)) per source, summed via UNION ALL + GROUP BY. The literal k = 60 constant matches both sources. Equal weights are the default; the blog’s weighted variant uses literal float multipliers (bm25_weight * 1.0/(k+rank) + vector_weight * 1.0/(k+rank)). There is no paradedb.score_hybrid / paradedb.rank_hybrid helper function in v2 (GitHub code search returned zero hits on main) — operators write the CTE inline.

MCPg’s BM-3 wrapper ships RRF as the documented default. The UNION ALL + GROUP BY SUM form is simpler than the test’s FULL OUTER JOIN + COALESCE form; the arithmetic is identical. The wrapper exposes k, bm25_weight, vector_weight, per_leg_limit, distance_op (allowlist <=> / <-> / <#>), and final_limit as kwargs. Linear blend is not offered because there is no upstream v2 source for min/max normalized linear blend — shipping it would be speculation.

Caveat — docs page removed. The hybrid guide on docs.paradedb.com returns HTTP 404 as of 2026-06-10 (the .prettierignore still lists docs/documentation/guides/hybrid.mdx but the file is gone). The blog post is the current canonical reference; future ParadeDB versions may ship a more formal hybrid-search API (the docs-page removal suggests the public surface is in flux). MCPg’s wrapper docstring cites both the blog URL with retrieval date and the test path so future maintainers can re-verify when upstream stabilizes.

3. Guardrails (apply to every phase)

Same patterns proven in the pg_turboquant integration (see pg_turboquant-integration.md for the canonical exposition):

  1. Module: a single new src/mcpg/pg_search.py. Cohesive cluster — keeps tools.py conflicts to adjacent-block only.
  2. Presence check on every public function via extension_installed(driver, "pg_search"). Reads return [] / None when absent; writes / DDL raise PgSearchError.
  3. Identifier safety: schema / table / column / index names go through the established _validate_identifier + _pg_quote_ident helpers. The wrappers reuse the helpers from mcpg.turboquant or mcpg.vector_tuning (lifted to a shared util when the third copy is needed — not before).
  4. No-speculation discipline. Same as the turboquant phases. Argument types and return shapes come from pg_search/sql/ verbatim; anything not documented in source is deferred to a future phase with a documented return condition.
  5. Extension allowlist: add pg_search to ENABLEABLE_EXTENSIONS in mcpg.extensions so the existing enable_extension tool can install it.
  6. Gating:
    • Read tools (search, snippets, aggregations, more_like_this) → no gate.
    • Write tools (none anticipated — pg_search is read-heavy).
    • DDL tools (CREATE INDEX … USING bm25, REINDEX, setup_pdb_schema) → unrestricted + MCPG_ALLOW_DDL.

4. Phasing

Five phases proposed, mirroring the turboquant cadence.

Phase BM-1 — observability + extension presence (1 PR)

Smallest first slice. Goal: any MCPg deployment can confirm whether pg_search is installed, list every BM25 index in the database, read each index’s catalog metadata.

Phase BM-2 — search execution (1 PR)

Wraps the @@@ operator and the core pdb.score / pdb.snippet projection helpers as MCPg tools.

pg_search indexes can cover one or many columns (or the entire table). The wrappers reflect that — columns is a list[str] | None, where None means “search the whole index”. The single-column case is just columns=["body"].

Validation: every identifier (schema/table/each entry in columns) through _validate_identifier; limit bounded 1..10_000. query and document_id go in as bound params — never spliced into SQL.

Phase BM-3 — hybrid-search composition (1 PR)

Composes BM25 + pgvector into one MCPg tool, mirroring the ParadeDB “Hybrid Search Missing Manual” pattern.

bm25_columns=None searches the entire BM25 index (ParadeDB’s default behavior — the index can cover multiple columns or the whole table). Pass an explicit list to restrict to a subset.

Composes with the RAG efficiency suite — once shipped, analyze_vector_search_efficiency can be re-used to tune the vector arm of a hybrid query.

Phase BM-4 — DDL (1 PR)

Allowlist-validated text_config, tokenizer, k1/b bounds. Gated under unrestricted + MCPG_ALLOW_DDL.

Phase BM-5 — advisor + audit category (1 PR)

recommend_pg_search_maintenance + audit_pg_search_indexes (analogous to TQ-2’s recommend_turboquant_maintenance + audit_turboquant_indexes). Rules sourced from documented signals only; threshold list TBD after Phase BM-1 reveals what metadata pg_search exposes.

5. Sequencing

  1. Investigation run — done (§2.1–§2.4 resolved; §2.5 deferred to BM-3). Results landed in this doc as the BM-0 checkpoint PR.
  2. BM-1 — observability. No dependencies. Next slice.
  3. BM-2 — search execution. Depends on BM-1’s PgSearchIndexInfo dataclass. Confirms pdb.snippet source location before exposing return_snippets=True; otherwise ships without snippet support.
  4. BM-3 — hybrid search. Depends on BM-2 (uses pg_search_run internally), pgvector (already integrated), and the §2.5 arithmetic decision.
  5. BM-4 — DDL. Independent of BM-2/BM-3.
  6. BM-5 — advisor + audit category. Depends on BM-1.

BM-2/BM-4 and BM-3/BM-4 could land in parallel.

6. Out of scope (named so they don’t drift in)

7. Why this is differentiated