DocsClient authorsMessage Search (Full-Text Search)

Message Search (Full-Text Search)

searchMessages runs Postgres full-text search over message text, scoped to conversations the requesting actor can see. This document covers the query operator syntax, the ranking/pagination design, and the visibility contract.

Operator syntax #

The query string mixes free text with structured operators. Operators are stripped from the free text before it is passed to Postgres's websearch_to_tsquery; the remainder is the tsquery. Whitespace-separated tokens matching key:value are treated as operators; everything else (including an unrecognized key:, or an operator with an empty value) is free text — the parser never errors on unknown syntax, matching websearch_to_tsquery's own forgiving behavior.

Operator Example Meaning
from: from:@alice, from:<user-uuid> Only messages sent by this user.
in: in:#general, in:<conversation-uuid> Only messages in this channel.
has:link has:link Only messages containing a URL (https?://).
has:attachment / has:file has:attachment Only messages with at least one attachment.
before: before:2026-07-01 Messages sent strictly before UTC midnight on this date (exclusive).
after: after:2026-06-01 Messages sent on or after UTC midnight on this date (inclusive).

Multiple has: operators AND-combine. Operators can be freely mixed with free text and with each other, e.g. deploy from:@alice in:#ops has:link after:2026-06-01.

Unknown/inaccessible from:/in: targets never error. If from: doesn't resolve to a real user, or in: doesn't resolve to a channel the actor can see, the query returns an empty page, not an error — this avoids leaking whether a username or channel exists to someone who can't see it (see "Visibility" below).

Ranking vs. pagination — why recency, not relevance #

Search results are paginated by recency (sentAt, id — the same cursor contract as every other message list; see docs/pagination.md), not by relevance. Each result also carries a rank (ts_rank score) and an optional headline (ts_headline snippet with <mark>...</mark> highlights) for client-side display or secondary sort.

This is a deliberate tradeoff: cursor pagination requires a stable total order with a unique tie-breaker. ts_rank is neither unique nor stable across concurrent inserts, so paginating by rank would produce skips and duplicates across pages. Pagination by (sentAt, id) avoids that at the cost of not surfacing the single "best" match first — the client can still sort a page's items by rank for display if it wants a best-match-first feel within a page. A dedicated relevance-ranked top-N (non-paginated) query is a possible future addition for grounded AI recall, but is out of scope here.

Visibility (security-critical) #

Search never returns a message from a conversation the actor cannot see — this is upheld per-request by DefaultMessageService::search_messages, which computes the accessible conversation set from list_accessible_conversation_ids (plus a full member-access gate when an explicit conversationId scope is given) before ever calling the repository. The repository itself is intentionally visibility-agnostic: it only ever searches the conversation ids it's handed, and an empty set yields zero rows — never "search everything". See the doc comments on MessageSearchParams (rim_core::message::repository) and the db-tests in rim_infra_db/tests/message_search_fts.rs for the enforced guarantees.

Underlying storage #

message.search_tsv is a GENERATED ALWAYS ... STORED tsvector column (to_tsvector('english', coalesce(text, ''))) with a GIN index, added in migration m20260708_225022_add_message_search_tsv. The 'english' text-search configuration is hardcoded; per-workspace configuration or a simple fallback for non-English content is a possible follow-up, not implemented here.

GraphQL contract #

input SearchMessagesInput {
  serverId: ID!
  query: String!
  conversationId: ID
  limit: Int
  cursor: MessageCursorInput
  dir: PageDir
}

type SearchResultMessage {
  # ...existing message fields...
  rank: Float!
  headline: String
}

type SearchResultPage {
  items: [SearchResultMessage!]!
  next: MessageCursorOutput
  prev: MessageCursorOutput
  pageInfo: PageInfo!
}

This is a breaking change from the pre-Track-B1 searchMessages (which returned a flat, unpaginated [SearchResultMessage!]! and took top-level limit/includeDeleted args). Frontend codegen must be re-run against the updated schema.