Недавно я рассказывал на Хабре про архитектуру своего пет-проекта — клипборд-менеджера Beetroot на стеке Tauri + React + Rust + SQLite. В комментариях тепло приняли отказ от прожорливого Electron и систему бэкапов БД, но за кадром осталась, пожалуй, самая алгоритмически болезненная часть разработки десктопной утилиты — поиск.

В моей ежедневной базе 1000+ записей: куски кода, URL-ы, стектрейсы, SQL-запросы, переписки из мессенджеров. Поиск по всему этому должен работать мгновенно и попадать точно в цель.

Сначала я пошёл по простому пути: подключил популярную библиотеку Fuse.js и думал, что задача решена. Но реальные данные буфера обмена оказались для неё патологическим кейсом.

Эта статья — про путь от «просто подключи готовую либу» до самописного 5-уровневого движка с мерж-скорингом. Два дня, 8 итераций, пара красивых продуктовых багов по дороге.

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

Поиск Fuse.js из коробки выглядит так:

const fuse = new Fuse(items, {
  keys: ['content', 'note', 'source_app', 'source_title'],
  threshold: 0.4,
  minMatchCharLength: 3,
})
return fuse.search(query)

Отгружается быстро, интегрируется за пять минут. И какое-то время это даже работало. Но данные буфера обмена — это вам не каталог товаров.

Одна запись может состоять из 5 000 символов минифицированного JSON или лога сервера. Буквы вашего поискового запроса статистически гарантированно встретятся где-то в этой простыне.

Запрос local v начал возвращать 98 результатов. Слово "timeout" радостно матчило переменную delivery_time_options. Пользователи тоже начали это замечать — один из них написал, что поиск «не находит заметку, в которой есть нужное слово, зато пытается искать отдельные буквы».

Он был абсолютно прав.

Как ищут Ditto и CopyQ

Прежде чем переписывать всё с нуля, я пошел смотреть, как решают эту задачу устоявшиеся клипборд-менеджеры.

  • Ditto (15+ лет, open-source): Строгий AND подстрок. Вводишь "hello world" → находит записи, содержащие "hello" И "world" как подстроки. Без нечёткого поиска, без ранжирования. Работает, потому что предельно просто и предсказуемо. Но опечатки — мимо.

  • CopyQ (кроссплатформенный, open-source): Тот же подход. Литеральный поиск подстрок с AND для нескольких слов. Без границ слов, без скоринга.

Оба проекта выбрали самое надежное, но топорное решение. Я хотел большего: толерантность к опечаткам, приоритезацию полей и умную подсветку совпадений. Но реализовать это было уже не так просто.

Итерация 1: Матчинг в рамках одного поля

Проблема: Мой запрос "local v" матчил запись, потому что слово "local" находилось в контенте, а буква "v" — в метадате окна source_app: "VS Code". Кросс-полевой матчинг раздувал выдачу.

Фикс: Я переписал логику так, чтобы все токены запроса должны были найтись строго в рамках одного поля.

Шум уменьшился, но длинные записи (всё те же JSON-блобы) всё равно содержали оба токена просто по закону больших чисел.

Итерация 2: Матчинг по границам слов и магия Unicode

Проблема: Запрос "port" триггерил слова import, transport и report.

Фикс: Нужно матчить токены только на границах слов — после пробелов, пунктуации или camelCase-переходов.

Но стандартный якорь \b в JavaScript не работает с Unicode. Кириллица, CJK-символы, арабица — всё это для \b просто «не слова». Пришлось писать свой isWordStart():

const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
const LOWERCASE = /\p{Ll}/u;
const UPPERCASE = /\p{Cu}/u;

function isWordStart(text: string, pos: number): boolean {
  if (pos === 0) return true;
  const prev = text[pos - 1];
  if (!LETTER_OR_DIGIT.test(prev)) return true; // после пробела/пунктуации
  return LOWERCASE.test(prev) && UPPERCASE.test(text[pos]); // camelCase-переход
}

