Любая система быстрого поиска начинается не с индекса как структуры, а с подготовки строковых данных. В JavaScript при работе с многоязычными текстами критично учитывать различия Unicode-представления, регистр и диакритические знаки.
Базовый этап подготовки включает приведение строки к каноническому виду:
const normalizeText = (str) =>
str.normalize('NFC');
Нормализация устраняет различия в представлении одинаковых символов
(например, «é» может быть представлена как один символ или как
комбинация e + ́).
Следующий уровень — унификация регистра и диакритики. Однако простое
.toLowerCase() недостаточно для локалей, поэтому
используется Intl.Collator.
Intl.Collator предоставляет механизм сравнения строк с
учётом языка, чувствительности и правил сортировки.
const collator = new Intl.Collator('ru', {
sensitivity: 'base',
usage: 'sort'
});
Параметр sensitivity: 'base' задаёт игнорирование
регистра и диакритики, что критично для поисковых индексов, где важно
совпадение по «основе» слова.
Сравнение:
collator.compare('ёлка', 'Елка'); // 0 (эквивалентны)
Хотя API не предоставляет прямого метода генерации «sort key», поведение можно имитировать через предварительную сортировку и хранение нормализованных форм:
function createSortKey(str, collator) {
return str
.normalize('NFC')
.toLowerCase();
}
Более точный вариант — хранить оригинал и отдельное нормализованное поле:
const data = [
{ id: 1, title: 'Ёжик' },
{ id: 2, title: 'Ежедневник' }
];
const collator = new Intl.Collator('ru', { sensitivity: 'base' });
const indexed = data.map(item => ({
...item,
key: item.title.normalize('NFC')
}));
Для ускоренного поиска используется инвертированный индекс, где каждому токену соответствует набор документов.
Ключевая проблема — корректное разбиение текста на токены в разных
языках. Intl.Segmenter решает задачу языково-зависимой
сегментации.
const segmenter = new Intl.Segmenter('ru', { granularity: 'word' });
function tokenize(text) {
return [...segmenter.segment(text)]
.filter(s => s.isWordLike)
.map(s => s.segment.toLowerCase());
}
Базовая структура индекса:
const index = new Map();
Построение:
function addToIndex(doc) {
const tokens = tokenize(doc.title);
for (const token of tokens) {
if (!index.has(token)) {
index.set(token, new Set());
}
index.get(token).add(doc.id);
}
}
Такой подход позволяет получать кандидатов за O(1) по ключу токена.
Поиск выполняется в два этапа:
Intl.Collatorfunction search(query, docs) {
const tokens = tokenize(query);
let resultIds = null;
for (const token of tokens) {
const ids = index.get(token) || new Set();
resultIds = resultIds
? new Set([...resultIds].filter(x => ids.has(x)))
: ids;
}
const collator = new Intl.Collator('ru', { sensitivity: 'base' });
return docs.filter(doc =>
[...resultIds].includes(doc.id) &&
collator.compare(doc.title, query) === 0
);
}
На практике сравнение compare === 0 используется редко;
чаще применяется частичное совпадение через нормализованные формы.
Для поиска по префиксу или подстроке используется комбинированный подход: индекс + локализованное сравнение.
function matchScore(title, query, collator) {
const t = title.normalize('NFC');
const q = query.normalize('NFC');
if (t.startsWith(q)) return 2;
if (collator.compare(t, q) === 0) return 3;
if (t.includes(q)) return 1;
return 0;
}
Ранжирование строится на основе оценки:
function rankedSearch(query, docs) {
const collator = new Intl.Collator('ru', { sensitivity: 'base' });
return docs
.map(doc => ({
doc,
score: matchScore(doc.title, query, collator)
}))
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score)
.map(x => x.doc);
}
Intl.Collator поддерживает корректное сравнение чисел
внутри строк:
const collator = new Intl.Collator('ru', {
numeric: true,
sensitivity: 'base'
});
Это влияет на сортировку:
collator.compare('file2', 'file10'); // -1 (2 < 10)
Без numeric: true сортировка будет лексикографической,
что нарушает порядок в индексах.
Для ускорения поиска часто создаётся отсортированный массив ключей:
const sorted = [...data].sort((a, b) =>
collator.compare(a.title, b.title)
);
Такой массив позволяет применять бинарный поиск:
function binarySearch(arr, query) {
let left = 0;
let right = arr.length - 1;
while (left <= right) {
const mid = (left + right) >> 1;
const cmp = collator.compare(arr[mid].title, query);
if (cmp === 0) return arr[mid];
if (cmp < 0) left = mid + 1;
else right = mid - 1;
}
return null;
}
При больших объёмах данных повторная нормализация становится затратной. Поэтому используется предварительное хранение «поисковых ключей»:
function buildKey(str, collator) {
return str.normalize('NFC').toLowerCase();
}
Для ускорения:
Разные языки требуют разных стратегий сегментации.
Intl.Segmenter учитывает это автоматически:
const segmenter = new Intl.Segmenter('ja', { granularity: 'word' });
Для индекса это означает, что одна и та же логика построения индекса может работать для разных языков без ручных правил разбиения.
При хранении мультиязычных данных индекс обычно строится с привязкой к локали:
const indexes = new Map();
function getIndex(locale) {
if (!indexes.has(locale)) {
indexes.set(locale, new Map());
}
return indexes.get(locale);
}
Каждая локаль использует свой Intl.Collator и
Intl.Segmenter, что обеспечивает корректную сортировку и
поиск без смешивания правил.
Эффективный поисковый механизм обычно сочетает:
Intl.Collator для финального ранжированияIntl.Segmenter для универсальной токенизацииТакая архитектура позволяет масштабировать поиск до больших массивов текстовых данных без потери локализационной корректности.