javascript

Как заставить LLM проанализировать хранилище из тысяч заметок, которое не влезает в контекст

  • вторник, 30 июня 2026 г. в 00:00:08
https://habr.com/ru/articles/1053366/

У меня в Obsidian накопилось под две тысячи заметок. Ежедневники, конспекты, обрывки идей, недописанные черновики. Граф‑вью честно показывает мне облако точек: красиво, но бесполезно. Какие заметки висят сиротами без единой связи, какие дублируют друг друга под разными тегами, какие кластеры тем так и не соединились, из графа не вытащить.

Очевидная мысль: «отдам всё LLM, пусть разберётся». Но 2000 заметок это миллионы токенов. Ни в один контекст это не влезает, а если бы и влезло, стоило бы как крыло самолёта и утонуло бы в шуме.

Так появился идея по созданию Vault Audit AI, плагин для Obsidian, который проводит аудит хранилища через LLM: находит сироты, кластеризует темы, предлагает теги и связи. Я его опубликовал в официальном каталоге и выложил на GitHub. В этой статье разберу инженерную начинку: как обойти лимит контекста через MapReduce, как не платить за повторный анализ, как абстрагировать четырёх LLM‑провайдеров под одним интерфейсом, и что пришлось переделать, чтобы пройти автоматическое ревью каталога.

Код на TypeScript, фрагменты настоящие (слегка почищены от обёрток локализации ради читаемости).

MapReduce поверх LLM

Сначала про то, что видит пользователь. Аудит запускается в трёх режимах под разный объём и бюджет. Single Аудит разбирает одну заметку за запрос с максимальным контекстом, для точечного глубокого анализа. Single Полный прогоняет все заметки подряд, игнорируя кэш, для первого запуска или полного пересбора. И Batch + Отчёт, рекомендуемый режим: батчевый анализ с кластеризацией, глобальными инсайтами и выгрузкой в Markdown и Canvas. Именно его я разбираю дальше, потому что это и есть полный MapReduce‑пайплайн, ради которого всё затевалось.

Дальше про то, как это устроено внутри. Классический приём из обработки больших данных ложится на задачу один в один. Если весь корпус не влезает в один запрос, бьём его на части, обрабатываем каждую независимо (Map), потом агрегируем результаты в один проход (Reduce).

Верхнеуровневый сценарий аудита выглядит так:

async run(): Promise<FinalAuditReport> {
  // 1. Отбираем файлы (исключая служебные папки)
  const files = this.collectFiles();

  // 2. Формируем батчи по бюджету символов
  const batches = await this.buildBatches(files);

  // 3. MAP: параллельный анализ батчей
  const mapResults = await this.runMapPhase(batches);
  const allSummaries = mapResults.flatMap((b) => b.files);

  // 4. REDUCE: кластеризация сводок
  const clusters = await this.runReducePhase(allSummaries);

  // 5. Финальный синтез: глобальные инсайты и план действий
  const { globalInsights, actionPlan } =
    await this.runFinalSynthesis(clusters, ...);

  return { clusters, globalInsights, actionPlan, ... };
}

Главное тут вот что: Map не возвращает текст, он возвращает структуру. Каждая заметка ужимается до компактной сводки: главная мысль, ключевые тезисы, сущности, оценка качества, предложенные теги. Это на порядок дешевле, чем тащить полный текст в фазу Reduce, и именно это позволяет Reduce увидеть всё хранилище целиком.

Как выглядит это в Obsidian
Как выглядит это в Obsidian

Батчинг по бюджету, а не по количеству

Наивный батчинг «по N заметок» ломается на разнородном контенте: десять однострочников и десять лонгридов это разная нагрузка на контекст. Поэтому батчи набираются жадно, по бюджету символов:

private async buildBatches(files: TFile[]) {
  const batches = [];
  let currentBatch: TFile[] = [];
  let currentPayload = "";

  const flush = () => {
    if (currentBatch.length === 0) return;
    batches.push({ payload: currentPayload, files: currentBatch });
    currentBatch = [];
    currentPayload = "";
  };

  for (const file of files) {
    // Если есть индекс и файл не менялся, пропускаем (см. ниже)
    if (this.index && !this.index.isStale(file)) continue;

    const content = await this.app.vault.cachedRead(file);
    const entry = this.formatForBatch(file, content);

    if (currentPayload.length + entry.length > this.config.batchCharBudget) {
      flush();
    }
    currentBatch.push(file);
    currentPayload += entry;
  }
  flush();
  return batches;
}

Здесь же зашита первая оптимизация стоимости: this.app.vault.cachedRead вместо read (отдаёт закэшированную версию, не лезет на диск лишний раз) и пропуск неизменённых файлов через индекс.

Параллельность с ограничением: семафор на воркерах

Map‑фаза это десятки независимых запросов к API. Гнать их все разом нельзя, упрёшься в rate limit провайдера. Гнать по одному медленно. Нужен пул воркеров с фиксированной параллельностью. Реализовал без библиотек (потому что их не знаю) ), через общую очередь:

private async runMapPhase(batches) {
  const results = new Array(batches.length);
  const queue = [...batches];
  let completed = 0;

  const worker = async () => {
    while (queue.length > 0) {
      if (this.signal.aborted) return;
      const batch = queue.shift();
      if (!batch) return;

      try {
        // withRetry: экспоненциальный backoff на сетевых сбоях
        results[batch.index] = await withRetry(
          () => this.analyzeBatch(batch),
          this.config.maxRetries,
          this.signal,
        );
      } catch (err) {
        // Один упавший батч не должен ронять весь аудит
        results[batch.index] = { files: [], error: String(err) };
      }

      completed++;
      this.report("mapping", completed, batches.length);

      if (this.config.delayBetweenBatchesMs > 0) {
        await new Promise((r) =>
          window.setTimeout(r, this.config.delayBetweenBatchesMs),
        );
      }
    }
  };

  const concurrency = Math.min(this.config.maxConcurrent, batches.length);
  const workers = Array.from({ length: concurrency }, () => worker());
  await Promise.all(workers);

  return results.filter((r) => !!r);
}

Тут есть три решения, которые я считаю принципиальными.

Изоляция сбоев: упавший батч пишет { files: [], error }, а не выбрасывает исключение наверх. Аудит хранилища на 2000 заметок не должен умирать из‑за одного таймаута на 47-м батче.

Кооперативная отмена: this.signal (AbortSignal) проверяется в начале каждой итерации. Пользователь нажал «Отмена», воркеры доедают текущий батч и останавливаются.

Rate limiting настраиваемой задержкой между батчами, потому что у бесплатных тарифов лимиты жёсткие.

LLM врёт про JSON, и это надо переживать

