PunchMan City verdict has implications for football integrity, says FAInquirerPalawan confirms ASF case, strengthens containment measuresThe Jerusalem PostUS Senate unanimously passes bipartisan resolution honoring American October 7 victimsBollywood HungamaNushrratt Bharuccha to undergo spine surgery: Team issues official statement amid accident reportsESPN DeportesCheco Pérez vuelve a Malasia, lugar de su primer podio en F1Observador DesportoOutdoors de apoio a CR7 e críticas a JJ surgem em LisboaSky TG24Festa dei Nonni, nonni cult di cinema, cartoni animati e serie da Lino Banfi a Coco e UpZDF heuteEntdecken Sie das ZDF-NachrichtenstudioХабрРусификация Omarchy Linux одним скриптомSportstarIndian hockey rocked by harassment complaints against umpire manager Gurinder Singh Sangha7sur7Mort du petit Wassim: Mohammed Taoussi condamné à la réclusion criminelle à perpétuitéBusiness AMWashington voert druk op: Europese Unie overweegt vrijgave van dieselvoorraden
The Daily Newsstand · Free, Always
Friday, October 2, 2026

50 лет эволюции поиска по коду: что под капотом кодинговых агентов

Translate

Когда мы просим Claude Code, Codex или Cursor «починить воот эту функцию», то сначала надо понять, где этот код вообще находится. В каком файле? В какой функции? Как она называется и кто её вызывает? Через какие еще системы пролетают байтики наших данных?

За последние полвека для этой задачи придумали огромное количество инструментов: grep, текстовые индексы, полнотекстовый поиск, ctags, LSP, AST, анализ потоков данных, графы, поиск по смыслу и многое другое. Самое забавное, что весь зоопарк с нами и почти ничего из этого не умерло.

В 2026 году поиск по коду в лучших кодинговых агентах по-прежнему состоит из алгоритмов и технологий, которым пять, десять, тридцать, а иногда и больше пятидесяти лет (grep вышел в 1973 году!). И в этой огромной статье я расскажу об эволюции этих технологий, истории и философии их создания, решаемых проблемах и способе работы.

Погнали!

Зачем зоопарк?

Все по делу — не потому, что индустрия так и не смогла написать один нормальный поисковик, а потому что «найти код» — это несколько принципиально разных задач.

Допустим, в проекте есть функция:

def verifySomething(user, password):

Если вопрос звучит как «где написано verifySomething?», достаточно найти текст. Если же мы спрашиваем «где объявлена именно эта функция и кто её вызывает?», текста уже мало - надо понимать области видимости, импорты, типы и то, что там вообще происходит. А можем спросить по смыслу, по форме или попросить найти путь любого байтика по системе.

И именно разные вопросы и порождают разнообразие решений.

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

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

И лучше всего история поиска по коду становится понятна, если идти примерно в том порядке, в котором возникали сами задачи, технологии решений и проявление их ограничений.

1. Текстовый поиск: grep и ripgrep

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

В Unix таким инструментом стал grep. Его написал Кен Томпсон в Bell Labs, а название пришло из строкового редактора под названием ed: в нём была команда g/re/p, которая означала «по всему файлу найти строки, подходящие под регулярное выражение, и напечатать их». Такой поиск требовался постоянно, а редактор ed загружал файл в память целиком и с большими файлами не справлялся, поэтому Томпсон вынес операцию в самостоятельную утилиту в 1973 году.

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

Например:

grep -R "verifyCredentials" .

Для исходного кода эта примитивность неожиданно оказалась огромным достоинством. grep не должен понимать Python, Go или Java. Ему вообще не важно, является ли найденный текст именем функции, комментарием или строковой константой.

Он просто ищет текст. Сегодня часто используется более современный инструмент ripgrep, команда которого называется rg:

rg "verifyCredentials"

Здесь важно не смешивать понятия. grep — это прежде всего подход: искать непосредственно в тексте файлов. ripgrep — современная и очень быстрая реализация этого подхода, заточенная в том числе под работу с деревьями исходников.

Почему такой примитивный поиск вообще до сих пор нужен

Главное достоинство прямого поиска состоит в том, что между файлом и результатом почти ничего нет.

Только что добавили:

func calcVatRate() {}

сохранили файл, сразу запустив rg можно сразу получить результат.

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

Поиск видит то, что реально лежит на диске сейчас.

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

