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 видят общий кэш.
|
// Один калькулятор прав на процесс: HTTP-ручки и Gateway видят общий кэш.
|
||||||
calculator := permissions.NewCalculator(source.New(st))
|
calculator := permissions.NewCalculator(source.New(st))
|
||||||
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
|
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{
|
srv := server.New(cfg, db, logger, server.Deps{
|
||||||
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
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 {
|
if allowed, retryAfter := s.searchLimiter.Allow("search:" + formatSnowflake(user.ID)); !allowed {
|
||||||
return nil, rateLimitedError(retryAfter)
|
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 {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
@@ -856,26 +856,3 @@ func rateLimitedError(retryAfter time.Duration) huma.StatusError {
|
|||||||
"retry_after_ms": milliseconds,
|
"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"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"glchat/internal/permissions"
|
"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,
|
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 — параметры нового сообщения.
|
// CreateMessageParams — параметры нового сообщения.
|
||||||
type CreateMessageParams struct {
|
type CreateMessageParams struct {
|
||||||
@@ -93,10 +93,11 @@ func (s *Store) CreateMessage(ctx context.Context, params CreateMessageParams) (
|
|||||||
}
|
}
|
||||||
_, err = s.writer.ExecContext(ctx, `
|
_, err = s.writer.ExecContext(ctx, `
|
||||||
INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type,
|
INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type,
|
||||||
attachments_json, mentions_json, pinned, created_at)
|
attachments_json, mentions_json, pinned, created_at, content_lower)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||||||
int64(params.ID), int64(params.ChannelID), int64(params.AuthorID), params.Content,
|
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 {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
}
|
}
|
||||||
@@ -167,7 +168,8 @@ func (s *Store) ListPinnedMessages(ctx context.Context, channelID uint64, limit
|
|||||||
// UpdateMessageContent меняет текст сообщения и фиксирует время правки.
|
// UpdateMessageContent меняет текст сообщения и фиксирует время правки.
|
||||||
func (s *Store) UpdateMessageContent(ctx context.Context, id uint64, content string) (*Message, error) {
|
func (s *Store) UpdateMessageContent(ctx context.Context, id uint64, content string) (*Message, error) {
|
||||||
result, err := s.writer.ExecContext(ctx,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -319,16 +321,82 @@ func (s *Store) ListReadStates(ctx context.Context, userID uint64) ([]ReadState,
|
|||||||
|
|
||||||
// SearchMessages ищет сообщения по тексту с учётом списка доступных комнат
|
// SearchMessages ищет сообщения по тексту с учётом списка доступных комнат
|
||||||
// (AGENT.md 7.15: права проверяет вызывающий код, хранилище ограничивает выборку).
|
// (AGENT.md 7.15: права проверяет вызывающий код, хранилище ограничивает выборку).
|
||||||
|
// Поиск нечёткий: FTS5 находит слова по началу («пр» → «Привет»), а подстрочные
|
||||||
|
// совпадения добираются по нормализованной копии текста без учёта регистра.
|
||||||
func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query string, limit int) ([]Message, error) {
|
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
|
return []Message{}, nil
|
||||||
}
|
}
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 25
|
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)), ",")
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(channelIDs)), ",")
|
||||||
args := make([]any, 0, len(channelIDs)+2)
|
args := make([]any, 0, len(channelIDs)+2)
|
||||||
args = append(args, query)
|
args = append(args, match)
|
||||||
for _, id := range channelIDs {
|
for _, id := range channelIDs {
|
||||||
args = append(args, int64(id))
|
args = append(args, int64(id))
|
||||||
}
|
}
|
||||||
@@ -343,6 +411,30 @@ func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
messages := make([]Message, 0, limit)
|
messages := make([]Message, 0, limit)
|
||||||
@@ -365,6 +457,47 @@ func prefixedMessageColumns(alias string) string {
|
|||||||
return strings.Join(columns, ", ")
|
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) {
|
func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) {
|
||||||
var (
|
var (
|
||||||
message Message
|
message Message
|
||||||
@@ -375,9 +508,10 @@ func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) {
|
|||||||
editedAt sql.NullString
|
editedAt sql.NullString
|
||||||
pinned int
|
pinned int
|
||||||
createdAt string
|
createdAt string
|
||||||
|
contentLow string
|
||||||
)
|
)
|
||||||
err := scanner.Scan(&message.ID, &message.ChannelID, &authorID, &message.Content, &replyToID,
|
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 {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user