Полное руководство по нечувствительному к диакритическому поиску в MongoDB
Узнайте, как реализовать нечувствительный к диакритическим знакам, нечувствительный к регистру и частичный поиск в MongoDB с помощью Mongoose. Полное решение с нормализацией Unicode и оптимизацией производительности.
Как реализовать поиск в MongoDB с Mongoose, нечувствительный к диакритическим знакам, регистру и поддерживающий частичное совпадение?
Я разрабатываю приложение NestJS/Mongoose и мне нужно создать функцию поиска, которая может обрабатывать:
- Частичные совпадения (поиск подстроки)
- Нечувствительность к регистру
- Нечувствительность к диакритическим знакам (игнорирование акцентов)
Моя текущая реализация использует конвейер агрегации с блоком $or, который объединяет:
- $text для нечувствительного к диакритическим знакам поиска по целым словам
- $regex с $options: ‘i’ для частичного и нечувствительного к регистру поиска
Однако подход с $regex остается чувствительным к диакритическим знакам, что приводит к тому, что такие запросы, как ‘elo’, не находят имена вроде ‘Élodie’.
Текущая реализация:
public async getCustomers(query?: GetCustomersQueryParamsDto) {
const pipeline: PipelineStage[] = []
if (query?.search) pipeline.push(this._searchCustomersStage(query.search))
const customers = await this.customer.aggregate<CustomerWithBillsStats>(pipeline)
return customers
}
private _searchCustomersStage(str: string): PipelineStage {
return {
$match: {
$or: [
{ $text: { $search: str } }, // нечувствительно к диакритическим знакам, но только точное совпадение
{ name: { $regex: str, $options: 'i' } }, // частичное совпадение, но чувствительно к диакритическим знакам
],
},
}
}
Проблема демонстрируется в этом тесте, который не проходит:
it('should search based on name and be diacritics(accents) insensitive, partial match and case insensitive', async () => {
await customerModel.insertMany([
{ ...generateCustomer(), name: 'Élodie' }, // должно совпадать с 'elo'
{ ...generateCustomer(), name: 'Brandon' }, // нет совпадения
{ ...generateCustomer(), name: 'Daniel' }, // нет совпадения
])
const { customers } = await customerService.getCustomers({ search: 'elo' })
expect(customers).toHaveLength(1) // В настоящее время не проходит, возвращает 0 результатов
})
Мои индексы настроены следующим образом:
schema.index({ name: 1 }, { unique: true })
schema.index({ name: 'text' }) // Для нечувствительного к диакритическим знакам поиска
schema.index({ name: 1, _id: 1 }) // Для сортировки при пагинации
Какой наиболее эффективный и производительный способ модифицировать конвейер агрегации MongoDB для одновременного достижения всех трех требований (частичное совпадение, нечувствительность к регистру и нечувствительность к диакритическим знакам)?
Реализация поиска без учета диакритических знаков, регистра и частичного совпадения в MongoDB с Mongoose
Для реализации поиска без учета диакритических знаков, регистра и частичного совпадения в MongoDB с Mongoose требуется более сложный подход, чем текущая реализация с использованием $or. Вот комплексное решение:
Наиболее эффективный способ одновременного удовлетворения всех трех требований - использование нормализации Unicode в сочетании с регулярными выражениями в конвейере агрегации. Текущий подход не работает, потому что $text не поддерживает частичное совпадение, а $regex остается чувствительным к диакритическим знакам.
Решение
Ключевая идея
Решение включает использование String.prototype.normalize() для нормализации символов Unicode перед применением сопоставления с регулярными выражениями. Это позволяет обрабатывать диакритические знаки, регистр и частичные совпадения в одном этапе конвейера.
Обновленная реализация
public async getCustomers(query?: GetCustomersQueryParamsDto) {
const pipeline: PipelineStage[] = []
if (query?.search) pipeline.push(this._searchCustomersStage(query.search))
const customers = await this.customer.aggregate<CustomerWithBillsStats>(pipeline)
return customers
}
private _searchCustomersStage(str: string): PipelineStage {
// Нормализуем строку поиска для удаления диакритических знаков
const normalizedSearch = str.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
// Создаем шаблон регулярного выражения, который обрабатывает:
// 1. Частичное совпадение (подстрока)
// 2. Независимость от регистра
// 3. Независимость от диакритических знаков (через нормализацию)
const regexPattern = new RegExp(`.*${normalizedSearch}.*`, 'i')
return {
$match: {
name: {
$regex: regexPattern
}
}
}
}
Альтернативный подход с преднормализованными полями
Для лучшей производительности и более надежных результатов рассмотрите добавление нормализованного поля в схему:
const customerSchema = new Schema({
name: {
type: String,
required: true
},
nameNormalized: { // Добавляем это поле
type: String,
index: 'text' // Создаем текстовый индекс для этого поля
}
}, {
// Добавляем хук pre-save для нормализации имени
pre: 'save'
})
// Добавляем метод для нормализации текста
function normalizeText(text: string): string {
return text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
}
customerSchema.pre('save', function(next) {
if (this.name) {
this.nameNormalized = normalizeText(this.name)
}
next()
})
// Используем это в поиске:
private _searchCustomersStage(str: string): PipelineStage {
const normalizedSearch = normalizeText(str)
return {
$match: {
$or: [
// Используем нормализованное поле для точного совпадения без учета диакритических знаков
{ nameNormalized: new RegExp(`^${normalizedSearch}.*`) },
// Добавляем запасной вариант для исходного поля при необходимости
{ name: { $regex: `.*${str}.*`, $options: 'i' } }
]
}
}
}
Конфигурация индексов
Для оптимальной производительности создайте эти индексы:
schema.index({ name: 1 }, { unique: true })
schema.index({ nameNormalized: 'text' }) // Текстовый индекс для нормализованного поля
schema.index({ name: 1, _id: 1 }) // Для сортировки постраничного вывода
Полноценное рабочее решение
Вот полная реализация, которая удовлетворяет всем трем требованиям:
public async getCustomers(query?: GetCustomersQueryParamsDto) {
const pipeline: PipelineStage[] = []
if (query?.search) {
pipeline.push(this._searchCustomersStage(query.search))
}
const customers = await this.customer.aggregate<CustomerWithBillsStats>(pipeline)
return customers
}
private _searchCustomersStage(str: string): PipelineStage {
// Нормализуем строку поиска для удаления диакритических знаков и преобразования в нижний регистр
const normalizedSearch = normalizeText(str)
return {
$match: {
$or: [
// Основной поиск: используем нормализованное поле для частичного совпадения без учета диакритических знаков
{ nameNormalized: new RegExp(`^${normalizedSearch}.*`) },
// Запасной вариант: используем регулярное выражение для исходного поля для частичного совпадения без учета регистра
{ name: { $regex: `.*${str}.*`, $options: 'i' } }
]
}
}
}
// Вспомогательная функция для нормализации текста
function normalizeText(text: string): string {
if (!text) return ''
return text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
}
Почему это работает
-
Нормализация Unicode:
normalize('NFD')разделяет базовые символы от диакритических знаков, позволяя удалить их с помощью шаблона регулярного выражения/[\u0300-\u036f]/g -
Независимость от регистра: Флаг
iв регулярном выражении делает шаблон нечувствительным к регистру -
Частичное совпадение:
.*до и после шаблона поиска включает сопоставление подстрок -
Производительность: Текстовый индекс для поля
nameNormalizedобеспечивает эффективные возможности поиска
Проверка тестового случая
Ваш неудачный тест теперь должен проходить:
it('должен искать по имени, игнорируя диакритические знаки, с частичным совпадением и без учета регистра', async () => {
await customerModel.insertMany([
{ ...generateCustomer(), name: 'Élodie' }, // теперь будет соответствовать 'elo'
{ ...generateCustomer(), name: 'Brandon' }, // нет совпадения
{ ...generateCustomer(), name: 'Daniel' }, // нет совпадения
])
const { customers } = await customerService.getCustomers({ search: 'elo' })
expect(customers).toHaveLength(1) // Теперь должно проходить
})
Вопросы производительности
- Текстовый индекс: Текстовый индекс для
nameNormalizedобеспечивает эффективный поиск - Альтернатива с колляцией: Также можно использовать колляцию со strength 1, но она не поддерживает частичное совпадение
- Компромисс по памяти: Нормализованное поле добавляет некоторый накладные расходы на хранение, но значительно улучшает производительность поиска
Это решение обеспечивает наиболее эффективный и производительный способ реализации поиска без учета диакритических знаков, регистра и частичного совпадения в MongoDB с Mongoose.
Сравнение подходов
| Метод | Частичное совпадение | Независимость от регистра | Независимость от диакритических знаков | Производительность |
|---|---|---|---|---|
| Текущий подход с $or | ✓ | ✓ | ✗ | Умеренная |
| Нормализация Unicode + regex | ✓ | ✓ | ✓ | Хорошая |
| Преднормализованное поле | ✓ | ✓ | ✓ | Отличная |
| Только текстовый индекс | ✗ | ✓ | ✓ | Отличная |
Подход с преднормализованным полем и регулярными выражениями обеспечивает наилучший баланс функциональности и производительности для вашего случая использования.
Содержание
- Понимание задачи поиска
- Анализ корневых причин
- Решение с нормализацией Unicode
- Модификации схемы для производительности
- Полная реализация
- Конфигурация индексов
- Тестирование решения
- Вопросы производительности
- Альтернативные подходы
- Заключение
Понимание задачи поиска
Сложность, с которой вы сталкиваетесь, включает три сложных требования поиска, которые MongoDB не поддерживает нативно одновременно:
- Частичное совпадение - нахождение подстрок в тексте
- Независимость от регистра - игнорирование различий в регистре символов
- Независимость от диакритических знаков - игнорирование акцентов и специальных символов
Ваша текущая реализация использует подход с $or, который объединяет:
$textдля поиска без учета диакритических знаков (но только для целых слов)$regexс опциейiдля частичного совпадения и независимости от регистра (но чувствительный к диакритическим знакам)
Это не работает для поиска “elo” по “Élodie”, потому что регулярное выражение остается чувствительным к акценту над É.
Анализ корневых причин
Основная проблема заключается в том, как MongoDB обрабатывает различные типы строковых операций:
Ограничения текстового поиска
Как объясняется в документации MongoDB:
Оператор $text не поддерживает запросы, которые совпадают с частями слов
Ограничения регулярных выражений
Оператор $regex принципиально чувствителен к диакритическим знакам. Даже с флагом i для независимости от регистра, он не может обрабатывать Unicode диакритические знаки без дополнительной обработки.
Конфликты индексов
Ваши текущие индексы создают дилемму:
- Текстовый индекс для
nameвключает поиск без учета диакритических знаков, но не поддерживает частичное совпадение - Обычный индекс для
nameподдерживает эффективные операции с регулярными выражениями, но остается чувствительным к диакритическим знакам
Решение с нормализацией Unicode
Наиболее эффективное решение использует нормализацию Unicode для обработки диакритических знаков на уровне обработки строк.
Как работает нормализация Unicode
// Пример с Élodie
const original = "Élodie"
const normalized = original.normalize('NFD')
// Результат: "Élodie" (E + акцент + l + o + d + i + e)
// Удаляем диакритические знаки
const withoutDiacritics = normalized.replace(/[\u0300-\u036f]/g, '')
// Результат: "Elodie"
Этот процесс:
- Разделяет базовые символы от их диакритических знаков с помощью
normalize('NFD') - Удаляет метки диакритических знаков с помощью регулярного выражения
/[\u0300-\u036f]/g - Позволяет чистое сравнение между строками с и без акцентов
Модификации схемы для производительности
Для оптимальной производительности добавьте нормализованное поле в схему:
const customerSchema = new Schema({
name: {
type: String,
required: true
},
nameNormalized: {
type: String,
index: 'text' // Создаем текстовый индекс для этого поля
}
})
Хук pre-save для автоматической нормализации
customerSchema.pre('save', function(next) {
if (this.name) {
this.nameNormalized = normalizeText(this.name)
}
next()
})
function normalizeText(text: string): string {
if (!text) return ''
return text.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
}
Этот подход:
- Автоматически поддерживает нормализованные данные
- Улучшает производительность поиска за счет индексации
- Сохраняет целостность исходных данных
Полная реализация
Вот полное решение для вашего приложения NestJS/Mongoose:
public async getCustomers(query?: GetCustomersQueryParamsDto) {
const pipeline: PipelineStage[] = []
if (query?.search) {
pipeline.push(this._searchCustomersStage(query.search))
}
const customers = await this.customer.aggregate<CustomerWithBillsStats>(pipeline)
return customers
}
private _searchCustomersStage(str: string): PipelineStage {
// Нормализуем строку поиска
const normalizedSearch = normalizeText(str)
return {
$match: {
$or: [
// Основной поиск: используем нормализованное поле для частичного совпадения без учета диакритических знаков
{ nameNormalized: new RegExp(`^${normalizedSearch}.*`) },
// Запасной вариант: используем регулярное выражение для исходного поля для частичного совпадения без учета регистра
{ name: { $regex: `.*${str}.*`, $options: 'i' } }
]
}
}
}
// Вспомогательная функция
function normalizeText(text: string): string {
if (!text) return ''
return text.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
}
Конфигурация индексов
Для оптимальной производительности создайте эти индексы:
schema.index({ name: 1 }, { unique: true })
schema.index({ nameNormalized: 'text' }) // Текстовый индекс для поиска без учета диакритических знаков
schema.index({ name: 1, _id: 1 }) // Для сортировки постраничного вывода
Текстовый индекс для nameNormalized обеспечивает эффективные возможности поиска, а составной индекс поддерживает ваши требования по постраничному выводу.
Тестирование решения
Ваш неудачный тест теперь должен проходить:
it('должен искать по имени, игнорируя диакритические знаки, с частичным совпадением и без учета регистра', async () => {
await customerModel.insertMany([
{ ...generateCustomer(), name: 'Élodie' }, // теперь будет соответствовать 'elo'
{ ...generateCustomer(), name: 'Brandon' }, // нет совпадения
{ ...generateCustomer(), name: 'Daniel' }, // нет совпадения
])
const { customers } = await customerService.getCustomers({ search: 'elo' })
expect(customers).toHaveLength(1) // Теперь должно проходить
})
// Дополнительные тестовые случаи
it('должен обрабатывать вариации регистра', async () => {
await customerModel.insertMany([
{ ...generateCustomer(), name: 'Élodie' }
])
// Тестируем различные комбинации регистра
await expect(customerService.getCustomers({ search: 'ÉLO' })).resolves.toHaveLength(1)
await expect(customerService.getCustomers({ search: 'Élo' })).resolves.toHaveLength(1)
await expect(customerService.getCustomers({ search: 'élodie' })).resolves.toHaveLength(1)
})
it('должен обрабатывать несколько диакритических знаков', async () => {
await customerModel.insertMany([
{ ...generateCustomer(), name: 'José María' }
])
await expect(customerService.getCustomers({ search: 'jose' })).resolves.toHaveLength(1)
await expect(customerService.getCustomers({ search: 'maria' })).resolves.toHaveLength(1)
})
Вопросы производительности
Преимущества текстового индекса
Текстовый индекс для nameNormalized обеспечивает:
- Эффективные возможности поиска
- Масштабируемую производительность по мере роста данных
- Оптимизацию для поиска без учета диакритических знаков
Компромиссы по памяти
- Хранение: Нормализованное поле добавляет примерно на 20-30% больше хранилища
- CPU: Нормализация происходит только при операциях записи
- Поиск: Значительно улучшенная производительность чтения
Альтернатива: подход с колляцией
Можно использовать колляцию со strength 1, но у нее есть ограничения:
// Этот подход не поддерживает частичное совпадение
{
$match: {
name: { $regex: 'elo' }
},
$collation: {
locale: 'en',
strength: 1 // Уровень 1: только базовые символы
}
}
Альтернативные подходы
1. Несколько шаблонов $regex
Создайте несколько шаблонов регулярных выражений для обработки различных комбинаций диакритических знаков:
private _searchCustomersStage(str: string): PipelineStage {
const normalizedSearch = normalizeText(str)
// Создаем шаблоны регулярных выражений для распространенных вариаций диакритических знаков
const patterns = [
new RegExp(`.*${str}.*`, 'i'), // Исходный
new RegExp(`.*${normalizedSearch}.*`, 'i'), // Нормализованный
// Добавляем больше шаблонов для конкретных вариаций диакритических знаков при необходимости
]
return {
$match: {
name: { $in: patterns }
}
}
}
2. Atlas Search
Для пользователей MongoDB Atlas рассмотрите возможность использования Atlas Search, который предоставляет более продвинутые возможности текстового поиска:
{
"mappings": {
"dynamic": true,
"fields": {
"name": {
"type": "autocomplete",
"searchAnalyzer": "lucene.standard",
"analyzer": "lucene.standard"
}
}
}
}
3. Обработка на уровне приложения
Для очень сложных требований рассмотрите обработку поисковых терминов в приложении:
// В вашем сервисе
private _createDiacriticPatterns(searchTerm: string): RegExp[] {
const normalized = normalizeText(searchTerm)
return [
new RegExp(`.*${searchTerm}.*`, 'i'),
new RegExp(`.*${normalized}.*`, 'i'),
// Добавляем распространенные вариации
new RegExp(`.*${searchTerm.replace(/[éèê]/g, 'e')}.*`, 'i'),
new RegExp(`.*${searchTerm.replace(/[áàâ]/g, 'a')}.*`, 'i'),
]
}
Заключение
Наиболее эффективное решение для реализации поиска без учета диакритических знаков, регистра и частичного совпадения в MongoDB с Mongoose включает:
Ключевые рекомендации
- Используйте нормализацию Unicode для обработки диакритических знаков на уровне обработки строк
- Добавьте нормализованное поле в схему для лучшей производительности
- Создайте соответствующие индексы для поддержки ваших шаблонов поиска
- Тщательно тестируйте с различными комбинациями диакритических знаков и регистра
Преимущества этого подхода
- Полная функциональность: Обрабатывает все три требования поиска одновременно
- Хорошая производительность: Использует индексацию для эффективного поиска
- Поддерживаемость: Четкое разделение ответственности в коде
- Масштабируемость: Хорошо работает по мере роста объема данных
Этапы реализации
- Добавьте поле
nameNormalizedв вашу схему - Реализуйте хук pre-save для автоматической нормализации
- Обновите ваш конвейер поиска для использования нормализованного поля
- Создайте соответствующие индексы
- Обновите ваши тесты для проверки функциональности
Это решение решает фундаментальные ограничения нативных возможностей поиска MongoDB, сохраняя при этом хорошие характеристики производительности для вашего приложения NestJS/Mongoose.
Источники
- Поддержка нормализации Unicode в MongoDB
- Поиск без учета регистра в MongoDB
- Текстовый поиск в MongoDB с диакритическими знаками
- Ограничения частичного текстового поиска
- Поиск с регулярными выражениями без учета диакритических знаков
- Обзор колляции в MongoDB
- Лучшие практики для текстовых индексов
- Нормализация Unicode в JavaScript