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