Но прежде чем до него дойдём, стоит разобраться, почему современный прямой поиск вообще может быть таким быстрым.

Что ускоряет ripgrep

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

Представим, что мы ищем регулярное выражение:

\d+-timeout

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

-timeout

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

В англоязычной документации это часто называют literal extraction. По-русски здесь достаточно сказать: из регулярного выражения извлекают обязательные текстовые фрагменты (литералы).

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

Второе важное свойство связано с устройством регулярных выражений.

Возьмём шаблон, на котором многие движки регулярных выражений зависают:

(x+x+)+y

и строку:

xxxxxxxxxxxxxxxxxxxxxxxxxxxx

Символа y в строке нет, значит, совпадения быть не может. Но движок, работающий перебором с возвратами, понимает это не сразу: он пробует все способы распределить x между вложенными повторениями, а число таких способов растёт экспоненциально с длиной строки. На трёх десятках символов поиск может занять секунды или минуты. Это называется катастрофическим перебором с возвратами (catastrophic backtracking).

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

Если всё-таки нужны возможности вроде обратных ссылок:

(ab)\1

можно попросить ripgrep использовать PCRE2 (другой движок: умеет больше, но без гарантии линейного времени):

rg -P '(ab)\1'

Но это уже другой механизм и другой набор компромиссов.

Есть и третья оптимизация, на практике зачастую не менее важная: лучший файл — тот, который вообще не пришлось читать.

В обычном проекте полно каталогов вроде:

.git/
node_modules/
target/
dist/
generated/

ripgrep по умолчанию не заходит туда, где разработчик обычно не хочет искать: он пропускает скрытые файлы и каталоги (с точкой в начале имени), двоичные файлы и всё, что перечислено в .gitignore.

Звучит скромнее, чем SIMD или автоматы, но в node_modules/ обычного проекта легко набирается сотня тысяч файлов, и быстрее всего ищется в тех, которые не пришлось открывать.

Где текстовый поиск перестаёт помогать

У этой простоты есть очевидный предел. На вопрос: "где проверяются данные пользователя?" при функции "verifyLogPass" текстовый поиск ничего не может сделать. Общих байтов между запросом и кодом нет.

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

И именно из этой проблемы естественно вырастает следующая технология.

2. N-граммный индекс: как не читать весь репозиторий на каждый запрос

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

Сам принцип гораздо старше поиска по коду. В классических поисковых системах давно используется обратный индекс: вместо представления «документ → слова внутри него» строится таблица «слово → документы, в которых оно встречается».

Условно:

payment → payment.py, checkout.py, README.md
retry   → payment.py, worker.py
timeout → worker.py, config.py

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

Допустим, в программе есть "processPaymentRetry", разработчик может искать Payment, mentRet, часть пути, кусок строки или регулярное выражение - граница слова здесь уже не настолько естественна.

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

Что такое n-грамма и почему внезапно появляются триграммы

Последовательность из n соседних элементов называют n-граммой. Буква n буквально означает длину.

Если взять символы, то два символа — биграмма; три — триграмма; и так далее.

Идея намного старше современных поисковых систем и не имеет одного аккуратного изобретателя. Ещё Андрей Марков в начале XX века исследовал зависимости между последовательностями символов; позже похожие идеи стали фундаментом статистической обработки текста.

Для поиска по коду популярным практическим вариантом стали именно последовательности из трёх символов — триграммы.

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

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

Построим простейший триграммный индекс

Пусть в репозитории есть четыре файла:

f1 = src/agent.py
f2 = src/world.py
f3 = tests/test_agent.py
f4 = src/agentic_world.py

f1, f2 и так далее здесь просто обозначение file1, file2.

Возьмём текст моего канальчика про агентов:

AgenticWorld

и разобьём на все последовательности из трёх соседних символов:

Age
gen
ent
nti
tic
icW
cWo
Wor
orl
rld

Теперь при построении индекса можно для каждой триграммы записать файлы, где она встретилась:

Age → f1, f2, f4
gen → f1, f2, f3, f4
cWo → f2, f4
rld → f2, f4

Вот это и есть обратный индекс для триграмм. Теперь представим, что файлов не четыре, а миллион. Пользователь ищет:

AgenticWorld

Если эта строка действительно находится в файле, там обязаны присутствовать и её части: Age, gen, cWo, rld.

