feat(search): нечёткий поиск по сообщениям — по началу слова и по подстроке
Поиск требовал ввести слово целиком. Теперь: - FTS5 ищет по началу слова: «пр» находит «Привет» (последнее слово запроса получает префикс), регистр не важен; - добавлена нормализованная копия текста (`messages.content_lower`, миграция 00007): подстрочные совпадения ищутся через LIKE без учёта регистра, включая кириллицу (SQLite lower() её не приводит — регистр опускает приложение); - при старте индекс дополняется у старых сообщений (порциями по 500); - результаты FTS и LIKE объединяются без дублей, сортировка по новизне; - тест: «пр» и «ивет» находят все три сообщения с «Привет/приветствие/ПРИВЕТ», посторонний текст не попадает.
This commit is contained in:
@@ -152,6 +152,14 @@ func run() error {
|
||||
// Один калькулятор прав на процесс: HTTP-ручки и Gateway видят общий кэш.
|
||||
calculator := permissions.NewCalculator(source.New(st))
|
||||
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
|
||||
// Один раз при старте дополняем индекс поиска у старых сообщений
|
||||
// (кириллица в SQLite lower() не приводится, нормализуем в приложении).
|
||||
if updated, err := st.BackfillSearchIndex(ctx, 500); err != nil {
|
||||
logger.WarnContext(ctx, "backfill search index failed", slog.Any("error", err))
|
||||
} else if updated > 0 {
|
||||
logger.InfoContext(ctx, "search index backfilled", slog.Int("messages", updated))
|
||||
}
|
||||
|
||||
srv := server.New(cfg, db, logger, server.Deps{
|
||||
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user