feat(search): нечёткий поиск по сообщениям — по началу слова и по подстроке

Поиск требовал ввести слово целиком. Теперь:

- FTS5 ищет по началу слова: «пр» находит «Привет» (последнее слово запроса
  получает префикс), регистр не важен;
- добавлена нормализованная копия текста (`messages.content_lower`, миграция
  00007): подстрочные совпадения ищутся через LIKE без учёта регистра, включая
  кириллицу (SQLite lower() её не приводит — регистр опускает приложение);
- при старте индекс дополняется у старых сообщений (порциями по 500);
- результаты FTS и LIKE объединяются без дублей, сортировка по новизне;
- тест: «пр» и «ивет» находят все три сообщения с «Привет/приветствие/ПРИВЕТ»,
  посторонний текст не попадает.
This commit is contained in:
2026-09-20 11:01:32 +03:00
parent bcfc275bb3
commit 1aef57dfd9
5 changed files with 215 additions and 32 deletions
@@ -0,0 +1,10 @@
-- +goose Up
-- Нечёткий поиск по сообщениям: FTS5 ищет по началу слова, а для поиска
-- подстроки нужна нормализованная копия текста (SQLite lower() не знает
-- кириллицу, поэтому регистр приводит приложение).
ALTER TABLE messages ADD COLUMN content_lower TEXT NOT NULL DEFAULT '';
CREATE INDEX messages_content_lower_idx ON messages (channel_id, id DESC);
-- +goose Down
DROP INDEX messages_content_lower_idx;
ALTER TABLE messages DROP COLUMN content_lower;
+1 -24
View File
@@ -240,7 +240,7 @@ func (s *Server) registerMessageRoutes(api huma.API) {
if allowed, retryAfter := s.searchLimiter.Allow("search:" + formatSnowflake(user.ID)); !allowed {
return nil, rateLimitedError(retryAfter)
}
messages, err := s.store.SearchMessages(ctx, []uint64{channelID}, toFTSQuery(input.Query), input.Limit)
messages, err := s.store.SearchMessages(ctx, []uint64{channelID}, input.Query, input.Limit)
if err != nil {
return nil, humaError(err)
}
@@ -856,26 +856,3 @@ func rateLimitedError(retryAfter time.Duration) huma.StatusError {
"retry_after_ms": milliseconds,
})
}
// toFTSQuery превращает пользовательский ввод в безопасный запрос FTS5:
// кавычки и служебные символы экранируются, слова соединяются по AND.
func toFTSQuery(input string) string {
fields := strings.FieldsFunc(input, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\n'
})
terms := make([]string, 0, len(fields))
for _, field := range fields {
cleaned := strings.Map(func(r rune) rune {
switch r {
case '"', '\'', '*', '(', ')', ':', '^', '-', '+':
return -1
}
return r
}, field)
if cleaned == "" {
continue
}
terms = append(terms, `"`+cleaned+`"`)
}
return strings.Join(terms, " AND ")
}
+54
View File
@@ -7,6 +7,7 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"glchat/internal/permissions"
@@ -568,3 +569,56 @@ func TestMentionsIncrementReadState(t *testing.T) {
}
}
}
// TestFuzzySearch проверяет нечёткий поиск: по началу слова, по подстроке и
// без учёта регистра (запрос пользователя).
func TestFuzzySearch(t *testing.T) {
f := newMessagingFixture(t)
for _, text := range []string{"Привет, мир", "приветствие другу", "ПРИВЕТ ВСЕМ"} {
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"`+text+`"}`, f.ownerCookie); rec.Code != http.StatusOK {
t.Fatalf("send %q = %d, body = %s", text, rec.Code, rec.Body.String())
}
}
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"совсем другое"}`, f.ownerCookie); rec.Code != http.StatusOK {
t.Fatalf("send other = %d", rec.Code)
}
search := func(query string) []string {
t.Helper()
rec := doJSON(t, f.srv, http.MethodGet,
"/api/v1/channels/"+f.openChannel+"/messages/search?q="+url.QueryEscape(query), "", f.memberCookie)
if rec.Code != http.StatusOK {
t.Fatalf("search %q = %d, body = %s", query, rec.Code, rec.Body.String())
}
payload := decodeResponse[struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}](t, rec)
contents := make([]string, 0, len(payload.Messages))
for _, message := range payload.Messages {
contents = append(contents, message.Content)
}
return contents
}
// По началу слова в нижнем регистре: «пр» должно найти все три сообщения.
prefix := search("пр")
if len(prefix) != 3 {
t.Fatalf("поиск «пр» вернул %d результатов: %v", len(prefix), prefix)
}
// Подстрока в середине слова тоже находится.
substring := search("ивет")
if len(substring) != 3 {
t.Fatalf("поиск «ивет» вернул %d результатов: %v", len(substring), substring)
}
// Посторонний текст не попадает в выдачу.
if results := search("привет всем"); len(results) != 1 {
t.Fatalf("поиск по фразе вернул %d результатов: %v", len(results), results)
}
if results := search("другое"); len(results) != 1 {
t.Fatalf("поиск «другое» вернул %d результатов: %v", len(results), results)
}
}
+142 -8
View File
@@ -60,7 +60,7 @@ type ReadState struct {
}
const messageColumns = `id, channel_id, author_id, content, reply_to_id, type,
attachments_json, mentions_json, edited_at, pinned, created_at`
attachments_json, mentions_json, edited_at, pinned, created_at, content_lower`
// CreateMessageParams — параметры нового сообщения.
type CreateMessageParams struct {
@@ -93,10 +93,11 @@ func (s *Store) CreateMessage(ctx context.Context, params CreateMessageParams) (
}
_, err = s.writer.ExecContext(ctx, `
INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type,
attachments_json, mentions_json, pinned, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`,
attachments_json, mentions_json, pinned, created_at, content_lower)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
int64(params.ID), int64(params.ChannelID), int64(params.AuthorID), params.Content,
nullableID(params.ReplyToID), string(params.Type), string(attachments), string(mentions), s.Now())
nullableID(params.ReplyToID), string(params.Type), string(attachments), string(mentions),
s.Now(), strings.ToLower(params.Content))
if err != nil {
return nil, mapError(err)
}
@@ -167,7 +168,8 @@ func (s *Store) ListPinnedMessages(ctx context.Context, channelID uint64, limit
// UpdateMessageContent меняет текст сообщения и фиксирует время правки.
func (s *Store) UpdateMessageContent(ctx context.Context, id uint64, content string) (*Message, error) {
result, err := s.writer.ExecContext(ctx,
`UPDATE messages SET content = ?, edited_at = ? WHERE id = ?`, content, s.Now(), int64(id))
`UPDATE messages SET content = ?, content_lower = ?, edited_at = ? WHERE id = ?`,
content, strings.ToLower(content), s.Now(), int64(id))
if err != nil {
return nil, err
}
@@ -319,16 +321,82 @@ func (s *Store) ListReadStates(ctx context.Context, userID uint64) ([]ReadState,
// SearchMessages ищет сообщения по тексту с учётом списка доступных комнат
// (AGENT.md 7.15: права проверяет вызывающий код, хранилище ограничивает выборку).
// Поиск нечёткий: FTS5 находит слова по началу («пр» → «Привет»), а подстрочные
// совпадения добираются по нормализованной копии текста без учёта регистра.
func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query string, limit int) ([]Message, error) {
if len(channelIDs) == 0 || strings.TrimSpace(query) == "" {
query = strings.TrimSpace(query)
if len(channelIDs) == 0 || query == "" {
return []Message{}, nil
}
if limit <= 0 || limit > 100 {
limit = 25
}
found := make([]Message, 0, limit)
seen := map[uint64]bool{}
if ftsQuery := toFTSPrefixQuery(query); ftsQuery != "" {
prefixMatches, err := s.searchMessagesFTS(ctx, channelIDs, ftsQuery, limit)
if err != nil {
return nil, err
}
for _, message := range prefixMatches {
if !seen[message.ID] {
seen[message.ID] = true
found = append(found, message)
}
}
}
if len(found) < limit {
substringMatches, err := s.searchMessagesSubstring(ctx, channelIDs, strings.ToLower(query), limit-len(found))
if err != nil {
return nil, err
}
for _, message := range substringMatches {
if !seen[message.ID] {
seen[message.ID] = true
found = append(found, message)
}
}
}
return found, nil
}
// toFTSPrefixQuery превращает пользовательский ввод в безопасный запрос FTS5:
// слова соединяются по AND, последнее ищется по началу («пр» → «Привет»),
// служебные символы вырезаются.
func toFTSPrefixQuery(input string) string {
fields := strings.FieldsFunc(input, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\n'
})
terms := make([]string, 0, len(fields))
for index, field := range fields {
cleaned := strings.Map(func(r rune) rune {
switch r {
case '"', '\'', '*', '(', ')', ':', '^', '-', '+':
return -1
}
return r
}, field)
if cleaned == "" {
continue
}
term := `"` + cleaned + `"`
if index == len(fields)-1 {
// Префиксный поиск по последнему слову: нечёткий ввод по мере набора.
term += "*"
}
terms = append(terms, term)
}
return strings.Join(terms, " AND ")
}
// searchMessagesFTS ищет по индексу FTS5 (слова и их начала).
func (s *Store) searchMessagesFTS(ctx context.Context, channelIDs []uint64, match string, limit int) ([]Message, error) {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(channelIDs)), ",")
args := make([]any, 0, len(channelIDs)+2)
args = append(args, query)
args = append(args, match)
for _, id := range channelIDs {
args = append(args, int64(id))
}
@@ -343,6 +411,30 @@ func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query s
if err != nil {
return nil, err
}
return scanMessages(rows, limit)
}
// searchMessagesSubstring ищет вхождения подстроки без учёта регистра.
func (s *Store) searchMessagesSubstring(ctx context.Context, channelIDs []uint64, query string, limit int) ([]Message, error) {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(channelIDs)), ",")
args := make([]any, 0, len(channelIDs)+2)
args = append(args, "%"+escapeLike(query)+"%")
for _, id := range channelIDs {
args = append(args, int64(id))
}
args = append(args, limit)
rows, err := s.reader.QueryContext(ctx, `
SELECT `+messageColumns+` FROM messages
WHERE content_lower LIKE ? ESCAPE '\' AND channel_id IN (`+placeholders+`)
ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, err
}
return scanMessages(rows, limit)
}
func scanMessages(rows *sql.Rows, limit int) ([]Message, error) {
defer rows.Close()
messages := make([]Message, 0, limit)
@@ -365,6 +457,47 @@ func prefixedMessageColumns(alias string) string {
return strings.Join(columns, ", ")
}
// BackfillSearchIndex заполняет нормализованный текст у старых сообщений:
// нужен для поиска подстроки без учёта регистра (кириллица в SQLite lower()
// не приводится). Вызывается один раз при старте, порциями.
func (s *Store) BackfillSearchIndex(ctx context.Context, limit int) (int, error) {
if limit <= 0 || limit > 5000 {
limit = 500
}
rows, err := s.reader.QueryContext(ctx,
`SELECT id, content FROM messages WHERE content_lower = '' AND content <> '' LIMIT ?`, limit)
if err != nil {
return 0, err
}
type pending struct {
id uint64
content string
}
items := make([]pending, 0, limit)
for rows.Next() {
var item pending
if err := rows.Scan(&item.id, &item.content); err != nil {
_ = rows.Close()
return 0, err
}
items = append(items, item)
}
if err := rows.Close(); err != nil {
return 0, err
}
updated := 0
for _, item := range items {
if _, err := s.writer.ExecContext(ctx,
`UPDATE messages SET content_lower = ? WHERE id = ?`,
strings.ToLower(item.content), int64(item.id)); err != nil {
return updated, err
}
updated++
}
return updated, nil
}
func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) {
var (
message Message
@@ -375,9 +508,10 @@ func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) {
editedAt sql.NullString
pinned int
createdAt string
contentLow string
)
err := scanner.Scan(&message.ID, &message.ChannelID, &authorID, &message.Content, &replyToID,
&message.Type, &attachments, &mentions, &editedAt, &pinned, &createdAt)
&message.Type, &attachments, &mentions, &editedAt, &pinned, &createdAt, &contentLow)
if err != nil {
return nil, mapError(err)
}