Привет.

В этой технической статье мы на практике разберёмся, что такое 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": {}
    }
  }
}

Спасибо всем, кто дочитал, для меня это было увлекательное путешествие и серьёзное исследование. Всегда любил и буду любить писать инструменты для веб-разработки и не только.


В данный момент я нахожусь в активном поиске работы – резюме.