Функциональность поиска на Val Town не очень впечатляет. Сейчас в её основе лежит механизм ILIKE Postgres, работающий на основе алгоритма поиска подстроки: если искомое выражение в коде есть, оно выводится в результатах. Этот процесс не включает никакого ранжирования, и очень слабо поддерживает запросы из нескольких слов. Более эффективный поиск является одной из самых желанных для нас возможностей.

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

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

▍ Поиск по коду и естественно-языковой поиск


Типичная проблема готовых поисковых решений в том, что они предназначены для работы с английским и другими естественными языками. К примеру, вот несколько алгоритмов, которые действуют по умолчанию при стандартной настройке поиска по всему тексту (FTS, Full Text Search):

  • Удаление стоп-слов: из текста перед его индексацией удаляются слова вроде «the» и «it», поскольку их слишком высокая частота создаёт больше проблем, нежели они несут пользы.
  • Стемминг: эта функция, по сути, отсекает от слова окончание, приводя его к основе (при этом основа не обязательно будет совпадать с корнем слова, – прим. пер.). Например, слово «running» в этом случае превратится в «run», и при выполнении поиска «runs» вы также получите результаты из документа, содержащего слова «running».
  • Лемматизация*: некоторые поисковые индексы достаточно наворочены, чтобы соотносить наиболее распространённые слова с их синонимами. Благодаря этому, вы можете выполнить поиск «excellent» и получить результаты из документов, содержащих «great».

*Прим. пер.: по факту лемматизация подразумевает преобразование слова в его изначальную, словарную форму.

Всё это ведёт к тому, что получаемый из документа вектор слов, который вы сохраняете в индексе, от этого документа сильно отличается:

select * from to_tsvector('english', 'I am writing this example sentence');
--- 'exampl':5 'sentenc':6 'write':3

Проблема со всеми этими правилами в том, что они вносят в код хаос. К примеру, the в TypeScript не является стоп-словом. Это допустимое имя переменной, которое вы вполне можете искать. Границы слов тоже в этом случае другие, и подвергать стеммингу названия функций смысла нет:

select * from to_tsvector('english',
  'function stringifyNumber(a: number): string { return a.toString() }');
-- 'a.tostring':7 'function':1 'number':4 'return':6 'string':5 'stringifynumb':2

Это довольно плохой индекс: в нём есть слова, которые должны быть стоп-словами, например, function, и он не разделяет a.to.String() на два токена, потому что . по умолчанию не является границей слова.

▍ Полнотекстовый поиск


В Postgres есть расширение Full Text Search, которое поддерживается нашим провайдером хостинга, компанией Render. Я уже использовал FTS в других проектах, и при определённых масштабах этот механизм прекрасно работал. Можно попробовать задействовать Postgres для всего. Собственно, до сих пор мы так и делали – вытрясали из неё, всё что можно. Это фантастическая технология, которая имеет отличную документацию и полноценно поддерживается нашим провайдером.

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

Тем не менее в предыдущих проектах, где я использовал FTS, возникали проблемы с производительностью и масштабированием. К примеру, Observable в итоге перешёл на Elasticsearch. У нас есть куча val (фрагменты TS- и JS-кода, написанные и выполняемые в браузере на серверах Val Town, — прим. пер.), и мы испытываем пределы возможностей кластера Postgres, состоящего из одного узла. Сложно найти упоминания успешной реализации поиска по коду с помощью FTS, хотя, возможно, об этом просто не пишут. Я же не хотел применять этот подход в качестве изначального и решил оставить его про запас.

▍ pg_trgrm


Решение, которое мы предварительно запустили (soft-launch) в виде алгоритма поиска v2, основано на модуле pg_trgrm, реализующем в Postgres поиск по триграммам. Для поиска по коду такой подход должен вполне сгодиться. Расс Кокс в своей известной работе «Regular Expression Matching with a Trigram Index» от 2012 года рассказывает историю о том, как разработчики Google Code Search использовали триграммные индексы и специальную реализацию регулярных выражений. В новой системе поиска GitHub, помимо всяческих технологий, которым лично я завидую, также используется триграммный индекс. И у Sourcegraph тоже есть инструмент поиска на основе триграмм, который они позаимствовали у Google.

