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
+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)
}