Можно посмотреть соответствующие списки:

Age:  f1 f2 f4
gen:  f1 f2 f3 f4
cWo:     f2    f4
rld:     f2    f4

и оставить файлы, присутствующие во всех нужных списках:

f2, f4

Вместо миллиона кандидатов осталось два - и теперь можно открыть именно их и уже обычным поиском убедиться, что вся строка действительно присутствует.

Почему индекс не может сразу дать окончательный ответ

Потому что наличие всех триграмм ещё не означает наличие исходной строки.

Age может находиться в имени одной функции, cWo — через тысячу строк в комментарии, а rld — в строковой константе в конце файла.

Поэтому работа делится на два этапа. Сначала дешёвым способом отбираем небольшое число файлов, которые могут подходить. Потом выполняется точная проверку только для них.

Это вообще очень важная общая философия поиска: не делать дорогую работу над всем корпусом, если сначала можно дешёво выбросить почти всё лишнее.

Тот же принцип в настоящем поисковике по коду

Zoekt — специализированный движок полнотекстового поиска по исходному коду. Он появился в Google примерно в середине 2010-х, а сегодня активно используется и развивается в экосистеме Sourcegraph. Цель Zoekt вполне практическая: быстро искать по большим Git-корпусам. В его документации прямо сформулирована задача получать результаты менее чем примерно за 50 мс даже на корпусах масштаба ОС Android или браузера Chrome.

В основе лежат триграммы, но Zoekt хранит не только информацию:

"cWo" встречается в file7

а ещё и позиции:

"cWo" встречается в file7 на смещении 182

В документации используется простой пример banana:

ban → 0
ana → 1, 3
nan → 2

То есть индекс знает не только факт присутствия фрагмента, но и его положение в тексте. Это позволяет отбрасывать ложные совпадения ещё раньше.

Если мы ищем AgenticWorld, а Age и rld находятся на расстоянии десяти тысяч символов друг от друга, они явно не образуют одну строку.

Позиционный индекс занимает больше места, зато сокращает объём работы при каждом запросе. И вот здесь появляется первый важный размен: можно потратить больше диска и времени заранее, чтобы потом каждый поиск был дешевле.

3. Разреженные n-граммы: почему GitHub решил сделать индекс ещё больше

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

Например, "processPayment" превращается в:

pro
roc
oce
ces
ess
ssP
sPa
Pay
aym
yme
men
ent

pro может присутствовать в огромном количестве файлов, ent — тоже. Их списки становятся оочень длинными, чтение этих списков стоит времени, а пользы для главной идеи индекса - отсеивания кандидатов - мало.

Когда GitHub создавал новый движок поиска по коду Blackbird, разработчики как раз столкнулись с этой проблемой. Blackbird — внутренний движок, лежащий в основе нового GitHub Code Search. В отличие от предыдущей реализации поверх Elasticsearch, его проектировали специально под поиск исходного кода.

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

GitHub называл такие структуры follow masks. Но на частых триграммах такая маска быстро заполняется, так как после них с ростом кодовой базы со временем встречается почти всё. В какой-то момент фильтр практически на любой вопрос отвечает «может быть», то есть перестаёт выполнять свою работу. GitHub описывает, что такие маски слишком быстро насыщались и оказались мало полезны.

В итоге в Blackbird пришли к разреженным n-граммам — sparse grams.

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

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

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

Это очень характерная инженерная оптимизация: экономить надо не абстрактную память, а тот ресурс, который находится на критическом пути запроса. В данном случае - скорость поиска.

4. Индекс всех подстрок: суффиксные массивы

После триграмм возникает естественный вопрос: а нельзя построить структуру, в которой поиск любой подстроки будет прямой операцией? Один из классических ответов — суффиксный массив. Современную форму этой структуры предложили Уди Манбер и Джин Майерс в 1990 году.

Идея очень красивая.

Возьмём "banana":

Выпишем все суффиксы:

banana
anana
nana
ana
na
a

и отсортируем:

a
ana
anana
banana
na
nana

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

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

Динамические структуры работают, но для поиска по коду обратные индексы часто оказываются проще: их удобно обновлять по частям, делить на сегменты, хранить на диске и пересобирать отдельно.

То есть «математически красивая структура» в жизни не всегда подходит по разным причинам. Здесь хочется сделать инженерный реверанс в сторону моей предыдущей статьи про Jev: в жизни многие рабочие архитектуры это Франкенштейнинг вместо академической чистоты.