Кстати, Unicode property escapes (\p{L}, \p{Ll}, \p{Cu}) — это шикарная фича ES2018, про которую многие забывают. Они из коробки покрывают кириллицу, хирагану, деванагари и работают быстро во всех современных движках. Для менеджера, поддерживающего 26 языков, это критично.

Результаты для "local v" улучшились с 98 до 78. Лучше, но всё ещё мусор.

Итерации 3 и 4: Приоритет полей — главный прорыв

К 3-й итерации код превратился в патч поверх патча. Я остановился, отрефакторил всё на чистые фазы, но поведение не изменилось. И тут пришло осознание настоящей проблемы.

Проблема была не в алгоритме. Проблема была в том, что метаданные окна искались наравне с контентом.

Beetroot трекает приложения-источники: каждая запись хранит source_app (например, "VS Code") и source_title ("GitHub — Mozilla Firefox"). Эти метаданные содержат слова вроде "code", "local", "server" — те самые популярные слова, которые люди чаще всего и ищут. Когда все поля имеют равный вес, метаданные загрязняют выдачу любого технического запроса.

Фикс: Разделить поля на два уровня.

const PRIMARY_KEYS = ["content", "note"];      // Что пользователь реально скопировал
const SECONDARY_KEYS = ["source_app", "source_title"]; // Откуда он это скопировал

Я добавил early-exit логику: мы ищем в метаданных окна, только если ничего не нашлось в самом тексте.

Выдача для "local v" рухнула с 98 до 9 релевантных записей. Это было самое большое продуктовое улучшение за все два дня. Не хитрый алгоритм, не тюнинг порогов нечёткости — просто логическое разделение контекста.

Но радовался я недолго.

Итерации 5–7: Early-exit ломает монотонное сужение

С ранжированием полей поиск стал отлично работать для финальных запросов. Но процесс живого набора текста (as-you-type) был полностью сломан.

Красивый баг: Набираю "thai" → получаю 6 результатов (в контенте есть фразы вроде "Thailand money transfer"). Добавляю пробел и " mon" (получается "thai mon") → выдача меняется на 1 совершенно нерелевантный результат (какой-то email, где "mon" просто нашлось в метаданных окна).

Что произошло? Строка "thai" попала в Фазу 1 (поиск по контенту, 6 результатов). Добавление " mon" обнулило Фазу 1 (в контенте нет такой сплошной строки). Управление перешло к Фазе 2, которая пошла искать по метаданным и нашла мусор.

Монотонное сужение — базовое UX-ожидание пользователя, что каждый новый символ будет сужать текущие результаты, а не перекидывать его на совершенно другую выборку — было нарушено.

Итерация 8: Финальная мерж-архитектура со скорингом

Решение оказалось элегантным: перестать выбирать между фазами. Нужно запускать их все и мержить результаты на основе весов.

const hits: ScoredHit[] = [
  ...collectScored(contiguousSearch(items, query, PRIMARY_KEYS), 1.0),
  ...collectScored(wordStartTokenSearch(items, query, PRIMARY_KEYS), 0.75),
  ...collectScored(contiguousSearch(items, query, SECONDARY_KEYS), 0.5),
  ...collectScored(wordStartTokenSearch(items, query, SECONDARY_KEYS), 0.25),
];

// Дедупликация: оставляем лучший скор для каждого элемента
const best = new Map<number, ScoredHit>();
for (const hit of hits) {
  const existing = best.get(hit.item.id);
  if (!existing || hit.score > existing.score) {
    best.set(hit.item.id, hit);
  }
}

// Кешируем индекс Fuse, чтобы не перестраивать его на каждый чих
// и ВСЕГДА мержим нечёт��ие совпадения с низким весом
const fuseResults = ensureIndex(items).search(query);
for (const r of fuseResults) {
  if (!best.has(r.item.id)) {
    best.set(r.item.id, {
      item: r.item,
      score: Math.max(0.05, 0.15 * (1 - (r.score ?? 1))),
      matches: [...(r.matches ?? [])],
    });
  }
}

