feat: Add full text search over chat messages (#27126)

Closes CODAGT-721
Closes CODAGT-722
Closes CODAGT-723
Closes CODAGT-724
Closes CODAGT-725

This PR adds the database and API pieces necessary to support full-text
chat message search.

- Adds required chat schema for full-text search
- Adds dbpurge job to populate search_tsv in the background
- Adds `search` parameter to GetChats query
- Adds `search` filter to `searchquery.Chats`
- Wires chat search filter into chats API

> Implemented by Coder Agents, reviewed and tested by a human.
This commit is contained in:
Cian Johnston
2026-07-16 15:21:57 +01:00
committed by GitHub
parent 9862f10484
commit f7481c5d08
26 changed files with 1756 additions and 35 deletions
+66
View File
@@ -312,6 +312,32 @@ SET
WHERE
id = @id::bigint;
-- name: BackfillChatMessagesSearchTsv :execrows
-- Backfills chat_messages.search_tsv for pending rows, newest first.
-- The WHERE clause must match the predicate of
-- idx_chat_messages_search_tsv_pending exactly so the partial index
-- serves this query.
WITH batch AS (
SELECT id FROM chat_messages
WHERE search_tsv IS NULL
AND deleted = false
AND visibility IN ('user', 'both')
AND role IN ('user', 'assistant')
ORDER BY id DESC
LIMIT @batch_size::int
)
UPDATE chat_messages cm
-- NULL means "pending", empty tsvector means "backfilled, no text".
SET search_tsv = COALESCE(
to_tsvector('simple', chat_message_search_text(cm.content)),
''::tsvector)
FROM batch WHERE cm.id = batch.id;
-- name: ChatSearchQueryIsEmpty :one
-- Reports whether search text tokenizes to an empty tsquery (e.g. '!!!').
-- Used to reject input that would silently match nothing.
SELECT numnode(websearch_to_tsquery('simple', @search::text)) = 0 AS is_empty;
-- name: GetChatByID :one
SELECT *
FROM chats_expanded
@@ -651,6 +677,46 @@ WHERE
)
ELSE true
END
-- websearch_to_tsquery accepts quoted phrases, OR, and -negation;
-- the 'simple' config folds case and skips stemming.
AND CASE
WHEN @search::text != '' THEN (
-- Served by idx_chats_title_fts.
to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', @search)
-- Served by idx_chat_diff_statuses_pr_title_fts.
OR EXISTS (
SELECT 1
FROM chat_diff_statuses cds
WHERE cds.chat_id = chats_expanded.id
AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', @search)
)
-- The WHERE clause must repeat the predicate of the partial index
-- idx_chat_messages_search_tsv so the planner can use it. Additional
-- filters should still be fine.
OR EXISTS (
SELECT 1
FROM chat_messages cm
WHERE cm.chat_id = chats_expanded.id
AND cm.search_tsv IS NOT NULL
AND cm.deleted = false
AND cm.visibility IN ('user', 'both')
AND cm.role IN ('user', 'assistant')
AND cm.search_tsv @@ websearch_to_tsquery('simple', @search)
)
-- Skip an explicit pr_number lookup unless the search is a valid bigint.
OR CASE
WHEN @search ~ '^[0-9]{1,18}$' THEN EXISTS (
SELECT 1
FROM chat_diff_statuses cds
WHERE cds.chat_id = chats_expanded.id
AND cds.pr_number IS NOT NULL
AND cds.pr_number = @search::bigint
)
ELSE false
END
)
ELSE true
END
-- Paginate over root chats only. Children are fetched
-- separately via GetChildChatsByParentIDs and embedded under
-- each parent. Other callers that need the full set should