Просишь модель вернуть чистый JSON, получаешь JSON, обёрнутый в ```json, с преамбулой «Конечно! Вот результат:». Парсить это наивным JSON.parse означает гарантированные краши в проде. Поэтому весь парсинг идёт через защищённый экстрактор:

function extractJSON<T>(raw: string): T {
  // Срезаем markdown-обёртку
  const cleaned = raw.replace(/```json\n?/gi, "").replace(/```\n?/g, "").trim();

  // Ищем первый { или [, отбрасываем болтовню модели до структуры
  const jsonStart = Math.min(
    ...[cleaned.indexOf("{"), cleaned.indexOf("[")].filter((i) => i >= 0),
  );
  if (!Number.isFinite(jsonStart)) {
    throw new Error("JSON не найден в ответе модели");
  }

  return JSON.parse(cleaned.slice(jsonStart)) as T;
}

Это не красиво, но это реальность работы с LLM: твой парсер обязан быть устойчивее, чем контракт, который модель «обещает» соблюдать.

Reduce: компактное представление решает

В фазе Reduce все сводки сворачиваются в минимальный JSON (короткие ключи экономят токены на структуре) и уходят одним запросом на кластеризацию. Если сводок слишком много, Reduce становится иерархическим (кластеризуем группы, потом сливаем кластеры), но в большинстве хранилищ хватает одного прохода:

private async runReducePhase(summaries) {
  const compact = summaries.map((s) => ({
    p: s.path, t: s.topics, k: s.keyIdeas, tags: s.suggestedTags,
  }));

  if (JSON.stringify(compact).length < 40000) {
    return await this.clusterize(compact); // один проход
  }
  // иначе иерархический reduce по группам
  ...
}

Инкрементальная индексация: не платить дважды

Аудит дорогая по токенам операция. Прогонять всё хранилище заново при каждом запуске расточительство, особенно когда между запусками изменилось три заметки из двух тысяч. Решение это персистентный индекс с проверкой по mtime.

Схема записи на одну заметку:

export interface NoteRecord {
  mtime: number;        // mtime файла на момент анализа, ключ инкрементальности
  analyzedAt: number;
  mainIdea: string;
  keyPoints: string[];
  entities: string[];
  quality: "draft" | "developed" | "polished";
  suggestedTags: string[];
  // ...
}

Вся инкрементальность держится на одной проверке:

isStale(file: TFile): boolean {
  const rec = this.data.notes[file.path];
  return !rec || rec.mtime !== file.stat.mtime;
}

Если файл не в индексе или его mtime не совпадает с записанным, значит, его надо переанализировать. Иначе берём готовую сводку. На втором прогоне по неизменному хранилищу аудит стоит почти ноль токенов.

Два момента, которые я заложил с прицелом на будущее.

Версионирование схемы. У индекса есть SCHEMA_VERSION. Когда я меняю структуру NoteRecord, старый индекс просто игнорируется (чистый старт), а не роняет плагин на несовместимых данных:

async load(): Promise<void> {
  try {
    const parsed = JSON.parse(await this.app.vault.adapter.read(INDEX_PATH));
    if (parsed.version === SCHEMA_VERSION && parsed.notes) {
      this.data = parsed;
    }
    // другая версия, молча начинаем заново
  } catch {
    // файла нет или битый JSON, чистый старт без краша
  }
}

Флаг dirty и prune. Индекс пишется на диск только если реально менялся (dirty), а prune чистит записи для файлов, которых больше нет. Мелочи, но именно они отличают «работает у меня» от «работает у пользователя с живым, постоянно меняющимся хранилищем».

Один интерфейс на четырёх провайдеров

Я хотел, чтобы плагин работал с OpenRouter, OpenAI, Groq и локальной Ollama(особенно важно вопрос для меня стоял с последним,потому что многим важна возможность работы на локальном LLM), и чтобы если что добавить пятого можно было, не переписывая ядро. Спасает то, что почти все они говорят на диалекте OpenAI‑совместимого API. Различия в заголовках и мелочах эндпоинта изолированы в одном месте:

function buildHeaders(settings: AIHubSettings): Record<string, string> {
  const provider = settings.provider ?? "openrouter";
  const headers: Record<string, string> = { "Content-Type": "application/json" };

  if (provider === "ollama" && !settings.apiKey.trim()) {
    headers["Authorization"] = "Bearer ollama"; // локально ключ не нужен
  } else {
    headers["Authorization"] = `Bearer ${settings.apiKey}`;
  }

  if (provider === "openrouter") {
    headers["HTTP-Referer"] = "https://obsidian.md";
    headers["X-Title"] = "Obsidian AI Hub";
  }
  return headers;
}

Всё остальное ядро не знает, с кем разговаривает, оно просто шлёт POST на ${baseUrl}/chat/completions. Новый провайдер это новая запись в профилях с его baseUrl и дефолтной моделью.

Как выглядит выбор модели в настройках
Как выглядит выбор модели в настройках

Стриминг с детектором петель

Текстовые операции (переписать, расширить, суммаризировать),которые также есть в моем плагине, стримятся в редактор по токену через SSE. Здесь две неочевидные проблемы,которые портят визуализацию или вообще все ломают.

Первая, парсинг SSE: чанки приходят не по строкам, строка может прийти разрезанной между двумя read(). Поэтому держим буфер и достаём из него полные строки, оставляя хвост:

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let generated = "";

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split("\n");
  buffer = lines.pop() ?? ""; // неполный хвост обратно в буфер

  for (const line of lines) {
    if (!line.startsWith("data: ")) continue;
    const jsonStr = line.slice(6);
    if (jsonStr === "[DONE]") return;

    const content = JSON.parse(jsonStr).choices?.[0]?.delta?.content;
    if (!content) continue;

    generated += content;
    if (detectRepetitionLoop(generated)) {
      console.warn("Обнаружена петля повторений, стрим прерван");
      return; // вторая проблема
    }
    onToken(content);
  }
}

Вторая проблема это залипание модели. Дешёвые и локальные модели иногда срываются в бесконечное повторение одного и того же блока(сам с этим в процессе написания сталкивался не раз и причину этого явления точно так и не понял). В стриме это выглядит как заметка, которая пишет сама себя до бесконечности (и жжёт токены). Детектор ищет в хвосте сгенерированного текста повторяющиеся блоки разной длины и прерывает стрим, если блок повторился N раз подряд:

export function detectRepetitionLoop(buffer, window, threshold): boolean {
  if (buffer.length < window * 2) return false;
  const tail = buffer.slice(-window * threshold);

  for (let chunkLen = 20; chunkLen <= window / 2; chunkLen += 10) {
    const probe = tail.slice(-chunkLen);
    if (probe.trim().length < 10) continue;

    let count = 0, pos = tail.length - chunkLen;
    while (pos >= chunkLen) {
      if (tail.slice(pos - chunkLen, pos) === probe) {
        if (++count >= threshold - 1) return true;
        pos -= chunkLen;
      } else break;
    }
  }
  return false;
}

Что ещё умеет плагин

В целом,если честно говорить,изначально я хотел написать простенький плагин для базовых ИИ‑функций(которых в Obsidian куча),идея с Аудитом пришла чуть позже,но,по моему мнению,она и стала главной фишкой. Поверх той же мультипровайдерной обвязки,кроме Аудита построено ещё несколько вещей, которыми я сам пользуюсь каждый день.

Пакетная обработка. Фильтруешь заметки по папке, тегам или диапазону дат, выбираешь действие (улучшить стиль, суммаризировать, проставить теги, исправить грамматику или свой промпт) и прогоняешь всё разом. Под капотом тот же пул воркеров, что и в Map‑фазе аудита: сотни заметок обрабатываются параллельно с ограничением по конкуренции и ретраями. По сути это аудит наоборот: там читаем и анализируем, тут читаем и модифицируем.

Инлайн‑работа с текстом. Прямо в редакторе из контекстного меню или по горячей клавише: сгенерировать текст и вставить под курсор, проанализировать выделение, расширить, сократить, переформулировать. Всё со стримингом, так что результат печатается на глазах, и тем же детектором петель(про что я писал выше).

Генерация Dataview‑запросов. Описываешь словами, что хочешь вытащить, модель отдаёт готовый Dataview‑блок. Удобно, когда помнишь, что Dataview умеет нужное, но лень вспоминать синтаксис.

Экспорт результатов аудита. Кластеры тем выгружаются в Canvas (визуальная карта связей хранилища), а полный отчёт в обычную markdown‑заметку с Dataview‑вставками и прямыми ссылками на каждый упомянутый файл. То есть результат аудита это не модальное окно, которое закрыл и забыл, а артефакт, который остаётся в хранилище и по которому можно работать.

Локальный режим через Ollama стоит подчеркнуть отдельно: при нём ни одна заметка не покидает машину. Для тех, у кого в хранилище личные или рабочие данные, это не «приятный бонус», а условие, при котором плагином вообще можно пользоваться.

Путь в каталог: 120 ошибок ревью к нулю

Опубликовать плагин в официальном каталоге Obsidian означает пройти автоматическую проверку, и она строгая,а это было для меня важным этапом,практически финишным,чтобы плагин мог хоть кто‑то заметить,а не лежал просто мертвым репозиторием на моем маленьком гитхабе. Первый прогон дал около 120 блокирующих ошибок. Самое поучительное:

Инлайн‑стили запрещены. Десятки el.style.color = ... и el.style.cssText = ... пришлось вынести в CSS‑классы. Динамические значения (цвет статуса, ширина прогресс‑бара) через CSS‑переменные и setCssProps, статику через классы.Изначально и планировалось все вынести сразу в отдельный файл,но на этапе реализацию мне было так проще,но эта привычка от которой лучше отказываться,и пытаться сразу все разбивать четко по файлам.

innerHTML запрещён (XSS‑риск). Сборку DOM из строк переписал на обсидиановский API (createEl, appendText, empty()).

Специфичность против !important. Когда переносил стили в файл styles.css, дефолты Obsidian начали перебивать мои правила, потому что инлайн‑стили раньше выигрывали по приоритету просто по факту того, что они инлайн. Сначала закрыл это !important, но линтер каталога ругается и на него. Финальное решение это поднять специфичность селекторами (.modal .ai-hub-filters .setting-item) вместо силового !important.

setTimeout к window.setTimeout, хардкод .obsidian к Vault#configDir, это мелочи совместимости, которых десятки. Каталог требует их все.

В сумме: от 120 ошибок до нуля блокирующих, плюс полная локализация RU/EN (переключаются и интерфейс, и сами промпты, так что на английском ассистент отвечает по‑английски).

Грабли, на которых я посидел

Мёртвые бесплатные модели. Захардкодил в дефолтах одну из бесплатных моделей OpenRouter, через неделю её убрали, и новые пользователи бы начали ловить 404 при первом запуске. Худшее первое впечатление. Решение это кнопка, которая тянет актуальный список бесплатных моделей прямо из openrouter.ai/api/v1/models (ключ не нужен), фильтрует по суффиксу :free и сортирует по размеру контекста. Захардкоженные списки в LLM‑приложениях гниют, данные должны быть живыми.

Конфиг‑папка не всегда .obsidian. Пользователь может её переименовать. Хардкод .obsidian/... ломается у таких людей молча. Правильно это this.app.vault.configDir.

requestUrl против fetch. Obsidian рекомендует свой requestUrl вместо браузерного fetch (обходит CORS на десктопе). Но requestUrl не умеет стриминг, поэтому для потоковой генерации я осознанно оставил fetch. Не любую рекомендацию линтера надо слепо выполнять, иногда у тебя есть причина, и её надо понимать.

Итоги

Что оказалось интереснее всего с инженерной точки зрения:

MapReduce это не только про Hadoop. Тот же паттерн «разбей, обработай параллельно, сверни» отлично решает проблему «корпус не влезает в контекст LLM».

Структурированный промежуточный результат (сводка вместо текста) это то, что делает Reduce‑фазу дешёвой и осмысленной.

Идемпотентность через mtime‑индекс превращает дорогую операцию в почти бесплатную на повторных прогонах.

Парсер должен быть устойчивее контракта модели, всегда.

Плагин называется Vault Audit AI, код открыт на GitHub, поставить можно из каталога сообщества Obsidian. Если у вас тоже хранилище разрослось в хаос, попробуйте, и расскажите, что нашёл аудит.

Буду рад обсудить с вами плагин и возможностью по его улучшению в комментариях.