// Сортировка: сначала по релевантности, затем по свежести
return [...best.values()]
  .sort((a, b) => b.score - a.score || b.item.last_used.localeCompare(a.item.last_used));

Теперь у меня 5 безусловных уровней скоринга:

Фаза

Скор

Что матчит

Подстрока (Primary)

1.0

Точное совпадение текста в самом контенте

Word-start (Primary)

0.75

Раздельные слова по границам в контенте

Подстрока (Secondary)

0.5

Имя приложения или заголовок окна

Word-start (Secondary)

0.25

Мультисловный поиск в метаданных

Fuzzy (Fuse.js)

0.05–0.15

Опечатки (последний рубеж)

Теперь запрос "thai" возвращает 6 записей (Скор 1.0). А запрос "thai mon" возвращает те же 6, минус те, где нет "mon", плюс мелкие fuzzy-добавки снизу. Монотонное сужение восстановлено.

Итоговые метрики

Вся эта логика уместилась в 300 строк на TypeScript, покрытых 27 юнит-тестами.

Замеры через performance.now() показали в среднем 0.76 мс на запрос по базе из 1100+ записей.

Запрос

Выдача ДО (Fuse.js)

Выдача ПОСЛЕ (5-tier merge)

local v

98 результатов

9

lm st

71

1–2

port 80

13

1–2

timeout

~40

~8

Можно было бы оптимизировать дальше — например, делать early-exit для Фазы 1, если она уже вернула достаточно результатов. Но пока 0.76 мс в in-memory исполнении — это территория, где пользователь просто не заметит задержки.

Ретроспектива: что бы я сделал иначе?

  1. Рефакторить на 3-й итерации, а не костылить до 7-й. Чистое разделение на фазы сразу подсветило настоящую проблему — приоритет полей. Патчи поверх патчей скрывали корневую причину.

  2. Тестировать на реальных данных буфера с первого дня. Синтетические тесты типа items = [{text: "hello"}] бесполезны. Баги всплывают только на 5000-символьных логах с рассеянными буквами.

  3. Не отдавать ранжирование сторонней библиотеке. Fuse.js — отличный инструмент для нечёткого поиска, но он не должен единолично владеть вашим поисковым пайплайном. Используйте его как один из сигналов.

Самое большое улучшение поиска в Beetroot оказалось не алгоритмическим, а архитектурным. Разделение «что пользователь скопировал» и «откуда он это скопировал» убрало 80% визуального мусора.

Утилиты вроде Ditto и CopyQ избегают этих проблем благодаря строгому поиску подстрок. Это пуленепробиваемый, предсказуемый подход, который отлично работает годами. Но если вы хотите дать пользователю UX с толерантностью к опечаткам, умной подсветкой и приоритетом метаданных — готовьтесь к тому, что «просто подключить библиотеку из npm» не выйдет, и придется собирать свой пайплайн.

Если вам интересно пощупать получившийся поиск вживую, Beetroot доступен на GitHub. Проект полностью бесплатный, работает на Windows 10/11 и бережно относится к оперативной памяти.

И главное. Я честно признаюсь: я далеко не эксперт в разработке поисковых движков. Текущая 5-уровневая архитектура с мержем — это результат проб, ошибок и продуктовой боли, а не академических знаний. Мне реально очень интересно узнать, как такие задачи решают правильно.

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

  • Какие еще методы и алгоритмы для быстрого in-memory поиска на клиенте существуют?

  • Кто сталкивался с похожей проблемой («мусор» при поиске по гигантским JSON-блобам и логам на клиенте) и как вы её обходили?

  • Есть ли для подобных задач более изящные паттерны или структуры данных (например, какие-нибудь инвертированные индексы на клиенте), о которых я пока просто не знаю?

Заранее спасибо за ваш опыт и критику!