5. Индекс быстрый, но может врать

До сих пор индекс выглядел как очевидная победа: потратили время один раз, зато потом не читаем весь репозиторий. Но как только появляется отдельный индекс, появляется ещё одно состояние системы, которое может разойтись с исходниками - это пересборка индекса.

Допустим:

12:00:00 добавили calcVatRate
12:00:01 сохранили файл
12:00:02 индекс ещё не обновился
12:00:03 поиск calcVatRate → 0 результатов

С точки зрения латенси запрос прекрасен, но он некорректный. Особенно опасно то, что отсутствие результатов выглядит как достоверный факт: «такой функции не существует».

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

Один из способов решить проблему — разделять относительно стабильное состояние и текущие изменения.

Например:

индекс состояния репозитория на commit abc123
+
изменённые файлы рабочей копии

Большую неизменившуюся часть проекта можно искать через готовый индекс. Файлы, которые изменились после построения индекса, проверять отдельно. Такой подход позволяет не перестраивать гигантскую структуру после каждого нажатия клавиши, но и одновременно не делать вид, что только что написанного кода не существует.

А как вообще читать огромный индекс

Когда индекс становится большим, возникает ещё одна уже системная задача.

Допустим, на диске лежит индекс размером в несколько гигабайт. Нужно ли перед поиском целиком загружать его в оперативную память? Не хотелось бы. Unix-подобные операционные системы позволяют отобразить файл в виртуальную память процесса с помощью mmap.

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

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

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

Ключевая мысль здесь такая: как только поиск становится инфрой, на его качество работают вся ОС, вплоть до устройства файлов, виртуальной памяти и поведения кэша операционной системы.

6. ctags: строки и код

Предположим, в проекте написано:

func ProcessPayment() {}

Текстовый поиск отлично отвечает: "где встречается последовательность символов ProcessPayment", но при поиске кода нам нужна не последовательность символов, а эта функция или что на нее ссылается.

И это концептуально разные вопросы. В проекте могут одновременно существовать метод ProcessPayment, тест TestProcessPayment, комментарий с таким вхождением, строковая константа, документация и одноименная функция в другом модуле. Иgrep честно принесёт вот это всё.

Потому чтобы перейти от текста к сущностям программы, нужен хотя бы минимальный разбор исходников. Один из самых старых и дешёвых способов — ctags. Первоначальный ctags написал Кен Арнольд для BSD Unix. Программа проходит по исходным файлам и строит индекс интересных программных сущностей — функций, типов, методов и других объявлений.

Условно:

ProcessPayment  src/payment.go  function
Payment         src/types.go    struct

Редактору после этого не надо искать объявление ProcessPayment по всему проекту. Можно посмотреть в таблицу и сразу открыть нужный файл.И это чрезвычайно простая и полезная идея, но возможности такого индекса ограничены.

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

И здесь мы переходим в следующую категорию.

7. LSP: проблема не только в поиске

До середины 2010-х хорошие редакторы (IDE) уже умели довольно много: перейти к определению функции, показать тип, найти ссылки, предложить автодополнение.

Но архитектурно всё это было неудобно. Есть огромное количество IDE, есть огромное количество языков. И если каждый редактор должен отдельно интегрировать каждый анализатор языка, количество связок быстро начинает расти как произведение числа IDE на число языков.

Именно из этой проблемы вырос Language Server Protocol — LSP. Важно: LSP не является поисковым алгоритмом и не является индексом.

Это протокол общения между IDE и процессом, который умеет анализировать конкретный язык. История протокола хорошо документирована Microsoft. До LSP похожие идеи уже использовались в OmniSharp для C# и в TypeScript Server. Когда VS Code пришлось интегрировать несколько разных языковых серверов с разными протоколами, команда начала проектировать общий язык общения. В 2016 году работа над LSP уже шла публично.

В результате редактор может задавать стандартные вопросы вроде:

перейти к определению
найти все ссылки
найти реализации
показать тип
переименовать сущность

А языковой сервер отвечает, используя знания конкретного языка. Для Go это может быть gopls, для Rust — rust-analyzer, для Python — один из соответствующих серверов и так далее. На этом уровне поиск впервые начинает по-настоящему понимать, что именно означают имена в коде.

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

