Другое

Полное руководство по нечувствительному к диакритическому поиску в MongoDB

Узнайте, как реализовать нечувствительный к диакритическим знакам, нечувствительный к регистру и частичный поиск в MongoDB с помощью Mongoose. Полное решение с нормализацией Unicode и оптимизацией производительности.

Как реализовать поиск в MongoDB с Mongoose, нечувствительный к диакритическим знакам, регистру и поддерживающий частичное совпадение?

Я разрабатываю приложение NestJS/Mongoose и мне нужно создать функцию поиска, которая может обрабатывать:

  • Частичные совпадения (поиск подстроки)
  • Нечувствительность к регистру
  • Нечувствительность к диакритическим знакам (игнорирование акцентов)

Моя текущая реализация использует конвейер агрегации с блоком $or, который объединяет:

  1. $text для нечувствительного к диакритическим знакам поиска по целым словам
  2. $regex с $options: ‘i’ для частичного и нечувствительного к регистру поиска

Однако подход с $regex остается чувствительным к диакритическим знакам, что приводит к тому, что такие запросы, как ‘elo’, не находят имена вроде ‘Élodie’.

Текущая реализация:

typescript
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' } }, // частичное совпадение, но чувствительно к диакритическим знакам
      ],
    },
  }
}

Проблема демонстрируется в этом тесте, который не проходит:

typescript
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 результатов
})

Мои индексы настроены следующим образом:

typescript
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 перед применением сопоставления с регулярными выражениями. Это позволяет обрабатывать диакритические знаки, регистр и частичные совпадения в одном этапе конвейера.

Обновленная реализация

typescript
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
      }
    }
  }
}

Альтернативный подход с преднормализованными полями

Для лучшей производительности и более надежных результатов рассмотрите добавление нормализованного поля в схему:

typescript
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' } }
      ]
    }
  }
}

Конфигурация индексов

Для оптимальной производительности создайте эти индексы:

typescript
schema.index({ name: 1 }, { unique: true })
schema.index({ nameNormalized: 'text' }) // Текстовый индекс для нормализованного поля
schema.index({ name: 1, _id: 1 }) // Для сортировки постраничного вывода

Полноценное рабочее решение

Вот полная реализация, которая удовлетворяет всем трем требованиям:

typescript
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, '')
}

Почему это работает

  1. Нормализация Unicode: normalize('NFD') разделяет базовые символы от диакритических знаков, позволяя удалить их с помощью шаблона регулярного выражения /[\u0300-\u036f]/g

  2. Независимость от регистра: Флаг i в регулярном выражении делает шаблон нечувствительным к регистру

  3. Частичное совпадение: .* до и после шаблона поиска включает сопоставление подстрок

  4. Производительность: Текстовый индекс для поля nameNormalized обеспечивает эффективные возможности поиска

Проверка тестового случая

Ваш неудачный тест теперь должен проходить:

typescript
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 Хорошая
Преднормализованное поле Отличная
Только текстовый индекс Отличная

Подход с преднормализованным полем и регулярными выражениями обеспечивает наилучший баланс функциональности и производительности для вашего случая использования.

Содержание

Понимание задачи поиска

Сложность, с которой вы сталкиваетесь, включает три сложных требования поиска, которые MongoDB не поддерживает нативно одновременно:

  1. Частичное совпадение - нахождение подстрок в тексте
  2. Независимость от регистра - игнорирование различий в регистре символов
  3. Независимость от диакритических знаков - игнорирование акцентов и специальных символов

Ваша текущая реализация использует подход с $or, который объединяет:

  • $text для поиска без учета диакритических знаков (но только для целых слов)
  • $regex с опцией i для частичного совпадения и независимости от регистра (но чувствительный к диакритическим знакам)

Это не работает для поиска “elo” по “Élodie”, потому что регулярное выражение остается чувствительным к акценту над É.

Анализ корневых причин

Основная проблема заключается в том, как MongoDB обрабатывает различные типы строковых операций:

Ограничения текстового поиска

Как объясняется в документации MongoDB:

Оператор $text не поддерживает запросы, которые совпадают с частями слов

Ограничения регулярных выражений

Оператор $regex принципиально чувствителен к диакритическим знакам. Даже с флагом i для независимости от регистра, он не может обрабатывать Unicode диакритические знаки без дополнительной обработки.

Конфликты индексов

