- BrainTools - https://www.braintools.ru -
Привет.
В этой технической статье мы на практике разберёмся, что такое RAG, распарсим MDN Web Docs [1], научимся готовить эмбеддинги, заполним ими векторную базу данных и напишем свой MCP сервер с гибридным векторным и полнотекстовым поиском. Зальём всё получившееся добро на HuggingFace [2], GitHub [3] и NPM [4], и настроим автоматическое обновление данных.
Внутри будет много пошаговых инструкций и примеров кода на Bun + TypeScript.
Скриншот вместо тысячи слов:

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

Но обо всём по порядку.
Интересующие нас данные лежат на GitHub [5] в довольно специфичном формате Markdown со своими наворотами. Собирается всем известный сайт с документацией с помощью собственного инструмента Rari [6].
Опишем интересующие нас файлы в 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 [7]:
set --no-cone --stdin < checkout.txt – собственно, наш список файлов.
В итоге получаем:
du -sh --apparent-size . | cut -f1
53M
текстовой информации в Markdown.
Нам необходимо:
Превратить Markdown в старый добрый plain text, потому что с точки зрения [8] токенизации и векторизации данных все эти звёздочки, скобочки и прочие специальные символы являются хоть и семантическим, но всё же лишним шумом.
Разбить текст на “чанки” – осмысленные куски, целые параграфы с заголовками, списки и примеры кода. Забегая вперёд скажу, что часто “эмбеддят” по 512 бездушных токенов с нахлёстом, дабы не растерять контекст, но MDN настолько хорошо структурирован, что нас это не коснётся.
Возьмём известную библиотеку marked [9]:
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, возвращающий древовидный список токенов [10], например, таких:
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 [11], и нам необходимо достать из него поле 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}nn${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
}
Приводить весь исходный код чанкера [12] я не стану, отмечу лишь:
Наши чанки будут полноценными осмысленными секциями от “заголовка до заголовка”.
Сами # Heading заголовки мы будем заменять на Heading:nn.
Заголовки разных уровней будем склеивать в Heading 1 - Heading 2:nn для пущего контекста последующих абзацев.
Все ссылки, strong, em и прочее форматирование будем оставлять в виде простого текста содержимого.
Таблицы будем выворачивать наизнанку в плоские списки.
Сложные definition-списки [13] будем сплющивать в обычные плоские с минимумом отступов.
Тройные обратные кавычки у блоков с кодом будем заменять на Example:nn.
Всевозможные абы как написанные {{…}}–шаблоны [14] со случайными пробелами и разными кавычками будем вычищать регулярными выражениями. Наверняка где-то есть настоящий парсер на JS, но я просто лениво описал их все методом грубой силы, пока не перестал падать гард с /{{[^}]+?}}/.test(text).
Не несущие смысловой нагрузки секции типа “See also” будем пропускать целиком.
Итого, подобный файл [15] превращается в следующие чанки:
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);
Текст мы получили, теперь нужно сделать из него эмбеддинг – векторное представление. Это, по сути, массив чисел, по которому можно вычислить, например, “косинусное сходство” с другим вектором. Что-то типа такого:
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 [16] – отлично справляется с длинными текстами технической документации до 8192 токенов, упаковывая их в 1024 измерения.
Измерения? Какие измерения?..
Эмбеддинг любого текста будет выглядеть как массив из 1024 чисел – измерений – всевозможных аспектов и смыслов от лингвистической морфологии до реляционных аналогий. Именно поэтому эмбеддинги для RAG создаются LLM, которые “понимают текст”. Также существуют т.н. Matryoshka-модели, которые сортируют эмбеддинги таким образом, чтобы можно было взять первые, например, 256 элементов массива, и всё равно получить “самое важное” без существенной потери качества.
Скучно? Практика!
Будем использовать биндинги [17] к широко известному inference-движку llama.cpp [18]:
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 версию [19], которую будем использовать всегда и везде.
Если вспомнить нашу диаграмму из начала статьи, то нам нужна база данных как для векторных, так и для текстовых данных.
Возьмём биндинги [20] к популярной LanceDB [21]:
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”.
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
}
Токенайзеру [22] необходимо скормить настоящие tokenizer.json [23] и tokenizer_config.json [24] от нашей конкретной модели.
Сделаем пробный векторный поиск:
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 [25]) поиск, но сначала создадим индекс текстовой колонки:
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 могли делать это самостоятельно?
Model Context Protocol (MCP) – протокол, с помощью которого т.н. “MCP host” (приложения/серверы типа LM Studio, Claude Desktop и даже llama.cpp с недавних пор [26]) может общаться со всевозможными инструментами, от чтения/записи локальных файлов до хождения в интернет.
LLM умеют вызывать инструменты, но не общаться по MCP напрямую. Вместо этого приложение/сервер транслирует всё в понятный для LLM формат. Cписок доступных инструментов попадает прямо в prompt, позволяя модели самостоятельно принимать решения об их вызове. К сожалению, формат не всегда является стандартным JSON по примеру OpenAI Tools [27] – да-да, те самые танцы от ковыряния Jinja-шаблонов до многочисленных PR в inference-движок для только вышедших LLM (привет, Gemma 4).
Воспользуемся официальным @modelcontextprotocol/sdk [28] (со страшными импортами пока ждём v2 [29]):
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 – ошибки [30].
В котором буквально поднимается дочерний процесс в режиме ожидания:
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 [31].
Делаем настоящий вызов:
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”, который переписывает запрос в один или даже несколько заведомо более подходящих и качественных.
Итак, у нас уже есть:
исходный код
pre-ingested датасет (артефакт LanceDB)
И если с исходным кодом всё понятно – выкладываем на GitHub [3], а собранный пакет публикуем в NPM [4], то что делать с датасетом? ~260 MB бинарных данных хранить в NPM наверное и можно, чисто технически, но звучит не очень.
Оказывается, на HuggingFace [32] помимо, собственно, моделей, выкладывают и различные датасеты [33]. А с недавних пор [34] LanceDB как раз стал одним из родных форматов, с красивым Dataset Viewer прямо в карточке датасета.
Создаём репозиторий [2] и пробуем залить:
brew install uv
uv tool install huggingface_hub
hf upload deepsweet/mdn --repo-type=dataset . data/
Хранить данные в data/ – общепринятый формат по умолчанию, но можно настроить и по-своему [35].
А как теперь обычному пользователю забрать датасет к себе? Можно, конечно, тоже заставить установить huggingface_hub и сделать hf download, но лучше воспользуемся официальной библиотекой @huggingface/hub [36]:
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() всё аналогично. С симлинками, к слову, есть неприятный детский баг [37], но показывать костыль я, конечно же, не буду.
На моём Apple Silicon M2 Max 12/30 создание датасета с нуля занимает полчаса. Макбук пыхтит и шумит, но резво создаёт эмбеддинги и заполняет таблицу.
Для того, чтобы сделать то же самое силами бесплатного раннера GitHub Actions без GPU (ну, а почему бы и нет) по моим грубым линейным прикидкам потребуется около 14 часов. А лимит [38] у нас 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 [39]:
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": {}
}
}
}
Спасибо всем, кто дочитал, для меня это было увлекательное путешествие и серьёзное исследование. Всегда любил [40] и буду любить писать инструменты для веб-разработки и не только.
В данный момент я нахожусь в активном поиске работы – резюме [41].
Автор: deepsweet
Источник [42]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/28478
URLs in this post:
[1] MDN Web Docs: https://developer.mozilla.org/
[2] HuggingFace: https://huggingface.co/datasets/deepsweet/mdn
[3] GitHub: https://github.com/deepsweet/mdn
[4] NPM: https://www.npmjs.com/package/@deepsweet/mdn
[5] лежат на GitHub: https://github.com/mdn/content/tree/main/files/en-us/web
[6] Rari: https://github.com/mdn/rari
[7] sparse-checkout: https://git-scm.com/docs/git-sparse-checkout
[8] зрения: http://www.braintools.ru/article/6238
[9] marked: https://github.com/markedjs/marked
[10] древовидный список токенов: https://marked.js.org/demo/?outputType=lexer&text=%23%20Heading%0A%0AParagraph.&options=%7B%0A%20%22async%22%3A%20false%2C%0A%20%22breaks%22%3A%20false%2C%0A%20%22extensions%22%3A%20null%2C%0A%20%22gfm%22%3A%20true%2C%0A%20%22hooks%22%3A%20null%2C%0A%20%22pedantic%22%3A%20false%2C%0A%20%22silent%22%3A%20false%2C%0A%20%22tokenizer%22%3A%20null%2C%0A%20%22walkTokens%22%3A%20null%0A%7D&version=17.0.6
[11] Frontmatter: https://www.markdownlang.com/advanced/frontmatter.html
[12] исходный код чанкера: https://github.com/deepsweet/mdn/blob/070699e2bd1a07ab9ce3af9c329b0d26c9e4b8c7/scripts/markdown.ts
[13] definition-списки: https://github.com/mdn/content/blob/26c6aca187b3718498886f9fba6c1cc4f4833b5d/files/en-us/web/api/animationeffect/gettiming/index.md#return-value
[14] шаблоны: https://github.com/mdn/rari/tree/main/crates/rari-doc/src/templ/templs
[15] подобный файл: https://raw.githubusercontent.com/mdn/content/26c6aca187b3718498886f9fba6c1cc4f4833b5d/files/en-us/web/javascript/reference/global_objects/arraybuffer/index.md
[16] BGE-M3: https://huggingface.co/BAAI/bge-m3
[17] биндинги: https://github.com/withcatai/node-llama-cpp
[18] llama.cpp: https://github.com/ggml-org/llama.cpp
[19] квантизированную Q4_K_M версию: https://huggingface.co/deepsweet/bge-m3-GGUF-Q4_K_M
[20] биндинги: https://github.com/lancedb/lancedb/tree/main/nodejs
[21] LanceDB: https://www.lancedb.com/
[22] Токенайзеру: https://github.com/huggingface/tokenizers.js
[23] tokenizer.json: https://huggingface.co/BAAI/bge-m3/blob/main/tokenizer.json
[24] tokenizer_config.json: https://huggingface.co/BAAI/bge-m3/blob/main/tokenizer_config.json
[25] BM25: https://emschwartz.me/understanding-the-bm25-full-text-search-algorithm/
[26] с недавних пор: https://github.com/ggml-org/llama.cpp/pull/18655
[27] OpenAI Tools: https://platform.openai.com/docs/guides/tools
[28] @modelcontextprotocol/sdk: https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x
[29] v2: https://github.com/modelcontextprotocol/typescript-sdk
[30] ошибки: http://www.braintools.ru/article/4192
[31] JSON-RPC 2.0: https://www.jsonrpc.org/specification
[32] HuggingFace: https://huggingface.co/
[33] датасеты: https://huggingface.co/datasets
[34] недавних пор: https://www.lancedb.com/blog/lance-x-huggingface-a-new-era-of-sharing-multimodal-data
[35] по-своему: https://huggingface.co/docs/hub/en/datasets-manual-configuration
[36] @huggingface/hub: https://github.com/huggingface/huggingface.js
[37] баг: https://github.com/lancedb/lancedb/issues/3197
[38] лимит: https://docs.github.com/en/actions/reference/limits
[39] GitHub Workflow: https://github.com/deepsweet/mdn/blob/9f72e08f557d1d4b48473ccba9c7a67c8ac7daba/.github/workflows/update.yml
[40] Всегда любил: https://github.com/svg/svgo/blob/6fd58725fc934ab51bc4ed84e7299dc73d72442b/package.json#L18-L22
[41] резюме: https://kir.belevi.ch/cv.ru.pdf
[42] Источник: https://habr.com/ru/articles/1019930/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1019930
Нажмите здесь для печати.