Мы же в использовании pg_trgrm преимущественно опирались на серию статей Стивена Гутеканста, посвящённую локальной индексации репозиториев в Postgres. Мы создали индекс GIN, применив gin_trgrm_ops к столбцу с текстом для поиска.

Пока могу лишь сказать, что это прекрасное решение для поиска с использованием регулярных выражений, но мы не опираемся на регулярные выражения – большинство поисковых запросов имеют свободную форму. Мы ранжируем результаты с помощью word_similarity, и было очень трудно заставить алгоритм производить ранжирование адекватным образом.

▍ Вселенная вариантов


Вариант Архитектура Язык Кол-во звёзд
Meilisearch Независимая Rust 41К
Typesense Независимая C++ 17К
Zoekt Независимая Go 493
ParadeDB Расширение Postgres Rust 3,2К
Sonic Независимая Rust 19,4К

Существуют специальные, ориентированные на код инструменты, но большинство из них являются закрытыми: поиск на GitHub работает прекрасно, но над ним явно работает отдельная команда с постоянным финансированием.

  • Сделанный Sourcegraph форк Zoekt довольно неплох, но при этом является пугающе нишевым и наложит на инфраструктуру лишние издержки по обслуживанию.
  • Окончательным и неизбежным решением нашей задачи может стать Elasticsearch. В нём нет специальной обработки кода, но зато есть широкие возможности кастомизации. Нас не вдохновляет тот факт, что придётся изучать настройку памяти в Java и вносить в приложение первое постоянное дисковое хранилище, а также дополнительный источник истины для наших данных. Возможно, мы сможем использовать Elasticsearch Cloud, чтобы избежать лишних хлопот с обслуживанием.
  • Meilisearch, будучи построенным на ✨Rust✨ со всеми его достоинствами, выглядит многообещающей альтернативой ES. Но акцент в нём сделан на увеличении скорости обработки, а не масштабируемости, и мы сомневаемся, что для инфраструктуры его использование создаст меньше издержек.
  • ParadeDB обещает все возможности Elasticsearch, но «только Postgres», что очень заманчиво. Жаль, но пока Render их расширение не поддерживает.

В общем, мы всё ещё работаем над своим решением. Поиск по коду вместо английского текста серьёзно усложняет задачу. Будучи небольшой командой, стремящейся сохранить простоту инфраструктуры и настройки сред разработки, а также удержать все данные в одном месте, мы стараемся избегать включения компонентов, требующих постоянного обслуживания. Неспроста большинство средних и крупных компаний создают под эту задачу целую команду, а не просто поисковый сервис.

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (3)


  1. TyVik
    21.04.2024 18:07
    +5

    А можно просто погрепать по ast. Да, это сложно, зато уже работает для множества языков и довольно шустро.


    1. victor-homyakov
      21.04.2024 18:07
      +1

      В WebStorm и других IDE от JetBrains давно есть аналогичная штука, называется Structural Search and Replace https://www.jetbrains.com/help/webstorm/structural-search-and-replace.html. Где-то в документации упоминалось, что там используется упрощённая, но более быстрая версия AST.


  1. molnij
    21.04.2024 18:07
    +3

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

    Интересное расширение функционала поиска кода помню у ReSharper'a когда поиск по "ReAl" подразумевал c разбиение по регистру и поиск каждого куска в рамках одного слова a-la ReadAll, ReadingAlternatives и т.п. Очень ускоряет процесс поиска именно в коде, но требует знания подобного синтаксиса. (Да, можно построить регулярку соответствующую этому же выражению, но это гораздо дольше, и хуже ложится в мышечную память) Ну и реализуется сравнительно просто. Индексировать такое больно, хотя вроде префиксное дерево где-то рядом должно быть, но обычный ilike, судя по описанию, должен справится в первом приближении. Я делал что-то похожее в рамках не очень большого объема кода - у нас справлялось без каких-то проблем.

    Ранжирование обычно прикручивается поверх уже найденного в таком случае, хотя про кластеризацию и т.п. уже не подскажу, совсем другой кейс.