Relay · Search
Search a channel
Relay provides ranked full-text search inside one channel. Search is a derived read model. The message store remains the source of truth, while post-commit workers update the search index asynchronously.
The current public SDK does not provide client.search(query). Call the HTTP endpoint from the host client or wrap it in a host-owned API layer.
Request
The caller must be a current channel member:
GET /api/channels/{channel_id}/messages/search?q=refund&limit=20
Authorization: Bearer <jwt> | Parameter | Requirement |
|---|---|
q | Required, trimmed, 1 to 200 Unicode characters |
limit | Optional, defaults to 20, maximum 100 |
cursor | Optional opaque cursor from the previous page |
Use a positive limit from 1 to 100. Lower-bound validation is incomplete in the current endpoint, so zero or negative values are outside the public contract.
The default Postgres search path uses web-search syntax. It supports terms, quoted phrases, OR, and a leading minus for exclusion:
"payment failed" OR refund -sandbox There is no public filter for sender, date range, mention, attachment type, or minimum rank.
Response
{
"data": {
"hits": [
{
"message": {
"id": "message-uuid",
"channel_id": "channel-uuid",
"sender_id": "user-uuid",
"body": "The refund is ready",
"seq": 42,
"attachments": []
},
"rank": 0.18,
"snippet": "The <mark>refund</mark> is ready"
}
],
"next_cursor": null,
"degraded": false
}
} Each hit contains the normal Relay message payload plus adapter-generated rank and snippet values. next_cursor is null when no later page exists.
Render snippets safely
snippet is untrusted user content with adapter-inserted <mark> tags. It is not a safe HTML fragment. Do not assign it directly to innerHTML or Svelte {@html}.
The safest current option is to render the snippet as text. If highlighting is required, sanitize it with a strict allowlist that permits only mark, or derive highlights from the message body in the client.
A structured highlight-range response and server-escaped snippet contract are not implemented.
Indexed content
The Postgres index currently matches:
- Message body
- Bound attachment filenames
Attachment file bytes are not indexed. Sender IDs and mention tokens exist in the derived document model but are not included in the current Postgres search vector.
A filename-only match can return a snippet that does not show the matching filename because snippets are generated from the message body. Use the returned message’s attachments metadata to display the filename.
Ordering and cursor
Primary search results use this order:
rank DESC, inserted_at DESC, message_id DESC Keep the cursor opaque and reuse it only with the same tenant, channel, query, and search mode. Pagination is stable only while the relevant index rows do not change.
The current cursor has two known limits:
- An undecodable cursor is treated as no cursor and repeats the first page instead of returning
422. - A cursor is not signed or bound to the original query and channel.
Clients should discard stored cursors when the query changes and deduplicate hits by message.id if the index changes during pagination.
Index consistency
Message create, edit, retract, and delete operations write durable post-commit handoffs. Search indexing happens after the conversation transaction commits.
This produces the following behavior:
- A new or edited message may not appear immediately.
- A retracted message has an empty indexed body, but its attachment filenames may still match.
- A deleted message is removed from the index asynchronously. Relay also filters deleted source messages before returning hits, so a stale index row is not returned as a visible message.
- Pin and unpin do not change the search document.
Relay does not currently publish a maximum index-lag objective. If a workflow requires read-after-write consistency, read the message through the conversation API instead of search.
Chinese search modes
Relay requests zhparser in production for Chinese word segmentation. If the Postgres extension is unavailable, startup falls back to simple_bigram instead of disabling search.
When the fallback handles a CJK query, the response sets:
{
"degraded": true
} The degraded path uses substring bigrams and caps the query at 20 unique bigrams. Web-search operators do not retain their normal semantics in this fallback.
Degraded pagination is incomplete: the fallback does not apply the incoming cursor and can repeat its first page. When degraded is true, treat the response as a single fallback page and ignore next_cursor.
Errors and limits
| Status | Meaning |
|---|---|
401 | JWT is missing or invalid |
403 | Caller is not a channel member |
422 | q is missing, empty, or longer than 200 characters |
429 | User or tenant search budget is exhausted |
The current defaults are 60 searches per minute per user and 600 per minute per tenant. A 429 response includes Retry-After in seconds.
Operator backfill
An operator can enqueue a tenant search backfill and inspect its state:
POST /api/admin/tenants/{tenant_id}/search/reindex
GET /api/admin/tenants/{tenant_id}/search/reindex
X-Admin-Token: <admin-token> The POST returns 202 Accepted. The state endpoint reports never_run, running, complete, or failed, together with indexed-row counts and failure details when available.
Despite the route name, the current operation is a resumable backfill, not a clean rebuild. A completed cursor is not reset for a later run, stale index rows are not cleared first, and backfill documents omit attachment filenames. Operators should not use this endpoint as proof that every derived search row was reconstructed exactly.
Product boundary
Channel membership, tenant-scoped indexes, ranked body search, attachment filename matching, cursor pagination, asynchronous updates, Chinese fallback, rate limits, and operator backfill are implemented.
Safe structured highlights, strict cursor validation, cursor binding, reliable degraded pagination, positive limit validation, index-lag objectives, exact rebuild semantics, attachment-aware backfill, advanced filters, and an SDK search helper remain product work. The Meilisearch adapter is only a skeleton and must not be enabled as a production backend.