8. SCIP: сохранение знания языкового сервера

LSP хорошо подходит для живой среды разработки: редактор запущен, рядом работает языковой сервер, пользователь задаёт запросы.

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

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

Одним из современных форматов для этого стал SCIP — Source Code Intelligence Protocol. SCIP был представлен в июне 2022 года как формат индекса для навигации по коду, в первую очередь для функций вроде «перейти к определению» и «найти ссылки». Он пришёл на смену более раннему формату LSIF в инфраструктуре Sourcegraph (компания и её одноимённый продукт: платформа для поиска и навигации по коду сразу во многих репозиториях).

После индексирования код можно представить набором фактов примерно такого вида:

здесь объявлена сущность X
этот диапазон ссылается на X
X имеет такой тип
здесь находится реализация
здесь находится импорт

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

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

Зато после такой подготовки вопросы, которые для grep принципиально неоднозначны, становятся почти обычными запросами к базе данных.

9. AST: сущности без имен

До сих пор в виртуальных примерах мы искали текст или программные сущности. Но бывает другая задача: "найди все вызовы console.log с одним аргументом".

Можно попробовать регулярное выражение.

И довольно быстро встретить:

console.log(user.name)

console.log(
    buildMessage(order)
)

// console.log(x)

"console.log(x)"

logger.log(x)

Человеку очевидно, что первые два примера — вызовы нужной функции, третий — комментарий, четвёртый — строка, а пятый вообще вызывает другой метод, а текст сам по себе этой структуры не знает.

Но компиляторы и анализаторы давно решают похожую проблему: они разбирают исходник в дерево синтаксических конструкций. Такое представление обычно называют AST — Abstract Syntax Tree, абстрактное синтаксическое дерево.

Условно вызов может выглядеть так:

вызов
  объект: console
  метод: log
  аргументы:
    ...

Теперь искать можно не последовательность символов, а такую форму дерева. Современный пример инструмента для этого — ast-grep. Он разбирает код и сопоставляет шаблон именно со структурой синтаксического дерева, а не с исходным текстом. Его документация прямо описывает подход как структурный поиск: шаблон должен соответствовать синтаксису программы, а пробелы и переносы строк перестают быть существенными.

Например, шаблон "console.log($MSG)" может найти разные варианты форматирования одного вызова, не путая их с комментариями.

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

Но у AST-поиска тоже есть принципиальная граница. Чтобы написать структурный запрос, мы уже должны примерно понимать, как выглядит то, что ищем.

На вопрос "где у нас реализована авторизация?" само по себе синтаксическое дерево не ответит. Зато если мы хотим "найди все обработчики, которые вызывают retry, но не оборачивают результат в withIdempotency"структурное представление становится чрезвычайно полезным.

10. Анализ потоков данных: разбросанные объекты

Теперь возьмём совсем другую задачу:

user_id = request.args["id"]

query = "SELECT * FROM users WHERE id=" + user_id

db.execute(query)

Где здесь проблема? В request.args, в конкатенации или db.execute?

По отдельности ни одно место не описывает всю историю. Нас интересует путь значения: оно приходит из HTTP-запроса, присваивается user_id, затем попадает в строку SQL и в итоге передаётся в базу данных.

То есть объект поиска — уже не точка в исходнике, а маршрут через программу. Для таких задач нужен анализ потоков данных. В задачах безопасности особенно распространён его вариант, который по-английски называется taint analysis. На нашем великом и могучем гораздо полезнее объяснить смысл: анализ распространения недоверенных данных.

Система различает четыре понятия.

  • Источник — место, откуда в программу попадает потенциально опасное значение, например пользовательский ввод

  • Опасная операция — место, куда такое значение попадать не должно: выполнение SQL, eval, вызов командной оболочки

  • Обезвреживание — проверка или преобразование, после которого значение считается безопасным

  • Правила распространения — как недоверенность переходит от одного значения к другому

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

Теперь вопрос можно сформулировать не как "где встречается db.execute", а "существует ли путь, по которому значение из HTTP-запроса попадёт в db.execute, не пройдя безопасное преобразование?"

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

То есть цена растёт ещё сильнее. AST спрашивает не про символы, а "где конструкция такой формы", а здесь "как значение может попасть отсюда воот сюда?".

11. Полнотекстовый поиск и BM25: почему нельзя просто взять обычный поисковик

