pg-kazsearch: как 20-летний инженер из Казахстана сделал первый полнотекстовый поиск для казахского языка
Стандартные поисковые движки прекрасно понимают английский, но казахский для них почти не существует. Один корень в казахском языке может принимать сотни форм, и обычные алгоритмы попросту не видят связи между ними.
Дархан Ахметов, founding engineer в американском AI-стартапе Nace AI с инвестициями от General Catalyst, решил закрыть этот пробел в одиночку — и сделал это на чистом Rust, с лексиконом на 21 863 основы и точностью поиска выше существующих решений на 23–26%. Получившийся open-source-проект pg-kazsearch уже заинтересовал один из крупнейших новостных порталов Казахстана. Дархан рассказал, как агглютинация делает казахский язык сложным для машин, почему обычный поиск по подстроке не работает, и что такое сингармонизм с точки зрения разработчика.
Ты работаешь в американском AI-стартапе на серьёзном технологическом уровне — что заставило тебя в свободное время заняться именно казахским поиском в PostgreSQL?
На самом деле всё началось как спор с друзьями-коллегами по nFactorial примерно год назад. Я тогда учил C и увлёкся тем, как устроены поисковые движки — провалился в эту «кроличью нору» и решил: а смогу ли я вообще сделать нормальный поиск для казахского? Спор был полушуточный, но зацепило меня по-настоящему, когда я понял, что речь не про «допилить настройку», а про то, что инструмента просто не существует. Для английского такие вещи есть десятилетиями. Для казахского — ничего. Вот этот пробел и стал настоящей мотивацией.
Расскажи, как ты вообще наткнулся на эту проблему — что было моментом, когда ты понял, что стандартные стеммеры не работают с казахским языком?
Когда я начал экспериментировать с полнотекстовым поиском в Postgres и подсунул ему казахский текст. Стандартные стеммеры (Snowball) казахский не поддерживают вообще — их для него никто не написал. Остаётся trigram, и на казахском он работает плохо. Конкретный момент: я искал слово в одной форме, а статья с тем же словом в другой форме не находилась. И я понял, что для казахского это не редкая осечка, а норма — потому что в этом языке слово почти всегда стоит в какой-то «нестандартной» форме.
Объясни простыми словами, что значит «агглютинативный язык» и почему именно это создаёт такую сложность для поиска — для тех, кто не лингвист и не разработчик?
Представьте, что слова собираются как конструктор. Есть корень, и к нему по очереди «приклеиваются» кусочки-суффиксы, каждый добавляет смысл: число, принадлежность, падеж и так далее. Отсюда и название — «агглютинация» буквально значит «склеивание».
Возьмём один корень — мектеп (школа). Из него вырастает:
мектеп — школа
мектептер — школы
мектептерім — мои школы
мектептеріміз — наши школы
мектептерімізде — в наших школах
мектептеріміздегі — те, что в наших школах
Для человека очевидно, что всё это — одно слово «школа» с добавками. Для обычного поиска мектеп и мектептеріміздегі — две совершенно разные строки, у них даже близко не одинаковый набор букв. Поэтому поиск по слову «мектеп» не находит текст, где написано «мектептеріміздегі». А таких форм у одного слова — десятки, в теории сотни. В английском у «school» форм по сути две: school и schools. Вот в этом вся разница и вся сложность.
Что такое BFS suffix-stripping и сингармонизм — и почему обычные методы поиска (как trigram) с этим не справляются?
Чтобы поиск понял, что мектептеріміздегі — это мектеп, нужно аккуратно снять всю цепочку суффиксов и дойти до корня. Вариантов, где «отрезать», много, и не все правильные. Я перебираю варианты отсечения слой за слоем — это и есть BFS, поиск в ширину — и на каждом шаге проверяю, законна ли получившаяся форма. Тут работают два разных языковых механизма.
Первый — сингармонизм. Это про гласные: внутри казахского слова гласные согласованы между собой, твёрдые с твёрдыми, мягкие с мягкими. Сравните қала-лар-да (в городах) и үй-лер-де (в домах) — смысл суффиксов одинаковый, но гласные разные, потому что подстраиваются под корень. Если я отрезал суффикс и форма нарушает сингармонизм — значит, отрезал неправильно, вариант отбрасывается.
Второй механизм — чередование согласных. Когда к слову, оканчивающемуся на глухой согласный, добавляется суффикс с гласной, конечный согласный озвончается: кітап (книга) → кітабы (его книга), п превращается в б. То есть в тексте я вижу «кітабы», но корень-то «кітап». Значит, при стемминге нужно не просто срезать «ы», но и восстановить исходный согласный — вернуть б обратно в п. Без этого шага «кітап» и «кітабы» так и остались бы для поиска разными словами.
Здесь часто спрашивают: зачем такие сложности, почему просто не искать подстроку? Логика вроде бы простая — все формы слова «мектеп» начинаются на «мектеп», значит, поиск по подстроке «мектеп» их все и найдёт. Но это ломается сразу с двух сторон.
Во-первых, корень сам меняется. Тот же кітап в тексте стоит как кітабы. Подстроки «кітап» в слове «кітабы» физически нет — буквы к-і-т-а-п там не встречаются, потому что п превратилось в б. Поиск по подстроке работает на уровне символов и не знает, что б и п здесь — один и тот же звук. Статью он просто пропустит.
Во-вторых, подстрока находит слишком много лишнего. Если искать «ат» (лошадь), подстрока выцепит и ата (дедушка), и атау (называть), и сағат (часы) — любое слово, где эти две буквы стоят хоть где-то, в том числе в середине совершенно не связанного слова. Выдача утонет в мусоре. У подстроки нет понятия границ слова или его структуры.
Trigram — это, по сути, более умная версия поиска по подстроке: текст бьётся на трёхбуквенные кусочки, и считается их пересечение. Это лучше грубой подстроки, потому что меряется похожесть, а не просто «есть/нет». Но корневая проблема та же — он работает с формой букв, а не со смыслом и грамматикой. Поэтому он одновременно и пропускает настоящие совпадения (когда корень мутирует), и подсовывает ложные (когда несвязанные слова случайно делят буквы). Мой бенчмарк — те самые 23–26% разницы — это ровно эта слабость, выраженная в цифрах.
Если коротко: поиск по казахскому — это не задача про строки, а задача про морфологию. Единственный честный способ понять, что кітабы и кітап — одно слово, это смоделировать, как язык вообще строит слова: снять суффиксы, учесть сингармонизм, обратить чередование согласных, сверить с реальными корнями. Никакой символьный «шорткат» сюда не ведёт. Именно поэтому стеммер и должен существовать.
Почему ты выбрал Rust и pgrx, а не что-то более привычное для таких задач — было ли это осознанным выбором или техническая необходимость?
Осознанный выбор, не случайность. Стемминг выполняется на каждом поисковом запросе, поэтому он обязан быть быстрым и не становиться узким местом. Rust даёт скорость уровня C, но без классических ошибок работы с памятью. pgrx позволяет писать расширения для Postgres на Rust нативно. И отдельный плюс: ту же самую Rust-логику стеммера я потом переиспользовал для плагина Elasticsearch через JNI-мост. Один движок — две платформы, без переписывания.
Расскажи про лексикон на 21 863 основы — откуда взялись эти данные и как ты их собирал и проверял?
Основы я взял из Apertium-kaz — это открытый лингвистический проект с морфологией казахского языка. Лексикон работает как страховка от overstemming — ситуации, когда алгоритм срезает слишком много и корень превращается во что-то неправильное. Сверяясь со списком настоящих основ, я не позволяю ему отрезать лишнее. По сути, это баланс: снимать суффиксы достаточно агрессивно, чтобы находить все формы, но не настолько, чтобы разрушить само слово. Покрытие в итоге около 80% токенов.
Вдохновлялся я отчасти турецким стеммером — для турецкого, родственного тюркского языка, наработки есть, в том числе академические, не просто Snowball. Но даже серьёзный, исследовательский стеммер там не идеален: он, например, заточен в основном под существительные и ограничен в охвате. При этом даже в таком виде он заметно улучшает поиск. Для меня это было важным сигналом: раз даже неполное решение настолько помогает, то для казахского, где вообще ничего не было, нормальный стеммер тем более нужен. Так что идею я взял, но реализацию делал под казахскую морфологию с нуля.
Цифры в твоём примере выглядят почти невероятно — 0 против 609 статей по одному запросу. Как ты сам объясняешь такой разрыв в качестве поиска?
Важно: это не значит, что чужой поиск «плохой» сам по себе. Он просто не понимает морфологию. Запрос «мектептеріміздегі мәселелер» — это слова в сложных формах. Поиск по точному совпадению строки не найдёт статьи, где те же слова стоят в других формах, а таких статей — большинство. Мой поиск приводит и запрос, и текст к общим корням, поэтому находит всё релевантное. Разрыв такой большой именно потому, что в казахском «другая форма слова» — это правило, а не исключение. На простом запросе из одного слова в начальной форме разница была бы небольшой. На живых, морфологически насыщенных запросах — огромной.
Ты сравнивал с pg_trgm на полном бенчмарке — расскажи, как был устроен сам эксперимент, чтобы результаты можно было доверять?
Тест на 2 999 реальных казахских новостных статьях с tengrinews.kz и 9 048 поисковых запросах. Метрики — стандартные для информационного поиска: Recall@10 (сколько релевантного попало в топ-10), MRR@10 и nDCG@10 (насколько высоко релевантное ранжируется). Принципиальный момент: оба метода — мой и pg_trgm — прогоняются на абсолютно одних и тех же данных и запросах, отличается только способ поиска. Иначе сравнение было бы нечестным. Результат: точность выше на 23–26% при скорости в 2,8 раза выше — 0,5 мс против 1,4 мс на запрос. Всё воспроизводимо, цифры из реального прогона.
Один из крупнейших новостных порталов заинтересовался внедрением — можешь рассказать подробнее, на какой стадии переговоры?
Скажу аккуратно, без преувеличений: интерес есть, но мы на ранней стадии. Я связался с командой одного из крупнейших новостных порталов, показал на их же контенте, как стеммер улучшает поиск, и получил содержательный отклик. До внедрения в продакшн пока не дошло — у них свои приоритеты и инфраструктура на Elasticsearch, под которую я как раз сделал плагин. Так что честнее назвать это живым диалогом и интересом, а не подписанной интеграцией. Не хочу выдавать желаемое за действительное.
Это open-source-проект — ты планируешь его монетизировать или это принципиально останется бесплатным вкладом в инфраструктуру казахского языка?
Проект open-source, и базовую инфраструктуру для казахского языка, по-моему, правильно держать открытой и бесплатной — в этом часть смысла. Это не исключает, что вокруг неё со временем могут появиться коммерческие вещи — поддержка, помощь с интеграцией, более сложные инструменты. Но сам стеммер как фундамент я хочу оставить доступным всем. Закрывать то, чего раньше вообще ни у кого не было, было бы как-то неправильно.
Какие дальше планы — масштабировать на другие тюркские языки, развивать дальше именно казахский, или это была разовая инженерная задача, которую ты для себя закрыл?
Сам подход — BFS-стемминг с проверкой сингармонизма — в принципе обобщается на другие тюркские языки, они структурно близки. Так что технически масштабирование реально. Но в приоритете сейчас — довести именно казахский до по-настоящему высокого качества и покрытия. Это точно не разовая задача, которую я для себя закрыл: язык живой, всегда есть что улучшать, и код открыт, так что строить на нём дальше может кто угодно, не только я.
Проект доступен на GitHub: https://github.com/darkhanakh/pg-kazsearch
Установка и релизы: https://github.com/darkhanakh/pg-kazsearch/releases