Ваши текущие индексы создают дилемму:

  • Текстовый индекс для name включает поиск без учета диакритических знаков, но не поддерживает частичное совпадение
  • Обычный индекс для name поддерживает эффективные операции с регулярными выражениями, но остается чувствительным к диакритическим знакам

Решение с нормализацией Unicode

Наиболее эффективное решение использует нормализацию Unicode для обработки диакритических знаков на уровне обработки строк.

Как работает нормализация Unicode

javascript
// Пример с Élodie
const original = "Élodie"
const normalized = original.normalize('NFD')
// Результат: "Élodie" (E + акцент + l + o + d + i + e)

// Удаляем диакритические знаки
const withoutDiacritics = normalized.replace(/[\u0300-\u036f]/g, '')
// Результат: "Elodie"

Этот процесс:

  1. Разделяет базовые символы от их диакритических знаков с помощью normalize('NFD')
  2. Удаляет метки диакритических знаков с помощью регулярного выражения /[\u0300-\u036f]/g
  3. Позволяет чистое сравнение между строками с и без акцентов

Модификации схемы для производительности

Для оптимальной производительности добавьте нормализованное поле в схему:

typescript
const customerSchema = new Schema({
  name: {
    type: String,
    required: true
  },
  nameNormalized: {
    type: String,
    index: 'text' // Создаем текстовый индекс для этого поля
  }
})

Хук pre-save для автоматической нормализации

typescript
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:

typescript
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, '')
}

Конфигурация индексов

Для оптимальной производительности создайте эти индексы:

typescript
schema.index({ name: 1 }, { unique: true })
schema.index({ nameNormalized: 'text' }) // Текстовый индекс для поиска без учета диакритических знаков
schema.index({ name: 1, _id: 1 }) // Для сортировки постраничного вывода

Текстовый индекс для nameNormalized обеспечивает эффективные возможности поиска, а составной индекс поддерживает ваши требования по постраничному выводу.

Тестирование решения

Ваш неудачный тест теперь должен проходить:

typescript
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, но у нее есть ограничения:

typescript
// Этот подход не поддерживает частичное совпадение
{
  $match: {
    name: { $regex: 'elo' }
  },
  $collation: {
    locale: 'en',
    strength: 1 // Уровень 1: только базовые символы
  }
}

Альтернативные подходы

1. Несколько шаблонов $regex

Создайте несколько шаблонов регулярных выражений для обработки различных комбинаций диакритических знаков:

typescript
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, который предоставляет более продвинутые возможности текстового поиска:

json
{
  "mappings": {
    "dynamic": true,
    "fields": {
      "name": {
        "type": "autocomplete",
        "searchAnalyzer": "lucene.standard",
        "analyzer": "lucene.standard"
      }
    }
  }
}

3. Обработка на уровне приложения

Для очень сложных требований рассмотрите обработку поисковых терминов в приложении:

typescript
// В вашем сервисе
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 включает:

Ключевые рекомендации

  1. Используйте нормализацию Unicode для обработки диакритических знаков на уровне обработки строк
  2. Добавьте нормализованное поле в схему для лучшей производительности
  3. Создайте соответствующие индексы для поддержки ваших шаблонов поиска
  4. Тщательно тестируйте с различными комбинациями диакритических знаков и регистра

Преимущества этого подхода

  • Полная функциональность: Обрабатывает все три требования поиска одновременно
  • Хорошая производительность: Использует индексацию для эффективного поиска
  • Поддерживаемость: Четкое разделение ответственности в коде
  • Масштабируемость: Хорошо работает по мере роста объема данных

Этапы реализации

  1. Добавьте поле nameNormalized в вашу схему
  2. Реализуйте хук pre-save для автоматической нормализации
  3. Обновите ваш конвейер поиска для использования нормализованного поля
  4. Создайте соответствующие индексы
  5. Обновите ваши тесты для проверки функциональности

Это решение решает фундаментальные ограничения нативных возможностей поиска MongoDB, сохраняя при этом хорошие характеристики производительности для вашего приложения NestJS/Mongoose.

Источники

  1. Поддержка нормализации Unicode в MongoDB
  2. Поиск без учета регистра в MongoDB
  3. Текстовый поиск в MongoDB с диакритическими знаками
  4. Ограничения частичного текстового поиска
  5. Поиск с регулярными выражениями без учета диакритических знаков
  6. Обзор колляции в MongoDB
  7. Лучшие практики для текстовых индексов
  8. Нормализация Unicode в JavaScript
Авторы
Проверено модерацией
Модерация