До сих пор мы почти не обсуждали ещё одну важную часть поисковой системы.

Допустим, "rg user" нашёл 12 тысяч совпадений. Формально поиск прошёл идеально: все места найдены. Практически такой список почти бесполезен, потому что нужно решить, какие результаты важнее и должны быть выше.

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

Одна из самых известных — BM25 (Best Matching 25, где 25 - это номер самой успешной формулы), выросшая из семейства моделей Okapi в исследованиях информационного поиска в 1980–1990-х. В упрощённом виде BM25 делает довольно здравые вещи. Редкое слово считается более информативным, чем слово, встречающееся почти везде. Десятое повторение слова в документе даёт меньше новой информации, чем первое.

Длина документа учитывается, чтобы огромный файл не выигрывал только потому, что в нём вообще много текста. Для обычной статьи или веб-страницы это работает замечательно.

С кодом начинаются странности.

Допустим, есть две строки:

def process_payment(order):

# payment flow should process refunds first

После простого разбиения на слова обе части содержат:

process
payment

Комментарий может получить прекрасную оценку релевантности, хотя разработчик искал функцию. Ещё интереснее с идентификаторами: "processPaymentRetry" если оставить это одним термином, запрос payment его не найдёт.

Если разбить:

process
payment
retry

мы улучшим поиск по словам, но потеряем часть информации о полном имени.

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

Именно поэтому одно из первых поколений GitHub Code Search на основе Elasticsearch в итоге уступило место специально построенному Blackbird. Не потому, что полнотекстовый поиск или BM25 плохие, а потому что просто код имеет слишком много структурных особенностей, которые для поиска по обычному человеческому тексту второстепенны.

12. Векторный поиск: ищем по смыслу

Вернёмся к примеру:

def verifyCredentials(...):

Пользователь спрашивает про авторизацию и для grep это тупик.authorization, auth или login в нужном месте могут вообще не встречаться, и здесь нужен способ сравнивать не символы, а смысл.

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

Модель преобразует фрагмент кода в набор чисел — вектор. То же самое происходит с текстовым запросом. Если модель обучилась отражать смысл, запрос про проверку авторизации и функция verifyCredentials могут оказаться близкими в этом числовом пространстве, несмотря на отсутствие общих слов.

Это крутая способность, которой у прямого текстового поиска просто нет. Но как только векторов становится миллион, появляется новая инженерная задача.

Самый простой способ найти ближайший — вычислить расстояние от запроса до каждого вектора. При миллионе элементов — миллион сравнений. При сотнях миллионов — уже совсем неприятно. Так возник целый класс алгоритмов ANN (Approximate Nearest Neighbors, приближённый поиск ближайших соседей): они готовы иногда пропустить часть подходящих результатов, зато не просматривают весь корпус.

Чаще всего в этом контексте встречаются три названия: HNSW, IVF и PQ. Первые два и есть алгоритмы ANN: они решают, как не сравнивать запрос со всеми векторами, PQ решает соседнюю задачу: как удешевить хранение и сравнение самих векторов. На практике их комбинируют.

HNSW (Hierarchical Navigable Small World) — многоуровневый граф. Верхние уровни разреженные и дают большие переходы, нижние плотнее и точнее. Запрос начинает сверху, быстро приближается к нужной области и спускается вниз, как по дорожной сети: трасса, районные дороги, локальные улицы. Уровень каждой вершины выбирается случайно при построении; сам поиск идёт по готовому графу и хорошо сочетает скорость и полноту, но требует много памяти на связи

IVF (Inverted File Index) — векторы заранее разбиты на кластеры, приходящий запрос определяет ближайшие кластеры и ищет только в них. Чем больше таких кластеров проверяется, тем выше полнота и тем медленнее поиск

PQ (Product Quantization, квантование произведением) — сжатие векторов. Вектор разбивается на части, каждая заменяется номером ближайшего представителя из выученного набора. Вместо исходных чисел хранится короткий набор кодов, и сравнение идёт прямо по ним, здесь зжатие неточное, часть качества теряется

13. Главная проблема смыслового поиска

В кодовом поиске быстро всплывает главный неприятный вопрос: что именно превращать в один вектор? Строку, функцию, класс, файл, каждые 500 символов?

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

