Repository navigation
Conversation
|
| - **`websearch_to_tsquery('english', q)`** rather than `plainto_tsquery`: it never errors on malformed user input and gives humans and LLMs `"quoted phrases"`, `-negation`, and `or` for free. `ILIKE '%q%'` is rejected for content search (cannot be indexed without `pg_trgm`, no ranking, no multi-word semantics). `pg_trgm` fuzzy matching is deferred as an optional add-on. | ||
| - **Exception — users and organizations** are searched with `ILIKE prefix%` on name/email fields plus the existing `LOWER(email)` index: the tables are small, and stemming hurts proper names. No new indexes needed. | ||
| - **GIN expression indexes**, not stored generated `tsvector` columns, for phase 1: no schema change, no entity regeneration, no write amplification. The tradeoff is that the query must repeat the index expression exactly, so the expression SQL text lives once as constants in `entity_api/src/search/` shared by index DDL documentation and query code. Revisit stored columns with `setweight` (title vs body weighting) only if ranking tuning demands it. | ||
| - **Per-entity searchers merged in Rust**, not one 9-way SQL `UNION ALL` and not a projection table (yet). Each entity has a *different* authorization join path; separate searchers own their joins, are independently testable, and the `types` filter is simply "skip searchers". Each searcher fetches up to `limit + 1` rows (see the pagination decision below for why the fetch depth must track the page size), results are merged by rank in the domain layer, and the global limit is clamped at 100 (the existing `MAX_LIMIT` precedent). A unified projection table arrives with the semantic phase, where it is unavoidable anyway. |
There was a problem hiding this comment.
This is a great idea. Would this require us to duplicate our authorization logic? Looking at the next bullet as well. If so, the main concern would be maintaining multiple duplicate rules.
There was a problem hiding this comment.
Yes the one rule ends up expressed twice, but that's probably not avoidable at reasonable cost.
grants_access_to needs to run in-memory (extractors hold one relationship, roles preloaded, check is free), while search needs the same rule inside Postgres to filter thousands of rows before LIMIT. A single expression would require SELECT EXISTS per authz check or RLS.
So the plan keeps the duplication at exactly two copies with a guard (updated here: afd60f0):
- One shared
visible_relationshipsfragment - the nine searchers join through it and never write their own scoping SQL (their per-entity part is just the FK path to a relationship id). - Tier logic stays in Rust: admin/member org ids are derived from the preloaded roles and enter SQL only as bind parameters.
- A matrix equivalence test (participant/non-participant × member/removed/admin/ super-admin, two orgs) asserts
grants_access_toand the fragment agree for every (user, relationship) pair; we can have unmirrored edits fail CI.
The alternative is coaching_relationships::visible_to(scope) -> Condition on the
entity, next to grants_access_to - same cost and same test, but it becomes a named reusable source of truth for future corpus-wide consumers (MCP search, exports), and the extractors could converge on it later.
I'm happy to explore this path as well.
There was a problem hiding this comment.
I like that idea a lot if you're willing to give it a shot! Thanks for the explanation and exploration!
|
|
||
| ## Notes (TipTap content) | ||
|
|
||
| The live collaborative note document is **not in Postgres** — it lives in Tiptap Cloud, keyed by `coaching_sessions.collab_document_name`. Postgres holds a secondary `notes.body` mirror. |
There was a problem hiding this comment.
I don't think we actually use notes at the moment. That was an artifact of the past before we started working with TipTap as far as I remember. This could have changed since then though. If that's the case and we do want to use something like this, we'd have to do some sort of import from what is currently in Tiptap. We should consider what tables will be used when we self-host Tiptap. There may be another more logical place to import the body data.
There was a problem hiding this comment.
Great point. I have eliminated the notes table from search scope. It will remain as a valid search type but will only become active after we have implemented the TipTap projections in PR 5
jhodapp
left a comment
There was a problem hiding this comment.
This is looking great Raymond, thank you for this. This review of mine goes through up to the "Response Shape" section with more to follow.
| | `organization_id` | uuid, optional | Narrow to one organization. Intersects with the caller's scope (can never widen it). | | ||
| | `user_id` | uuid, optional | "By user": matches the creator (`user_id` column) for notes/goals/actions/agreements/topics; a participant (coach or coachee) for sessions/transcripts; ignored for users/organizations. Intersect-only. | | ||
| | `coaching_session_id` | uuid, optional | Narrow to one session (MCP vocabulary parity). Applies to notes/actions/agreements/topics/transcripts and the session itself. | |
There was a problem hiding this comment.
Could we make these be a more generalized pattern instead of explicit? Something like an optional id and type of entity param set, like ("0000-00-000-000...", ORGANIZATION_ID_TYPE)? There's probably an even better way of expressing this, but I hope this at least conveys my intent.
There was a problem hiding this comment.
I considered this, and I thought keeping them explicit would be more idomatic. the catch is the filters aren't actually uniform: user_id means "creator" for notes/goals/actions but "participant" for sessions/transcripts, and goal_id means "linked goal" for actions but "the goal itself" for goals.
A generic (id, entity_type) pair would hide those semantics rather than remove them, and it costs param composability; organization_id=X&user_id=Y&goal_id=Z intersect naturally, while a single pair can only anchor one thing at a time.
The generalization does exist one layer down, though: all the params compile into one Filters struct that every Searcher implementation consumes uniformly, so a future anchor is one field there, not a contract change.
I am happy to iterate on this further, had you thought about it the same way?
There was a problem hiding this comment.
I think as long as there's a type in place in all relevant layers that results in non-duplicate code and a strong interface contract (where possible), this is good enough. Composability at the param level is important.
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
The plan promised identical cursor mechanics in every mode while documenting only the FTS keyset predicate, which does not transfer: a resume-after-distance predicate cannot ride the HNSW index (it only serves ORDER BY ... LIMIT k), and an RRF-fused score is a function of an item's rank within the retrieved lists, so deepening those lists on a later page re-ranks rows already returned — the keyset invariant does not survive lists that deepen between pages.
|
@jhodapp Thanks for this comprehensive review.
Great point! The "applies identically" claim was written from the leak-prevention side only; it's true for correctness but, as said, HNSW post-filters the scope join, so a tier-1 user's highly selective scope can filter away all k global neighbors and return a false empty. The plan now commits to a layered mitigation: iterative index scans ( The miss-testing story now exists on both sides.
Agreed on both legs. The plan now commits up front to the pragmatic answer: semantic/hybrid retrieve a fixed pool (~top 200 per arm, pinned with data at PR 6/7), fuse once, and paginate within the frozen pool — next_cursor goes null at exhaustion, documented as bounded-depth. The cursor was already opaque, but its payload now leads with a mode/version discriminator byte so decode can branch on payload shape, and the keyset predicate is explicitly labeled keyword-only.
Right, this was absent. Fixed as suggested: search_chunks now carries embedding_model (name+version), retrieval always filters to the current model. We flip the filter only once the re-embed completes. One caveat added to the doc: HNSW needs a typmod-fixed column, so vector(1536) can't be dim-agnostic — the column makes same-dim swaps free and dim-changing swaps survivable.
Stated as the general rule now, with one ordering detail made explicit: the collapse happens within each retrieval arm before RRF — raw chunks entering fusion would let one entity hold several rank positions in the semantic arm, crowding out others and distorting its own fused score. Also pinned how it composes with the frozen pool and that the winning chunk supplies the snippet and, for transcripts, the start_ms deep link — same as PR 2's grouped segments.
Both now pinned. Sync removal is now part of teh lifecycle — chunk deletion runs inside the same transaction as the source delete/soft-delete/archive (no lag window where deleted content stays findable; query-time re-checks of deleted_at/archived_at were set aside since they'd re-import every searcher predicate into the chunk query). Outage: semantic and hybrid fail loud with a 502-style upstream error
Both corrected. e439092 Peformnace-related reviews
Applied. The scoping query now runs once per request in domain::search, producing visible_relationship_ids: Option<Vec>, and every searcher takes it as an array bind. One trade-off on the "derivable in-memory for tier 1" part: roles are preloaded on
Applied as a two-pass design. 6d481c1 One refinement over "one batched query": it's one per entity type present in the page, each over at most a page of ids. Users/orgs are unaffected (ILIKE searchers snippet from name/email fields), transcripts headline only segments whose transcription survives the merge, and the rule carries forward: hybrid never snippets the ~200/arm pool, and semantic-mode chunk snippets hydrate in the same post-merge step.
Bounded, as suggested: the fan-out now runs 2–3 searchers in flight, with the pool math acknowledged in the searcher decision bullet, so wall clock barely moves while per-request draw drops from 7 to the bound.
Stated as the primary trigger now: a9eb250.
(a) and (b) added as a "Query-time latency budget" bullet: hybrid fires the keyword arm concurrently with the embed call, and a bounded in-process LRU caches query embeddings keyed on. (c) is already in the plan via the bullet for the ANN-recall "scoped exact scan" fallback.
Decided for server-side final-token prefixing. 1e60579 I treated debounce as complementary rather than an alternative option here. A debounced query still carries a partially typed final word, which is the normal state of as-you-type. |
|
@rnambaale thanks for going through the feedback from Claude in-depth and making it very clear to me what you decided and each commit. Excellent work. I feel ready for you to proceed if you want to begin implementation, perhaps starting with the first phase and testing it out with a new frontend branch that has a prototype search box in the global header, like this?
The reason I'm recommending trying with a real frontend prototype is so we can try out and get a sense for the latency of search across the API boundary. |

Description
Proposes the phased implementation plan for the platform's search capability, covering both human (coach/coachee UI, org admin, super admin) and AI-agent (MCP) consumers. This PR is documentation only - it establishes the API contract, authorization model, and PR-by-PR delivery path for team review before any code lands.
GitHub Issue: [Resolves] #404
Changes
docs/implementation-plans/search-capability-backend.md:GET /searchcontract emulating the shape (not the product) of Tiptap's retired search API: single scored, type-tagged hit list with snippets and navigation context, keyset cursor pagination,mode=keyword|semantic|hybridreserved from day onevisible_relationshipsscope mirroringgrants_access_toexactly (participant + current org membership), plus org-admin and super-admin tiers; disallowed types silently droppedwebsearch_to_tsquery+ GIN expression indexes, no extensions); later phases: transcripts, members/orgs, MCPsearchtool, notes projection via the planned self-hosteddocs-collab-server, then pgvector semantic and hybrid RRF modesCREATE INDEX CONCURRENTLYtransaction caveat), testing strategy, and sizing per PRTesting Strategy
Docs-only change — review the rendered markdown. The plan itself defines the testing strategy for each implementation PR (scope-isolation integration tests, envelope-pinning controller tests, a shared "searchable corpus" scenario builder).
Concerns
notes.bodymirror, which can lag the live Tiptap doc; accurate note search arrives with thedocs-collab-serverprojection (PR 5).CREATE INDEX CONCURRENTLYasdoadminiftranscript_segmentsis large in production by then; the migration is writtenIF NOT EXISTSto no-op afterward.