В этой технической статье мы на практике разберёмся, что такое RAG, распарсимMDN Web Docs, научимся готовить эмбеддинги, заполним ими векторную базу данных и напишем свой MCP сервер с гибридным векторным и полнотекстовым поиском. Зальём всё получившееся добро наHuggingFace,GitHubиNPM, и настроим автоматическое обновление данных.
Внутри будет много пошаговых инструкций и примеров кода на Bun + TypeScript.
Скриншот вместо тысячи слов:
Retrieval-Augmented Generation (RAG) – это процесс, при котором ответ LLM обогащается внешними данными, будь то корпоративная документация, личная база знаний или поиск в интернете.
В нашем конкретном случае внешними данными будет являться целый MDN, но полностьюлокально и оффлайн. Все LLM конечно же в той или иной степени уже обучались на основе этого открытого источника данных, но их "память" ограничена конкретной датой рождения модели.
Мы будем делать RAG, завёрнутый в MCP:
Но обо всём по порядку.
Интересующие нас данныележат на GitHubв довольно специфичном формате Markdown со своими наворотами. Собирается всем известный сайт с документацией с помощью собственного инструментаRari.
Опишем интересующие нас файлы вcheckout.txt:
И заберём только то, что нужно, не выкачивая весь репозиторий:
Git clone:
- -depth=1– только последний коммит.
- –filter=tree:0– только коммиты без дерева и блобов.
- –no-checkout– без записи файлов.
Gitsparse-checkout:
- set --no-cone --stdin < checkout.txt– собственно, наш список файлов.
В итоге получаем:
текстовой информации в Markdown.
Нам необходимо:
- Превратить Markdown в старый добрый plain text, потому что с точки зрения токенизации и векторизации данных все эти звёздочки, скобочки и прочие специальные символы являются хоть и семантическим, но всё же лишним шумом.
- Разбить текст на "чанки" – осмысленные куски, целые параграфы с заголовками, списки и примеры кода. Забегая вперёд скажу, что часто "эмбеддят" по 512 бездушных токенов с нахлёстом, дабы не растерять контекст, но MDN настолько хорошо структурирован, что нас это не коснётся.
Возьмём известную библиотекуmarked:
Из которой нам интересен методlexer, возвращающийдревовидный список токенов, например, таких:
по которому мы и будем рекурсивно итерироваться.
Типичный документ на MDN выглядит так:
Этот---блок с метаданными называетсяFrontmatter, и нам необходимо достать из него полеtitle, которое мы будем использовать вместо отсутствующего заголовка первого уровня:
Наш рекурсивныйprocessTokens, сильно упрощённо для наглядности:
Приводить весьисходный код чанкерая не стану, отмечу лишь:
- Наши чанки будут полноценными осмысленными секциями от "заголовка до заголовка".
- Сами# Headingзаголовки мы будем заменять наHeading:\n\n.
- Заголовки разных уровней будем склеивать вHeading 1 - Heading 2:\n\nдля пущего контекста последующих абзацев.
- Все ссылки,strong,emи прочее форматирование будем оставлять в виде простого текста содержимого.
- Таблицы будем выворачивать наизнанку в плоские списки.
- Сложныеdefinition-спискибудем сплющивать в обычные плоские с минимумом отступов.
- Тройные обратные кавычки у блоков с кодом будем заменять наExample:\n\n.
- Всевозможные абы как написанные{{…}}-шаблонысо случайными пробелами и разными кавычками будем вычищать регулярными выражениями. Наверняка где-то есть настоящий парсер на JS, но я просто лениво описал их все методом грубой силы, пока не перестал падать гард с/{{[^}]+?}}/.test(text).
- Не несущие смысловой нагрузки секции типа "See also" будем пропускать целиком.
Итого,подобный файлпревращается в следующие чанки:
Текст мы получили, теперь нужно сделать из него эмбеддинг – векторное представление. Это, по сути, массив чисел, по которому можно вычислить, например, "косинусное сходство" с другим вектором. Что-то типа такого:
Примерно так, максимально упрощённо, и работает векторный поиск. Поиграйтесь с числами векторов чтобы понять их влияние на результат.
Для преобразования текста в вектор существует множество специальных embedding-моделей, мы же остановим выбор наBGE-M3– отлично справляется с длинными текстами технической документации до 8192 токенов, упаковывая их в 1024 измерения.
Измерения? Какие измерения?..
Эмбеддинг любого текста будет выглядеть как массив из 1024 чисел – измерений – всевозможных аспектов и смыслов от лингвистической морфологии до реляционных аналогий. Именно поэтому эмбеддинги для RAG создаются LLM, которые "понимают текст". Также существуют т.н. Matryoshka-модели, которые сортируют эмбеддинги таким образом, чтобы можно было взять первые, например, 256 элементов массива, и всё равно получить "самое важное" без существенной потери качества.
Скучно? Практика!
Будем использоватьбиндингик широко известному inference-движкуllama.cpp:
Все муки выбора нужного GPU/CPU бэкенда уже автоматизированы внутри, загруженная модель займёт около ~655 MB VRAM/RAM.
Очень важно, чтобы embedding-модель для оригинального и поискового векторов была идентична, от общей натренированности до точности чисел с плавающей точкой. Поэтому для наших нужд сделаемквантизированную Q4_K_M версию, которую будем использовать всегда и везде.
Если вспомнить нашу диаграмму из начала статьи, то нам нужна база данных как для векторных, так и для текстовых данных.
Возьмёмбиндингик популярнойLanceDB:
На выходе получим папку./db/mdn.lanceсо всем необходимым содержимым. В следующий раз можно сделатьdb.openTable(DATASET_TABLE), принцип понятен.
Процесс заполнения базы данных называется "ingesting".
Bun предоставляет удобный Async Iterable по глобу, чем мы и воспользуемся:
Результат недетерминированный, директории и файлы идут не в алфавитном порядке. Иногда это важно, и можно воспользоватьсяglob.scanSync()с последующей сортировкой.
Итак, все наши чанки "от заголовка до заголовка" благополучно влезли в лимит 8192 токенов эмбеддинга у BGE-M3. А если бы нет? Ну, llama.cpp бы предусмотрительно упал, и нужно было бы что-то придумывать. Например, склеивать абзацы одной секции только "пока влазит". А остальные выносить в такую же, но рядом, с повтором заголовка.
А как узнать количество токенов в тексте? Это далеко не всегда просто количество слов, разделённых пробелами. Всё несколько сложнее:
Токенайзерунеобходимо скормить настоящиеtokenizer.jsonиtokenizer_config.jsonот нашей конкретной модели.
Сделаем пробный векторный поиск:
nearestTo(vector)как раз ищет похожие на наш запрос векторы.
Принудительныйasпотому что хорошего места для втыкания дженерика я не нашёл.
Теперь сделаем "обычный" полнотекстовый (LanceDB использует алгоритмBM25) поиск, но сначала создадим индекс текстовой колонки:
К слову, будь у нас миллион векторов и остро стоял вопрос производительности, имело бы смысл создать также и векторный индекс, с квантизированными копиями:
В свою очередь, гибридный поиск совмещает в себе общие результаты, которые отправляются в "Reranker" – специальный алгоритм, или даже целая LLM, который объединяет и ранжирует найденное.
Воспользуемся встроенным Reciprocal Rank Fusion (RRF), который как раз хорошо подходит для гибридного поиска, в отличие от сравнения "score" из разных источников в лоб:
Таким образом, мы получаем лучшие результаты из обоих миров, что особенно важно для такой технической документации, как MDN.
Базу мы заполнили, и она уже даже отвечает на запросы. Как сделать так, чтобы LLM могли делать это самостоятельно?
Model Context Protocol (MCP) – протокол, с помощью которого т.н. "MCP host" (приложения/серверы типа LM Studio, Claude Desktop и даже llama.cppс недавних пор) может общаться со всевозможными инструментами, от чтения/записи локальных файлов до хождения в интернет.
LLM умеют вызывать инструменты, но не общаться по MCP напрямую. Вместо этого приложение/сервер транслирует всё в понятный для LLM формат. Cписок доступных инструментов попадает прямо в prompt, позволяя модели самостоятельно принимать решения об их вызове. К сожалению, формат не всегда является стандартным JSON по примеруOpenAI Tools– да-да, те самые танцы от ковыряния Jinja-шаблонов до многочисленных PR в inference-движок для только вышедших LLM (привет, Gemma 4).
Воспользуемся официальным@modelcontextprotocol/sdk(со страшными импортами пока ждёмv2):
Мы создали сервер и зарегистрировали в нём инструмент с мета-информацией, которую будет учитывать LLM как для принятия решения, так и использования:
- description– общее описание инструмента.
- inputSchema– обязательная схема входных параметров, очень важно доходчиво описать каждое поле.
- outputSchema– опциональная схема выходных результатов. Если наша цель просто передать текстовую информацию произвольного формата обратно пользователю через LLM, то достаточноinputSchema+content. Для структурированных данных, например, JSON, нужен ещё иstructuredContent.
Сервер нужно соединить с доступным транспортом, например, STDIO, Streamable HTTP или Server-Sent Events (SSE). Для наших локальных нужд максимально подходит юниксовый до невозможности STDIO:
- stdin– входные данные.
- stdout– выходные.
- stderr– ошибки.
В котором буквально поднимается дочерний процесс в режиме ожидания:
Как видим, внутри протокол представляет собойJSON-RPC 2.0.
Делаем настоящий вызов:
Заменяем тестовую заглушку на настоящие вызовы нашего RAG и вписываем сервер вmcp.jsonтого же LM Studio:
Запускаем, задаём первый вопрос в чате и понимаем, что чёткое описаниеqueryвinputSchemaкрайне важно. Пока я остановился на таком:
Natural language query for hybrid vector and full-text search
Однако вынес это в переменную окружения для тонкой настройки под каждую конкретную LLM, её температуру и прочие параметры. В больших серьёзных RAG для этого есть специальный шаг "Rewriter", который переписывает запрос в один или даже несколько заведомо более подходящих и качественных.
HuggingFace
Итак, у нас уже есть:
- исходный код
- pre-ingested датасет (артефакт LanceDB)
И если с исходным кодом всё понятно – выкладываем наGitHub, а собранный пакет публикуем вNPM, то что делать с датасетом? ~260 MB бинарных данных хранить в NPM наверное и можно, чисто технически, но звучит не очень.
Оказывается, наHuggingFaceпомимо, собственно, моделей, выкладывают и различныедатасеты. А снедавних порLanceDB как раз стал одним из родных форматов, с красивым Dataset Viewer прямо в карточке датасета.
Создаёмрепозиторийи пробуем залить:
Хранить данные вdata/– общепринятый формат по умолчанию, но можно настроить ипо-своему.
А как теперь обычному пользователю забрать датасет к себе? Можно, конечно, тоже заставить установитьhuggingface_hubи сделатьhf download, но лучше воспользуемся официальной библиотекой@huggingface/hub:
А ведь надо ещё забирать и нашу embedding-модель.
Входной точкой библиотеки сделаем такое:
Теперь пользователь может как заранее скачать/обновить датасет, так и запустить сервер отдельными командами.
Скачиваться всё будет в стандартный кэш HuggingFace, по умолчанию~/.cache/huggingface, для которого, кстати, есть полезная командаhf cache pruneдля очистки старых ревизий.
Скачать – скачали, а путь?
Многовато кода, но по факту это просто брожение по подобной файловой структуре с помощью официальных утилит:
СgetModelPath()всё аналогично. С симлинками, к слову, есть неприятный детскийбаг, но показывать костыль я, конечно же, не буду.
Autoupdate
На моём Apple Silicon M2 Max 12/30 создание датасета с нуля занимает полчаса. Макбук пыхтит и шумит, но резво создаёт эмбеддинги и заполняет таблицу.
Для того, чтобы сделать то же самое силами бесплатного раннера GitHub Actions без GPU (ну, а почему бы и нет) по моим грубым линейным прикидкам потребуется около 14 часов. Алимиту нас 6.
Нужно сделать точечное обновление только изменённых с предыдущего раза файлов на MDN. Расчёт на то, что, скажем, раз в месяц вообще все файлы документации никто не трогает.
Для начала добавим в таблицу новую колонку:
И будем записывать туда кратчайший путь к обрабатываемому файлу относительноdata/files/en-us/web/.
В запросах явно укажем имена колонок для поиска:
Далее, добавим файлcache.jsonв корень репозитория, и будем записывать хеш для каждого файла:
Ультрабыстрого некриптостойкого Wyhash по умолчанию должно хватить с головой, нам нужно просто проверить изменился файл или нет. На всякий случай закомментировал альтернативы.
Если файл новый или был изменён, то сначала мы удаляем все чанки-строки из таблицы, которые к нему относятся:
А затем создаём новые эмбеддинги и добавляем в таблицу строки:
Если файл был удалён, то просто удаляем строки.
LanceDB на каждый наш чих создаёт файлы в папках_transactions/и_versions/, которые необходимо подчистить, заодно и текстовый индекс обновим:
Пишем свой uploader:
Важно убедиться, чтоdata/mdn.lance/иcache.jsonточно обновляются одновременно в одном коммите.
Добавляем Cron в нашGitHub Workflow:
Запускается или в первый день каждого месяца (00:00 UTC) самостоятельно, или руками.
- https://github.com/deepsweet/mdn
- https://www.npmjs.com/package/@deepsweet/mdn
- https://huggingface.co/datasets/deepsweet/mdn
Спасибо всем, кто дочитал, для меня это было увлекательное путешествие и серьёзное исследование.Всегда любили буду любить писать инструменты для веб-разработки и не только.
В данный момент я нахожусь в активном поиске работы –резюме.