У меня в 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. Если у вас тоже хранилище разрослось в хаос, попробуйте, и расскажите, что нашёл аудит.

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

Комментарии (7)


  1. Void-Cowboy
    29.06.2026 11:50

    зачем? Обсидиан сам по себе строит карты и прочее - подключаете mcp и радуетесь жизни


  1. Ziverpup Автор
    29.06.2026 11:50

    Вопрос по факту,но все же мне есть,что сказать.Граф-вью и Canvas показывают связи, которые уже есть, но не находят того, чего не хватает: сироты, дубли тем под разными тегами, какие заметки стоило бы связать, про что собственно я и писал в статье.Это анализ содержания, а не визуализация структуры.

    Про MCP,это рабочая альтернатива,но для меня было пару нюансов. Разница в трёх вещах: плагин может работать полностью локально через Ollama (ничего не уходит в облако), даёт специализированный пайплайн вместо чата (инкрементальный индекс, кластеризация, отчёт в Canvas), и ставится в два клика без настройки сервера. MCP мощнее и гибче, но это другой уровень входа,и не всегда среднестатистическому пользователю Obsidian хочется с этим возиться.

    Хотя соглашусь,что для кого-то MCP закроет задачу полностью и даже лучше.


  1. Petroleum_man
    29.06.2026 11:50

    Делаю аи приложение для работы с доками на локальных моделях, почти все перечисленные грабли собрал и решения примерно такие же)

    Могу добавить, что локальные модели ещё могут не только циклы выдавать, но писать ответ в секцию reasoning и наоборот. С max tokens свои приколы. Запрошенный max tokens должен покрывать основной ответ и thinking токены, число которых неизвестно. А все это вместе input tokens + max tokens должно укладываться в контекстное окно модели. А если нет - либо получаем ошибку от провайдера, либо обрезку ответа


    1. Ziverpup Автор
      29.06.2026 11:50

      Честно,пока глубокого опыта с локалками нет, работал в основном через OpenRouter, так что на reasoning/max_tokens в полный рост не наступал. Но как раз собираюсь поиграться с локальными моделями, так что ваша заметка про thinking-токены очень в тему, заберу на будущее. А поделитесь, как вы сами с этими reasoning-токенами справляетесь? Интересен рабочий подход из первых рук.


      1. Petroleum_man
        29.06.2026 11:50

        Придумал только детектить косячные ответы и делать ретрай. Косячные ответ это пустой или обрезанный по max tokens, там вроде в поле stop reason будет length, циклы (детектил встроенной в vllm функцией, на openrouter такого нет, придется самому делать). Детекциию протечек управляющих токенов нужно делать под модель, у них разные токены, у кого-то think, у кого-то thought, у кого то протекает Тул колл | tool | - что то вроде этого.

        С опенроутер ещё нужно быть осторожным. Во первых, он не до конца openai compatible - нужно по документации уточнять что он принимает и как обрабатывает. Ризонинг там вроде включается через extra body. Во-вторых, он перенаправляет запросы сторонним провайдерам. А их поведение не гарантированно. Запрашиваешь ризонинг, может прислать без ризонинга

        Если делаешь агента, который в цикле вызывает тулы, то везде дефолтное поведение - есть тул колл, цикл продолжается, нет тул колла - цикл завершается. Локальная модель может написать в content : вызову тул... И не вызвать его в секции тул кола. Цикл закрывается. Никак особо не решается, ответ модели формально кокорректный.


  1. Del137
    29.06.2026 11:50

    Спасибо за статью, очень в тему пришлась. У меня похожая ситуация — Obsidian Vault на несколько тысяч заметок (архив переписки с ChatGPT за пару лет, плюс рабочие документы), и я как раз сейчас руками собираю похожий пайплайн: embeddings + кластеризация + Qdrant для семантического поиска через локального агента.

    у меня локальная модель (Qwen3.6-35B) тоже норовит обернуть ответ в markdown с преамбулой вместо чистой структуры, и наивный JSON.parse валился постоянно. Возьму вашу функцию извлечения JSON один в один, она явно прошла через те же грабли что и я.

    Не все понял, ибо далеко не программист, но главное чтобы работало)


    1. Ziverpup Автор
      29.06.2026 11:50

      Рад, что функция пригодилась,она и правда выстрадана, LLM врёт про чистый JSON с завидным постоянством. Любопытно, что мы пошли разными путями: у вас классический RAG-стек ,а я кластеризацию отдаю прямо LLM в reduce-фазе, без отдельного векторного хранилища. Мой путь дешевле в инфраструктуре, но хуже масштабируется на действительно больших объёмах и не даёт семантического поиска как побочки. На ваших тысячах заметок embeddings-подход, наверное, выиграет. Расскажете потом, как Qdrant себя поведёт,любопытно было бы тоже в этом покопаться