feat(search): ranked full-text paragraph search — tsvector, GET /search, MCP parity - #455
Conversation
STORED generated tsvector on paragraphs.text (English config) plus a GIN index, so ranked full-text search can replace the ILIKE scan. Generated + STORED keeps the vector consistent with text with no trigger and no app write path; existing rows gain a populated vector with no backfill. ADR-062 records the retrieval stance: FTS in core and deterministic, embeddings/pgvector explicitly deferred to a future decision. Refs #445 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pets Replace the ILIKE substring scan with ranked FTS: websearch_to_tsquery + ts_rank_cd (cover density, so a tight cluster of the query terms outranks the same terms scattered across a long paragraph) + ts_headline snippets. New options object scopes by library / project / division / part / nodeType; the CSI PART filter climbs each hit to its root part via a recursive CTE and ranks root parts by document order. A blank query returns []; a degenerate no-lexeme query falls back to an escaped ILIKE substring scan. Query building lives in a pure search-query.ts builder; toSearchOptions is the omit-undefined constructor REST/MCP use under exactOptionalPropertyTypes. Refs #445 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add GET /search, the REST twin of the MCP search_library affordance: a Zod
query schema (q required; libraryId/projectId/division/part/nodeType/limit
optional, numeric params coerced) drives the ranked FTS query and returns
{ success, data: SearchHit[] } with snippet + rank on every hit. openapi.yaml
gains the /search operation and the SearchHit schema; the REST contract gate
asserts the 200 envelope against that schema.
Refs #445
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bring the MCP search_library tool to input parity with GET /search — add libraryId/projectId/part/nodeType alongside the existing division/limit, all routed through the same searchParagraphs path so both surfaces return identical rows for identical inputs. Response stays backward-compatible: snippet and rank are additive fields. contract-map now pairs get /search -> search_library (dropping the MCP-native exemption) and lists the tool under INV5_READ_PENDING, since a non-vacuous response-shape assertion needs seeded paragraph content. Extract the library/search tool registrations into library-tools.ts to keep tools.ts under the 400-line cap. Refs #445 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds deterministic ranked PostgreSQL full-text paragraph search with generated vectors, REST ChangesRanked paragraph search
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant RESTSearch
participant MCPSearch
participant searchParagraphs
participant PostgreSQL
Client->>RESTSearch: GET /search or search_library request
RESTSearch->>searchParagraphs: validated query and filters
MCPSearch->>searchParagraphs: converted query and filters
searchParagraphs->>PostgreSQL: ranked FTS or fallback query
PostgreSQL-->>searchParagraphs: hits with snippets and ranks
searchParagraphs-->>RESTSearch: SearchHit results
searchParagraphs-->>MCPSearch: SearchHit results
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
|
@coderabbitai full review |
✅ Action performedFull review finished. You're currently rate limited under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. Your next review will be available in 41 minutes. |
The `snippet` field carries <mark> highlight tags and is documented/rendered as HTML, but ts_headline and the ILIKE fallback returned uploaded paragraph text verbatim — text like `<img onerror=…>` would reach a consumer as live markup (stored XSS). Escape &,<,> in the source text before the <mark> tags are inserted so <mark> is the only live markup; both snippet paths are covered by regression tests. Also publish the nodeType enum in openapi.yaml: the handler validates against the 12-value NodeTypeSchema (400 on miss) while the contract documented any string, so a spec-generated client could send values the route rejects. Both found by the Codex adversarial review on PR #455. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex adversarial review (review of record — CodeRabbit rate-limited)CodeRabbit hit its Fair-Usage rate limit on this PR (next window ~50 min), so Codex (GPT-5.5, xhigh) ran as the review gate against
Verification: |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@openapi.yaml`:
- Around line 233-236: Add maxItems: 100 to the array schema for data under the
response definition, alongside its type and items fields, so the documented
SearchHit result array matches the endpoint’s limit constraint.
In `@src/db/queries/search.ts`:
- Around line 44-47: Preserve backward compatibility for searchParagraphs by
supporting the previous positional arguments (query, division, limit) alongside
the new ParagraphSearchOptions object. Add overloads and an adapter in
searchParagraphs that normalize both call styles into the current options
format, ensuring existing TypeScript and JavaScript callers retain their
division and limit behavior.
In `@src/mcp/contract-map.ts`:
- Around line 212-214: Add a seeded parsed-paragraph fixture to the INV-5
contract test, then invoke both `search_library` and `GET /search` with the same
query and assert their returned rows are identical. Update the
`INV5_READ_PENDING` handling for `search_library` so this parity assertion is
executed rather than merely listed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: e84430b8-7302-4499-baa5-c714d6c53303
📒 Files selected for processing (17)
docs/adr/062-full-text-retrieval-stance.mddocs/superpowers/plans/2026-07-10-fts-paragraph-search.mdopenapi.yamlsrc/api/contract.integration.test.tssrc/api/router.tssrc/api/search.integration.test.tssrc/api/search.tssrc/db/index.tssrc/db/migrations/042_paragraphs_search_vector.tssrc/db/queries/search-query.tssrc/db/queries/search.integration.test.tssrc/db/queries/search.tssrc/mcp/anchors.test.tssrc/mcp/contract-map.tssrc/mcp/handlers.tssrc/mcp/library-tools.tssrc/mcp/tools.ts
The `limit` query param caps at 100, but the response `data` array schema was unbounded. Add `maxItems: 100` so generated clients and the contract gate carry the endpoint's actual bound. Addresses CodeRabbit review on PR #455. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Why
Every downstream client of this headless API — an SPA, an LLM/agent workflow over MCP, a reporting integration — starts almost every task with "find the relevant paragraphs." The only deterministic retrieval was
searchParagraphs, an ILIKE substring scan with no ranking, no stemming, and poor recall for natural-language queries. Retrieval quality caps the quality of everything built on top, so the deterministic layer should own it.What
Ranked PostgreSQL full-text search replaces the ILIKE scan, exposed as a new REST route and brought to parity on MCP.
tsvectorcolumn onparagraphs(English config, fromtext) plus a GIN index. Generated + STORED keeps the vector consistent with the text with no trigger and no app write path; existing rows gain a populated vector with no backfill.searchParagraphsrewrite —websearch_to_tsquery+ts_rank_cd+ts_headlinesnippets, scoped bylibraryId/projectId/division/part(CSI PART 1/2/3) /nodeType. A blank query returns[]; a degenerate (no-lexeme) query falls back to an escaped ILIKE substring scan. Each hit carries stable anchors: paragraph UUID, spec id, section, title, plussnippetandrank.GET /search— the REST twin of the previously MCP-nativesearch_library, documented inopenapi.yaml(SearchHit schema) in the same PR.search_library— same query path and the same scope filters, so REST and MCP return identical rows for identical inputs.snippet/rankare additive, so the response stays backward-compatible.contract-mapnow pairsget /search → search_libraryand both contract gates stay green.Design decisions
Ambiguous calls made autonomously (issue was the spec; brainstorming skipped by request):
ts_rank_cdover plaints_rank. The acceptance criterion "exact-phrase beats scattered terms" is proximity-sensitive; cover-density ranking is the proximity-aware member of thets_rank*family, so a tight cluster of the query terms outranks the same terms spread across a long paragraph. The issue namedts_rankgenerically.partfilter = enclosing CSI PART ordinal (1/2/3). There is no stored part pointer, so each hit is climbed to its root paragraph via a recursive CTE and the spec's root parts are ranked by document order. This matches what a spec editor means by "search within Products."meta.vanish. Retrieval surfaces all stored content; suppression of hidden content is a render-layer concern (the markdown renderer), not a retrieval one. Filtering here would silently drop recall.<mark>…</mark>. Neutral highlight markup a UI can style and an agent can strip. Returned in a JSON field of a headless API — clients escape on render as usual.search_librarystays inINV5_READ_PENDING. A non-vacuous response-shape assertion needs seeded paragraph content (a parsed spec) beyondpnpm seed, mirroringlist_library_specs.toSearchOptionsomit-undefined constructor. Lets the REST/MCP layers build the options object from validated-but-optional fields underexactOptionalPropertyTypes.library-tools.ts. Adding the parity filters pushedtools.tsover the enforced 400-line cap, so the library/search tool registrations moved to their own module (the established*-tools.tspattern).Testing
pnpm test→ 1633 passed)searchdb + api, both contract gates, MCP server) → 79 passedpnpm lint)src/api/contract.integration.test.ts,src/mcp/contract.integration.test.ts)🤖 Co-authored by Claude Fable 5. Closes #445.
Summary by CodeRabbit
New Features
GET /searchendpoint.search_libraryfunctionality with equivalent search filters.Bug Fixes
Documentation