Привет.
В этой технической статье мы на практике разберёмся, что такое RAG, распарсим MDN Web Docs, научимся готовить эмбеддинги, заполним ими векторную базу данных и напишем свой MCP сервер с гибридным векторным и полнотекстовым поиском. Зальём всё получившееся добро на HuggingFace, GitHub и NPM, и настроим автоматическое обновление данных.
Внутри будет много пошаговых инструкций и примеров кода на Bun + TypeScript.
Скриншот вместо тысячи слов:

RAG
Retrieval-Augmented Generation (RAG) – это процесс, при котором ответ LLM обогащается внешними данными, будь то корпоративная документация, личная база знаний или поиск в интернете.
В нашем конкретном случае внешними данными будет являться целый MDN, но полностью локально и оффлайн. Все LLM конечно же в той или иной степени уже обучались на основе этого открытого источника данных, но их "память" ограничена конкретной датой рождения модели.
RAG-MCP
Мы будем делать RAG, завёрнутый в MCP:

Но обо всём по порядку.
MDN
Интересующие нас данные лежат на GitHub в довольно специфичном формате Markdown со своими наворотами. Собирается всем известный сайт с документацией с помощью собственного инструмента Rari.
Опишем интересующие нас файлы в checkout.txt:
/files/en-us/web/api/**/*.md !/files/en-us/web/api/index.md /files/en-us/web/css/reference/**/*.md !/files/en-us/web/css/reference/index.md !/files/en-us/web/css/reference/mozilla_extensions/** !/files/en-us/web/css/reference/webkit_extensions/** /files/en-us/web/html/reference/**/*.md !/files/en-us/web/html/reference/index.md /files/en-us/web/http/reference/**/*.md !/files/en-us/web/http/reference/index.md !/files/en-us/web/http/reference/resources_and_specifications/** /files/en-us/web/javascript/reference/**/*.md !/files/en-us/web/javascript/reference/index.md !/files/en-us/web/javascript/reference/javascript_technologies_overview/** /files/en-us/web/svg/reference/**/*.md !/files/en-us/web/svg/reference/index.md
И заберём только то, что нужно, не выкачивая весь репозиторий:
git clone --depth=1 --filter=tree:0 --no-checkout git@github.com:mdn/content.git data cd data git sparse-checkout set --no-cone --stdin < checkout.txt git checkout ls -1 files/en-us/web/
Git clone:
-depth=1– только последний коммит.–filter=tree:0– только коммиты без дерева и блобов.–no-checkout– без записи файлов.
Git sparse-checkout:
set --no-cone --stdin < checkout.txt– собственно, наш список файлов.
В итоге получаем:
du -sh --apparent-size . | cut -f1 53M
текстовой информации в Markdown.
Markdown
Нам необходимо:
Превратить Markdown в старый добрый plain text, потому что с точки зрения токенизации и векторизации данных все эти звёздочки, скобочки и прочие специальные символы являются хоть и семантическим, но всё же лишним шумом.
Разбить текст на "чанки" – осмысленные куски, целые параграфы с заголовками, списки и примеры кода. Забегая вперёд скажу, что часто "эмбеддят" по 512 бездушных токенов с нахлёстом, дабы не растерять контекст, но MDN настолько хорошо структурирован, что нас это не коснётся.
Возьмём известную библиотеку marked:
import { marked } from 'marked' const md = ` # Heading Paragraph. ` const tokens = marked.lexer(md) console.log(tokens) const html = marked.parser(tokens) console.log(html)
Из которой нам интересен метод lexer, возвращающий древовидный список токенов, например, таких:
declare namespace Tokens { interface Paragraph { type: 'paragraph'; raw: string; pre?: boolean; text: string; tokens: Token[]; } } export type Token = Tokens.Paragraph | Tokens.Blockquote // | ... export type TokensList = Token[]
по которому мы и будем рекурсивно итерироваться.
Типичный документ на MDN выглядит так:
--- title: Promise.race() short-title: race() slug: Web/JavaScript/Reference/Global_Objects/Promise/race page-type: javascript-static-method browser-compat: javascript.builtins.Promise.race sidebar: jsref --- The **`Promise.race()`** static method takes an iterable of promises as input and returns a single {{jsxref("Promise")}}. This returned promise settles with the eventual state of the first promise that settles. {{InteractiveExample("JavaScript Demo: Promise.race()", "taller")}}
Этот --- блок с метаданными называется Frontmatter, и нам необходимо достать из него поле title, которое мы будем использовать вместо отсутствующего заголовка первого уровня:
import matter from 'gray-matter' import { marked, type Token } from 'marked' export const chunkMarkdown = (document: string): string[] => { const { content, data } = matter(document) const tokens = marked.lexer(`# ${data.title}\n\n${content}`) const chunks = processTokens(tokens) return chunks }
Наш рекурсивный processTokens, сильно упрощённо для наглядности:
const processTokens = (tokens: Token[]): string[] => { const result: string[] = [] if (token.type === 'paragraph') { const chunks = processTokens(token.tokens) result.push(chunks.join('')) continue } if (token.type === 'text') { if (Array.isArray(token.tokens)) { const chunks = processTokens(token.tokens) result.push(chunks.join('')) } else { result.push(token.text) } continue } return result }
Приводить весь исходный код чанкера я не стану, отмечу лишь:
Наши чанки будут полноценными осмысленными секциями от "заголовка до заголовка".
Сами
# Headingзаголовки мы будем заменять наHeading:\n\n.Заголовки разных уровней будем склеивать в
Heading 1 - Heading 2:\n\nдля пущего контекста последующих абзацев.Все ссылки,
strong,emи прочее форматирование будем оставлять в виде простого текста содержимого.Таблицы будем выворачивать наизнанку в плоские списки.
Сложные definition-списки будем сплющивать в обычные плоские с минимумом отступов.
Тройные обратные кавычки у блоков с кодом будем заменять на
Example:\n\n.Всевозможные абы как написанные
{{…}}-шаблоны со случайными пробелами и разными кавычками будем вычищать регулярными выражениями. Наверняка где-то есть настоящий парсер на JS, но я просто лениво описал их все методом грубой силы, пока не перестал падать гард с/{{[^}]+?}}/.test(text).Не несущие смысловой нагрузки секции типа "See also" будем пропускать целиком.
Итого, подобный файл превращается в следующие чанки:
ArrayBuffer: The `ArrayBuffer` object is used to represent a generic raw binary data buffer. It is an array of bytes, often referred to in other languages as a "byte array". You cannot directly manipulate the contents of an `ArrayBuffer`; instead, you create one of the typed array objects or a `DataView` object which represents the buffer in a specific format, and use that to read and write the contents of the buffer. The `ArrayBuffer()` constructor creates a new `ArrayBuffer` of the given length in bytes. You can also get an array buffer from existing data, for example, from a Base64 string or from a local file. `ArrayBuffer` is a transferable object. ***************************************************************************************************************************************************** ArrayBuffer - Description - Resizing ArrayBuffers: `ArrayBuffer` objects can be made resizable by including the `maxByteLength` option when calling the `ArrayBuffer()` constructor. You can query whether an `ArrayBuffer` is resizable and what its maximum size is by accessing its `resizable` and `maxByteLength` properties, respectively. You can assign a new size to a resizable `ArrayBuffer` with a `resize()` call. New bytes are initialized to 0. These features make resizing `ArrayBuffer`s more efficient — otherwise, you have to make a copy of the buffer with a new size. It also gives JavaScript parity with WebAssembly in this regard (Wasm linear memory can be resized with `WebAssembly.Memory.prototype.grow()`). ***************************************************************************************************************************************************** ArrayBuffer - Description - Transferring ArrayBuffers: `ArrayBuffer` objects can be transferred between different execution contexts, like Web Workers or Service Workers, using the structured clone algorithm. This is done by passing the `ArrayBuffer` as a transferable object in a call to `Worker.postMessage()` or `ServiceWorker.postMessage()`. In pure JavaScript, you can also transfer the ownership of memory from one `ArrayBuffer` to another using its `transfer()` or `transferToFixedLength()` method. When an `ArrayBuffer` is transferred, its original copy becomes detached — this means it is no longer usable. At any moment, there will only be one copy of the `ArrayBuffer` that actually has access to the underlying memory. Detached buffers have the following behaviors: - `byteLength` becomes 0 (in both the buffer and the associated typed array views). - Methods, such as `resize()` and `slice()`, throw a `TypeError` when invoked. The associated typed array views' methods also throw a `TypeError`. You can check whether an `ArrayBuffer` is detached by its `detached` property. ***************************************************************************************************************************************************** ArrayBuffer - Constructor: - `ArrayBuffer()`: Creates a new `ArrayBuffer` object. ***************************************************************************************************************************************************** ArrayBuffer - Static properties: - `ArrayBuffer[Symbol.species]`: The constructor function that is used to create derived objects. ***************************************************************************************************************************************************** ArrayBuffer - Static methods: - `ArrayBuffer.isView()`: Returns `true` if `arg` is one of the ArrayBuffer views, such as typed array objects or a `DataView`. Returns `false` otherwise. ***************************************************************************************************************************************************** ArrayBuffer - Instance properties: These properties are defined on `ArrayBuffer.prototype` and shared by all `ArrayBuffer` instances. - `ArrayBuffer.prototype.byteLength`: The size, in bytes, of the `ArrayBuffer`. This is established when the array is constructed and can only be changed using the `ArrayBuffer.prototype.resize()` method if the `ArrayBuffer` is resizable. - `ArrayBuffer.prototype.constructor`: The constructor function that created the instance object. For `ArrayBuffer` instances, the initial value is the `ArrayBuffer` constructor. - `ArrayBuffer.prototype.detached`: Read-only. Returns `true` if the `ArrayBuffer` has been detached (transferred), or `false` if not. - `ArrayBuffer.prototype.maxByteLength`: The read-only maximum length, in bytes, that the `ArrayBuffer` can be resized to. This is established when the array is constructed and cannot be changed. - `ArrayBuffer.prototype.resizable`: Read-only. Returns `true` if the `ArrayBuffer` can be resized, or `false` if not. - `ArrayBuffer.prototype[Symbol.toStringTag]`: The initial value of the `[Symbol.toStringTag]` property is the string `"ArrayBuffer"`. This property is used in `Object.prototype.toString()`. ***************************************************************************************************************************************************** ArrayBuffer - Instance methods: - `ArrayBuffer.prototype.resize()`: Resizes the `ArrayBuffer` to the specified size, in bytes. - `ArrayBuffer.prototype.slice()`: Returns a new `ArrayBuffer` whose contents are a copy of this `ArrayBuffer`'s bytes from `begin` (inclusive) up to `end` (exclusive). If either `begin` or `end` is negative, it refers to an index from the end of the array, as opposed to from the beginning. - `ArrayBuffer.prototype.transfer()`: Creates a new `ArrayBuffer` with the same byte content as this buffer, then detaches this buffer. - `ArrayBuffer.prototype.transferToFixedLength()`: Creates a new non-resizable `ArrayBuffer` with the same byte content as this buffer, then detaches this buffer. ***************************************************************************************************************************************************** ArrayBuffer - Examples - Creating an ArrayBuffer: In this example, we create a 8-byte buffer with an `Int32Array` view referring to the buffer: Example: const buffer = new ArrayBuffer(8); const view = new Int32Array(buffer);
Embedding
Текст мы получили, теперь нужно сделать из него эмбеддинг – векторное представление. Это, по сути, массив чисел, по которому можно вычислить, например, "косинусное сходство" с другим вектором. Что-то типа такого:
const cosineSimilarity = (a: number[], b: number[]): number => { let dot = 0 let na = 0 let nb = 0 for (let i = 0; i < a.length; i++) { dot += a[i] * b[i] na += a[i] * a[i] nb += b[i] * b[i] } return dot / (Math.sqrt(na) * Math.sqrt(nb)) } const v1 = [0.1, 0.2, 0.3] const v2 = [0.1, 0.25, 0.35] const result = cosineSimilarity(v1, v2) console.log(result)
Примерно так, максимально упрощённо, и работает векторный поиск. Поиграйтесь с числами векторов чтобы понять их влияние на результат.
Для преобразования текста в вектор существует множество специальных embedding-моделей, мы же остановим выбор на BGE-M3 – отлично справляется с длинными текстами технической документации до 8192 токенов, упаковывая их в 1024 измерения.
Измерения? Какие измерения?..
Эмбеддинг любого текста будет выглядеть как массив из 1024 чисел – измерений – всевозможных аспектов и смыслов от лингвистической морфологии до реляционных аналогий. Именно поэтому эмбеддинги для RAG создаются LLM, которые "понимают текст". Также существуют т.н. Matryoshka-модели, которые сортируют эмбеддинги таким образом, чтобы можно было взять первые, например, 256 элементов массива, и всё равно получить "самое важное" без существенной потери качества.
Скучно? Практика!
Будем использовать биндинги к широко известному inference-движку llama.cpp:
import { getLlama } from 'node-llama-cpp' const MODEL_PATH = './bge-m3-GGUF-Q4_K_M.gguf' const MAX_TOKENS = 8192 const TEXT = 'Hello' const llama = await getLlama() const model = await llama.loadModel({ modelPath: MODEL_PATH }) const context = await model.createEmbeddingContext({ contextSize: MODEL_MAX_TOKENS, batchSize: MODEL_MAX_TOKENS, }) const embedding = await context.getEmbeddingFor(TEXT) console.log(embedding.vector) await context.dispose() await model.dispose() await llama.dispose()
Все муки выбора нужного GPU/CPU бэкенда уже автоматизированы внутри, загруженная модель займёт около ~655 MB VRAM/RAM.
Очень важно, чтобы embedding-модель для оригинального и поискового векторов была идентична, от общей натренированности до точности чисел с плавающей точкой. Поэтому для наших нужд сделаем квантизированную Q4_K_M версию, которую будем использовать всегда и везде.
Database
Если вспомнить нашу диаграмму из начала статьи, то нам нужна база данных как для векторных, так и для текстовых данных.
Возьмём биндинги к популярной LanceDB:
import lancedb from '@lancedb/lancedb' const DATASET_PATH = './db' const DATASET_TABLE = 'mdn' const data = [ { text: 'Hello', vector: [1, 2, 3] }, { text: 'Habr', vector: [4, 5, 6] } ] const db = await lancedb.connect(DATASET_PATH) const table = await db.createTable(DATASET_TABLE, data) table.close() db.close()
На выходе получим папку ./db/mdn.lance со всем необходимым содержимым. В следующий раз можно сделать db.openTable(DATASET_TABLE), принцип понятен.
Процесс заполнения базы данных называется "ingesting".
Ingesting
Bun предоставляет удобный Async Iterable по глобу, чем мы и воспользуемся:
import path from 'node:path' import lancedb from '@lancedb/lancedb' import { chunkMarkdown } from './markdown.ts' import { vectorize } from './vectorize.ts' const DATA_ROOT_PATH = './data/files/en-us/web/' const DATASET_PATH = './db' const DATASET_TABLE = 'mdn' const glob = new Bun.Glob('**/*.md') const files = glob.scan(DATA_ROOT_PATH) type TIngestData = { text: string, vector: number[] } const data: TIngestData[] = [] for await (const file of files) { const filePath = path.resolve(DATA_ROOT_PATH, file) const documentFile = Bun.file(filePath) const document = await documentFile.text() const chunks = chunkMarkdown(document) for (const text of chunks) { const vector = await vectorize(chunk) data.push({ text, vector }) } } const db = await lancedb.connect(DATASET_PATH) const table = await db.createTable(DATASET_TABLE, data) const stats = await table.stats() console.log('Total rows:', stats.numRows) table.close() db.close()
Результат недетерминированный, директории и файлы идут не в алфавитном порядке. Иногда это важно, и можно воспользоваться glob.scanSync() с последующей сортировкой.
Итак, все наши чанки "от заголовка до заголовка" благополучно влезли в лимит 8192 токенов эмбеддинга у BGE-M3. А если бы нет? Ну, llama.cpp бы предусмотрительно упал, и нужно было бы что-то придумывать. Например, склеивать абзацы одной секции только "пока влазит". А остальные выносить в такую же, но рядом, с повтором заголовка.
А как узнать количество токенов в тексте? Это далеко не всегда просто количество слов, разделённых пробелами. Всё несколько сложнее:
import { Tokenizer } from '@huggingface/tokenizers' import tokenizerJson from './tokenizer.json' import tokenizerConfigJson from './tokenizer_config.json' const tokenizer = new Tokenizer(tokenizerJson, tokenizerConfigJson) const getTokenCount = (text: string): number => { const { ids } = tokenizer.encode(text) const tokenCount = ids.length return tokenCount }
Токенайзеру необходимо скормить настоящие tokenizer.json и tokenizer_config.json от нашей конкретной модели.
Search
Сделаем пробный векторный поиск:
import lancedb from '@lancedb/lancedb' import { vectorize } from './vectorize.ts' const TEXT = 'Promise.race description' const DATASET_PATH = './db' const DATASET_TABLE = 'mdn' const db = await lancedb.connect(DATASET_PATH) const table = await db.openTable(DATASET_TABLE) const vector = await vectorize(TEXT) type TQueryResult = { text: string, vector: number[], _score: number, _relevance_score: number } const results = await table .query() .nearestTo(vector) .limit(3) .toArray() as TQueryResult[] console.log(results) table.close() db.close()
nearestTo(vector) как раз ищет похожие на наш запрос векторы.
Принудительный as потому что хорошего места для втыкания дженерика я не нашёл.
Теперь сделаем "обычный" полнотекстовый (LanceDB использует алгоритм BM25) поиск, но сначала создадим индекс текстовой колонки:
await table.createIndex('text', { config: lancedb.Index.fts() }) await table.waitForIndex(['text_idx'], 60)
const results = await table .query() .fullTextSearch(text) .limit(3) .toArray() console.log(results)
К слову, будь у нас миллион векторов и остро стоял вопрос производительности, имело бы смысл создать также и векторный индекс, с квантизированными копиями:
await table.createIndex('vector', { config: lancedb.Index.ivfPq() })
В свою очередь, гибридный поиск совмещает в себе общие результаты, которые отправляются в "Reranker" – специальный алгоритм, или даже целая LLM, который объединяет и ранжирует найденное.
Воспользуемся встроенным Reciprocal Rank Fusion (RRF), который как раз хорошо подходит для гибридного поиска, в отличие от сравнения "score" из разных источников в лоб:
const reranker = await lancedb.rerankers.RRFReranker.create() const results = await table .query() .nearestTo(vector) .fullTextSearch(text) .rerank(reranker) .limit(3) .toArray() console.log(results)
Таким образом, мы получаем лучшие результаты из обоих миров, что особенно важно для такой технической документации, как MDN.
Базу мы заполнили, и она уже даже отвечает на запросы. Как сделать так, чтобы LLM могли делать это самостоятельно?
MCP
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):
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' const server = new McpServer({ name: 'my-mcp-server', version: '0.1.0' }) server.registerTool( 'my-mcp-tool', { description: 'My MCP tool description', inputSchema: z.object({ query: z.string().describe('Query description') }), outputSchema: z.object({ results: z.array(z.string()) }) }, ({ query }) => { const results = [ { text: `${query}!` } ] return { content: results.map((result) => ({ type: 'text', text: result.text })), structuredContent: { results: results.map((result) => result.text) } } } )
Мы создали сервер и зарегистрировали в нём инструмент с мета-информацией, которую будет учитывать LLM как для принятия решения, так и использования:
description– общее описание инструмента.inputSchema– обязательная схема входных параметров, очень важно доходчиво описать каждое поле.outputSchema– опциональная схема выходных результатов. Если наша цель просто передать текстовую информацию произвольного формата обратно пользователю через LLM, то достаточноinputSchema+content. Для структурированных данных, например, JSON, нужен ещё иstructuredContent.
Сервер нужно соединить с доступным транспортом, например, STDIO, Streamable HTTP или Server-Sent Events (SSE). Для наших локальных нужд максимально подходит юниксовый до невозможности STDIO:
stdin– входные данные.stdout– выходные.stderr– ошибки.
В котором буквально поднимается дочерний процесс в режиме ожидания:
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' // … const transport = new StdioServerTransport() await server.connect(transport)
Пробуем!
echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | bun mcp.ts | jq
{ "result": { "tools": [ { "name": "my-mcp-tool", "description": "My MCP tool description", "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "query": { "type": "string", "description": "Query description" } }, "required": [ "query" ] }, "execution": { "taskSupport": "forbidden" }, "outputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "results": { "type": "array", "items": { "type": "string" } } }, "required": [ "results" ], "additionalProperties": false } } ] }, "jsonrpc": "2.0", "id": 1 }
Как видим, внутри протокол представляет собой JSON-RPC 2.0.
Делаем настоящий вызов:
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"my-mcp-tool","arguments":{"query":"Hello Habr"}},"id":2}' | bun mcp.ts | jq
{ "result": { "content": [ { "type": "text", "text": "Hello Habr!" } ], "structuredContent": { "results": [ "Hello Habr!" ] } }, "jsonrpc": "2.0", "id": 2 }
Заменяем тестовую заглушку на настоящие вызовы нашего RAG и вписываем сервер в mcp.json того же LM Studio:
{ "mcpServers": { "mdn": { "command": "bun", "args": [ "/path/to/server.ts", ] } } }
Запускаем, задаём первый вопрос в чате и понимаем, что чёткое описание 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 прямо в карточке датасета.
Создаём репозиторий и пробуем залить:
brew install uv uv tool install huggingface_hub hf upload deepsweet/mdn --repo-type=dataset . data/
Хранить данные в data/ – общепринятый формат по умолчанию, но можно настроить и по-своему.
А как теперь обычному пользователю забрать датасет к себе? Можно, конечно, тоже заставить установить huggingface_hub и сделать hf download, но лучше воспользуемся официальной библиотекой @huggingface/hub:
export const downloadDataset = async (): Promise<void> => { await snapshotDownload({ repo: 'datasets/deepsweet/mdn' }) } export const downloadModel = async (): Promise<void> => { await snapshotDownload({ repo: 'deepsweet/bge-m3-GGUF-Q4_K_M' }) }
А ведь надо ещё забирать и нашу embedding-модель.
Входной точкой библиотеки сделаем такое:
#!/usr/bin/env node import { downloadDataset, downloadModel } from './huggingface.ts' import { startMcpServer } from './server.ts' switch (process.argv[2]) { case 'download': { await downloadDataset() await downloadModel() break } case 'server': { await startMcpServer() break } default: { console.error('Unknown command, use "download" or "server"') process.exit(1) } }
Теперь пользователь может как заранее скачать/обновить датасет, так и запустить сервер отдельными командами.
Скачиваться всё будет в стандартный кэш HuggingFace, по умолчанию ~/.cache/huggingface, для которого, кстати, есть полезная команда hf cache prune для очистки старых ревизий.
Скачать – скачали, а путь?
import { getHFHubCachePath, getRepoFolderName, scanCachedRepo } from '@huggingface/hub' export const getDatasetPath = async (): Promise<string> => { const cachePath = getHFHubCachePath() const repoFolderName = getRepoFolderName({ name: 'deepsweet/mdn', type: 'dataset' }) const repoPath = path.join(cachePath, repoFolderName) const repo = await scanCachedRepo(repoPath) if (repo.revisions.length === 0) { throw new Error(`Unable to get ${type} path, it needs to be downloaded first`) } const latestRevision = repo.revisions.reduce((latest, current) => { if (current.lastModifiedAt > latest.lastModifiedAt) { return current } return latest }) const datasetPath = path.join(latestRevisionPath, 'data') return datasetPath }
Многовато кода, но по факту это просто брожение по подобной файловой структуре с помощью официальных утилит:
~/.cache/huggingface/hub/datasets--deepsweet--mdn/ ├── blobs/ │ ├── 33f18443bc0446a4dab95096b42f3e415d7fba39 │ ├── 3b83cacefd9c8d7a429203abe7eb0d2ebdb628fe │ ├── 41ff04e8d79bd9dc459d8799dc89b33568e0a8c4 │ ├── 4a539474c8a8039a01fcd8ed2a58a18521ef24ef0d2bd21c8d45ce9ef2d28c03 │ ├── 536cef56f6390ea17fd6c64a25d0ee7f5288c485dcf0119161ccf43973162a69 │ ├── 539601bc2841d549a704666045c8b4bb80f0f0864532fc247a64c307ca7cad7f │ ├── 57268217c821a064405153212fc6644ee51211bea80cc22bdefaa224843f37c1 │ ├── 9aeee575b909b2407f9facdbf78829f64354e8b7d71a30f17a2e4838f43039a5 │ ├── b0020a0af6bba0d2ce70240354f9eb6e75471079ef8410c08c03f7b8d64165e3 │ ├── bc6b760c06e409fb50ff6b243917473f4a65d786cbdbc4052ca35c1f1a749842 │ ├── d04bb4a066304be8e755c1916ffa2335e8e59cfbe3362c930eb4ad36f6c1b9c0 │ ├── e43b0f988953ae3a84b00331d0ccf5f7d51cb3cf │ └── fdf8413b1075ae1ca24149c43aa67429e9b3f269f9eccebac583eb75bf4c87b5 ├── refs/ │ └── main └── snapshots/ └── d2f931828c10edb929a638b731fad322b01e4b56/ ├── data/ ├── .gitattributes -> ../../blobs/41ff04e8d79bd9dc459d8799dc89b33568e0a8c4 ├── .gitignore -> ../../blobs/e43b0f988953ae3a84b00331d0ccf5f7d51cb3cf ├── cache.json -> ../../blobs/3b83cacefd9c8d7a429203abe7eb0d2ebdb628fe └── README.md -> ../../blobs/33f18443bc0446a4dab95096b42f3e415d7fba39
С getModelPath() всё аналогично. С симлинками, к слову, есть неприятный детский баг, но показывать костыль я, конечно же, не буду.
Autoupdate
На моём Apple Silicon M2 Max 12/30 создание датасета с нуля занимает полчаса. Макбук пыхтит и шумит, но резво создаёт эмбеддинги и заполняет таблицу.
Для того, чтобы сделать то же самое силами бесплатного раннера GitHub Actions без GPU (ну, а почему бы и нет) по моим грубым линейным прикидкам потребуется около 14 часов. А лимит у нас 6.
Нужно сделать точечное обновление только изменённых с предыдущего раза файлов на MDN. Расчёт на то, что, скажем, раз в месяц вообще все файлы документации никто не трогает.
Для начала добавим в таблицу новую колонку:
type TIngestData = { file: string, text: string, vector: TVector }
И будем записывать туда кратчайший путь к обрабатываемому файлу относительно data/files/en-us/web/.
В запросах явно укажем имена колонок для поиска:
const results = await table .query() .nearestTo(vector) .column('vector') .fullTextSearch(text, { columns: 'text' }) .rerank(reranker) .limit(3) .toArray()
Далее, добавим файл cache.json в корень репозитория, и будем записывать хеш для каждого файла:
const cache: Record<string, string> = {} const hash = Bun.hash(document).toString(16) // const hash = Bun.hash.rapidhash(document).toString(16) // const hash = Bun.SHA256.hash(document, 'hex') cache[file] = hash
Ультрабыстрого некриптостойкого Wyhash по умолчанию должно хватить с головой, нам нужно просто проверить изменился файл или нет. На всякий случай закомментировал альтернативы.
Если файл новый или был изменён, то сначала мы удаляем все чанки-строки из таблицы, которые к нему относятся:
await table.delete(`file = '${file}'`)
А затем создаём новые эмбеддинги и добавляем в таблицу строки:
await table.add(data)
Если файл был удалён, то просто удаляем строки.
LanceDB на каждый наш чих создаёт файлы в папках _transactions/ и _versions/, которые необходимо подчистить, заодно и текстовый индекс обновим:
if (hasChanges) { const indexName = 'text_idx' await table.dropIndex(indexName) await table.optimize({ cleanupOlderThan: new Date() }) await table.createIndex('text', { config: lancedb.Index.fts() }) await table.waitForIndex([indexName], 60) }
Пишем свой uploader:
import { commit } from '@huggingface/hub' import type { CommitOperation } from '@huggingface/hub' // … const operations: CommitOperation[] = [ { operation: 'delete', path: CACHE_FILENAME }, { operation: 'addOrUpdate', path: CACHE_FILENAME, content: cacheFile }, { operation: 'delete', path: `data/${TABLE_FILENAME}` } ] // … for await (const file of files) { const fileRelativePath = path.join('data', TABLE_FILENAME, file) const fileAbsolutePath = path.resolve(datasetPath, TABLE_FILENAME, file) const fileBlob = Bun.file(fileAbsolutePath) operations.push({ operation: 'addOrUpdate', path: fileRelativePath, content: fileBlob }) } await commit({ accessToken: process.env.HF_TOKEN, repo: `datasets/${DATASET_REPO}`, title: '♻️ update', operations })
Важно убедиться, что data/mdn.lance/ и cache.json точно обновляются одновременно в одном коммите.
Добавляем Cron в наш GitHub Workflow:
name: update on: schedule: - cron: '0 0 1 * *' workflow_dispatch:
Запускается или в первый день каждого месяца (00:00 UTC) самостоятельно, или руками.
Итого
npx -y @deepsweet/mdn@latest download
{ "mcpServers": { "mdn": { "command": "npx", "args": [ "-y", "@deepsweet/mdn@latest", "server" ], "env": {} } } }
Спасибо всем, кто дочитал, для меня это было увлекательное путешествие и серьёзное исследование. Всегда любил и буду любить писать инструменты для веб-разработки и не только.
В данный момент я нахожусь в активном поиске работы – резюме.