Поэтому код разбивают на осмысленные фрагменты — чанки (англицизм прижился как есть). Границами обычно служат функции, методы, классы и другие логические блоки, иногда с небольшим соседним контекстом. Вместе с вектором полезно хранить служебную информацию:

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

Качество разбиения может оказаться не менее важным, чем алгоритм поиска по векторам.

Похожесть — это ещё не правильность

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

Десять таких:
withRetry(withIdempotency(handler))

и один:
withRetry(handler)

На запрос «где реализованы повторные попытки для платёжных вебхуков?» смысловой поиск с удовольствием принесёт десять правильных обработчиков: они прекрасно соответствуют запросу. Проблемный же отличается именно тем, что нужной логики не содержит. А при отладке мы чаще всего ищем как раз это: что выбивается из общего шаблона и где чего-то не хватает. Смысловая близость здесь может даже мешать, потому что наверх поднимаются самые типичные и правильные примеры.

Поэтому эмбеддинги не заменили grep, AST и символьный поиск. Они решают другой класс задач — «примерно понимаю, что делает нужный код, но не знаю, как он называется» — и для него чрезвычайно полезны.

14. Векторный индекс тоже надо обновлять

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

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

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

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

15. Карта репозитория

Даже идеальный поиск отдельных функций оставляет ещё одну проблему. Допустим, кодовый агент видит функцию:

def charge_order(...):

но не знает, что рядом существует Gateway, запись делается через Ledger, а повторные запросы фильтруются выше в middleware. Передать модели весь миллион строк нельзя, поэтому нужен компактный обзор структуры проекта — карта репозитория:

условно:
### 
billing/service.py
    PaymentGateway
    TaxCalculator
    Ledger

payments/gateway.py
    PaymentGateway
        charge()
        refund()

models/order.py
    Order

Для огромного проекта даже такой список слишком велик, так что надо ещё выбрать, какие сущности важнее.

Интересный реальный пример — Aider. В 2023 году он описал новую реализацию карты репозитория на основе Tree-sitter: раньше для этого использовался ctags, но Tree-sitter дал более богатую информацию о структуре исходников.

Что такое Tree-sitter и зачем он здесь появился

Tree-sitter — библиотека инкрементального синтаксического разбора. Обычный синтаксический анализатор получает файл и строит дерево.

В IDE проблема другая: пользователь только что изменил три символа, и через несколько миллисекунд анализ надо выполнить снова. Tree-sitter умеет эффективно обновлять существующее дерево после локального изменения, вместо того чтобы каждый раз воспринимать файл как совершенно новый. Он рассчитан на быструю работу, чтобы использоваться прямо во время набора текста, и старается сохранять полезное дерево даже в присутствии синтаксических ошибок.

Aider использует Tree-sitter, чтобы извлекать из исходников определения и ссылки на программные сущности. Затем строится граф, где части проекта связаны между собой.

Граф может оказаться слишком большим для контекста модели, поэтому Aider ранжирует его элементы и отбирает самые важные так, чтобы карта уложилась в заданный бюджет токенов. Документация Aider описывает это так: файлы становятся узлами графа, зависимости — рёбрами, а алгоритм ранжирования выбирает наиболее значимые части репозитория. Используются идеи, родственные PageRank — алгоритму ранжирования в поиске Google.

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

16. Git: ещё один поисковый индекс

До сих пор основные вопросы были о текущем состоянии кода. Но при отладке очень быстро появляются вопросы исторические вопросы: когда то-то появилось, кто именно и когда убрал проверку и что было раньше на этом месте.

Ни векторный поиск, ни AST текущего проекта на это не ответят. Но у нас уже есть инструмент сохранения всей истории и это git.

Например, запрос:

git log -S'idempotency_key'

ищет коммиты, в которых изменилось количество вхождений указанной строки. На самом деле, с историей гита можно проделывать множество операций. Git даже использует для семейства таких операций выразительное название pickaxe — буквально «кирка»: раскапывать историю в поисках места, где что-то появилось или исчезло. Официальная документация отдельно описывает -S, -G и связанные режимы.

Это тоже поиск по коду, но с другой осью поиска — по времени время.

Иногда при расследовании бага один git log -S полезнее самой умной векторной базы, потому что реальный вопрос может звучать не "где", а "когда" и "зачем".

17. Что делать с результатами

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

И внезапно появляется новая проблема: что показывать первым?

Предположим, rg user возвращает 12 тысяч результатов - с точки зрения полноты всё отлично, но для человека или агента — ужас.

Поэтому в поисковой системе важно разделять две задачи: найти кандидатов и отсортировать их по полезности. Это совсем не одно и то же. Поверх этого может работать отдельная обученная модель, которая смотрит на небольшой набор кандидатов и переставляет их местами.

В итоге современная система поиска может выглядит примерно так:

текстовый поиск ───────────┐
поиск по смыслу ───────────┤
программные сущности ──────┼── объединение ── ранжирование ── ответ
граф проекта ──────────────┤
история Git ───────────────┘

И это не архитектурный провал. Наоборот, преимущество возникает именно потому, что эти способы поиска ошибаются по-разному. Если два поисковика находят одно и то же и пропускают одно и то же, второй почти ничего не добавляет, гораздо интереснее объединять системы с разными сильными сторонами.

Так что в итоге победило?

Ничего. И это, пожалуй, самый интересный результат.

grep не проиграл индексам. Индексы не проиграли LSP. LSP не сделал ненужным AST. AST не заменил анализ потоков данных. Векторный поиск не уничтожил текстовый. А git вообще отвечает на вопрос, который почти никто из остальных и не пытался решать.

Потому что у каждого своя задача.

Подход

Зачем появился

Что умеет лучше всего

Где упирается в предел

grep / ripgrep

быстро найти известный текст в файлах

точное совпадение прямо в текущем состоянии проекта

не понимает смысл и на огромном корпусе должен много читать

n-граммный индекс

не перечитывать весь корпус при каждом запросе

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

индекс надо строить и поддерживать свежим

разреженные n-граммы

уменьшить цену слишком частых ключей

сильнее сокращают число кандидатов

индекс становится сложнее и больше

полнотекстовый поиск / BM25

ранжировать результаты по словам

запросы, где важна лексическая релевантность

код плохо превращается в обычный документ

ctags

быстро находить объявления

дешёвая навигация по сущностям

плохо разрешает неоднозначные ссылки

LSP

отделить анализ языка от конкретного редактора

определения, ссылки, типы, реализации

требует живого языкового анализа и состояния проекта

SCIP и похожие индексы

сохранить знания анализатора заранее

навигация по большим корпусам без постоянного языкового сервера

дорого строить корректный индекс

AST

отличить структуру программы от текста

точный поиск конструкций, рефакторинги

форму искомого кода надо примерно знать заранее

анализ потоков данных

искать путь значения через программу

уязвимости и сложные зависимости между местами

тяжёлый статический анализ

поиск по смыслу

искать код без совпадения названий

«знаю, что делает, но не знаю, как называется»

похожесть не равна правильности

карта репозитория

дать компактное представление большого проекта

понимание структуры и выбор контекста

структурная важность не равна релевантности задаче

Git

искать по прошлым состояниям кода

происхождение и история изменений

ищет во времени, а не объясняет смысл текущего кода

сочетание подходов

использовать независимые сигналы

сложные реальные запросы

больше инфраструктуры и логики ранжирования

Именно поэтому спустя пятьдесят лет в прорывных инструментах кодонаписания всё ещё запускается grepи его разновидности. Он остался не потому, что никто не придумал ничего умнее. Он просто идеально отвечает на свой вопрос. Как и все остальные:

  • grep: где прямо сейчас находится этот текст?

  • N-граммный индекс: в каких файлах он, скорее всего, находится, чтобы не читать весь корпус?

  • LSP и символьные индексы: что это за программная сущность и где ссылки именно на неё?

  • AST: где встречается конструкция такой формы?

  • анализ потоков данных: может ли значение пройти отсюда сюда?

  • поиск по смыслу: где код, похожий по смыслу на то, что я описал?

  • карта репозитория: как примерно устроен проект и какие его части связаны?

  • git: как это место стало таким?

У каждого вопроса своё представление кода.

Десятилетиями строились всё более хорошие поисковые примитивы: grep, индексы, языковые серверы, синтаксические деревья, графы, Git, смысловой поиск. А потом благодаря LLM появилась система, которая может все объединить и сама решить, что именно из этого использовать и как.

Примитивы остались, но сам поиск изменился.

Но это уже совершенно другая история.

Спасибо!

Мой канал про агентов, LLM, продукты и людей: Agentic World и другие статьи:

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.