В прошлой статье я рассказывал, как добавил в Obsidian локальный семантический индекс. Сам проект развивается открыто, код лежит здесь:
GitHub: https://github.com/zinverno/obsidian-ai-hub
С тех пор semantic-слой заметно вырос. Появились автоматическая синхронизация индекса, Similar Notes и поиск потенциальных дублей. В какой-то момент стало понятно, что сам по себе семантический поиск решает только половину задачи.
Базовая схема у меня была такой:
Markdown ↓ chunks ↓ embeddings ↓ local vector index ↓ semantic search
Можно было написать запрос вроде «как хранить локальный индекс» и получить заметку, где такой фразы буквально нет, но смысл совпадает. Для MVP этого хватало, но довольно быстро начали раздражать две вещи: индекс приходилось обновлять вручную, а уже посчитанные embeddings использовались только для поиска.
Так появились две следующие части системы: автоматическая синхронизация semantic index и Semantic Discovery, то есть Similar Notes плюс поиск потенциальных дублей.
Индекс, который живёт вместе с Vault
Первая версия semantic index была намеренно ручной. Пользователь сам запускал индексацию Vault или отдельной заметки. Для MVP это даже полезно: плагин не начинает неожиданно считать embeddings всей базы знаний сразу после установки.
После первоначальной индексации такой режим быстро надоедает. Изменил заметку, обнови индекс. Переименовал файл, снова обнови. Удалил заметку, опять синхронизируй.
Поэтому после первого построения индекс теперь подписывается на события Vault:

IndexingService в локальный LocalVectorStore.Самое интересное здесь не поймать событие, а правильно обработать их последовательность. Если пользователь быстро печатает текст, Obsidian может выдать несколько modify подряд:
modify A modify A modify A modify A
Нет смысла четыре раза пересчитывать embeddings. Scheduler ждёт debounce, сейчас это 1500 мс, и сворачивает такую пачку в один финальный upsert A.
Содержимое заметки при этом читается непосредственно перед flush. То есть в индекс уходит последняя версия файла, а не промежуточное состояние, которое прожило несколько сотен миллисекунд.
С переименованиями похожая история. Если файл за короткое время прошёл цепочку:
A → B B → C C → D
нет необходимости честно воспроизводить всю эту драматургию в векторном индексе. В конечном состоянии нам нужен актуальный документ под новым путём, а старые пути должны исчезнуть.
Clear оказался неожиданно сложнее, чем поиск
Самая неприятная гонка появилась вокруг команды очистки индекса.
Представим такой порядок:
clear index save suspended=true
Пользователь нажал Clear, индекс удалился, после чего плагин должен сохранить marker, который говорит, что автоматическая синхронизация приостановлена. Если Obsidian завершится между этими двумя операциями, после следующего запуска приложение увидит пустой индекс, но ещё не увидит marker приостановки.
Startup reconciliation в такой ситуации вполне может решить, что индекс просто пропал, и попытаться его восстановить.
Очень заботливый Delete.
Поэтому порядок пришлось перевернуть:
save suspended=true ↓ pause scheduler ↓ exclusive mutation ↓ clear index
Теперь даже если приложение завершится после сохранения marker, но до физической очистки, состояние останется безопасным. Автосинхронизация не начнёт самовольно восстанавливать индекс.
Такие вещи почти не видны в обычном happy path, но именно из них потом появляются самые неприятные баги.
Similar Notes без новых API-запросов
После автосинхронизации началась более приятная часть. Если заметка уже проиндексирована, у нас есть embeddings всех её chunks, а значит для команды «найти похожие заметки» не обязательно снова отправлять текст embedding provider.
Можно построить представление всего документа из уже существующих vectors. В первой версии я использовал простой вариант:
normalize( mean( normalize(chunk1), normalize(chunk2), ... ) )
Сначала каждый chunk vector нормализуется, затем vectors усредняются, после чего итоговый document vector снова нормализуется.
Дальше выполняется обычное сравнение:
current note ↓ document vector ↓ compare with other documents ↓ sort by similarity
Сам исходный документ исключается из результатов, один destination path появляется только один раз, а snippet выбирается отдельно по наиболее похожему chunk.
Получается, что система отвечает сразу на два разных вопроса: насколько две заметки похожи целиком и какой конкретный фрагмент лучше показать пользователю.

И главное, для этого не нужен новый embedding request. Мы используем уже построенный локальный индекс.
Почти нулевой vector и similarity 1.0
Во время adversarial review нашёлся довольно красивый численный edge case.
Допустим, заметка состоит из двух chunks:
[1, 0] [-1, 0.000000001]
Они практически полностью компенсируют друг друга, поэтому средний vector получается почти нулевым. Но если просто нормализовать оставшийся численный шум, можно получить направление примерно такого вида:
[0, 1]
Микроскопическая погрешность внезапно превращается в уверенное semantic direction. После этого такой документ способен получить cosine similarity около 1.0 к совершенно другой заметке.
Формально математика отработала правильно, практически результат бессмысленный.
Поэтому перед финальной нормализацией я добавил оценку coherence:
norm(sum(chunkVectors)) ----------------------- chunkCount
Если vectors chunks в основном смотрят в одну сторону, значение остаётся достаточно высоким. Если они почти полностью взаимно уничтожаются, coherence стремится к нулю.
Документы с крайне низкой coherence просто не участвуют в Semantic Discovery. Лучше не показать результат вообще, чем уверенно показать случайный.
Potential Semantic Duplicates
Если document vectors уже построены, следующий шаг напрашивается сам собой: можно искать не только похожие заметки для текущего документа, но и очень близкие пары во всём Vault.
Первая версия алгоритма намеренно простая:
for every unique pair A, B: score = cosine(A, B) if score >= 0.95: candidate
Порог сейчас равен 0.95. Пары канонизируются, поэтому A-B существует один раз, а B-A и A-A не появляются.
В интерфейсе я специально не называю такие документы Duplicate. Формулировка скорее Potentially Similar Pair. Cosine similarity 0.97 всё-таки ещё не лицензия на удаление файла.

Как вернуть 100 результатов и случайно не сохранить полмиллиона
Первая реализация поиска дублей выглядела примерно так:
const candidates = []; for (const pair of allPairs) { if (pair.score >= threshold) { candidates.push(pair); } } return candidates .sort(compare) .slice(0, 100);
Логически всё правильно, но есть небольшая проблема. Для 1000 документов количество уникальных пар равно:
499 500
То есть ради 100 результатов программа потенциально сначала создаёт почти полмиллиона объектов. На десктопе это ещё может пройти незаметно, на мобильном устройстве шутка становится менее смешной.
Поэтому сейчас используется bounded top-K heap. Во время полного прохода хранится максимум:
limit = 100
кандидатов.
Сложность сравнения документов всё ещё остаётся O(N² · D), где N это число документов, а D размерность vector. Зато память под кандидаты становится O(limit) вместо потенциального O(N²).
Для небольших и средних personal Vault этого пока более чем достаточно. HNSW сюда я сознательно не тащил. В какой-то момент ведь надо разрешить себе просто закончить функцию.
Короткие заметки тоже пришлось фильтровать
Ещё одна проблема появилась с очень маленькими документами. Заметка из пары слов вроде Docker или Meeting tomorrow может случайно получить очень высокую similarity с другим коротким текстом.
С математической точки зрения всё нормально, но для поиска дублей это часто просто шум. Поэтому короткие документы в duplicate discovery отбрасываются.
Сейчас минимальный порог составляет 32 значимых Unicode-буквы или цифры. Это не фундаментальная константа семантического поиска, а обычный практический guardrail против бессмысленных результатов.
Что получилось в итоге
Сейчас semantic-слой уже умеет не только искать заметки по смыслу. Поверх одного локального индекса работают три сценария:
Semantic Search, поиск по смыслу;
Similar Notes, поиск заметок, похожих на текущую;
Potential Semantic Duplicates, поиск подозрительно похожих пар.
Сам индекс при этом автоматически синхронизируется с Vault.
Markdown-файлы не изменяются, Similar Notes не делает новых embedding requests, duplicate scan тоже работает по уже сохранённым vectors. Remote provider нужен только для embeddings новых или изменённых chunks, а Clear и Rebuild остаются явными действиями пользователя.
К этому моменту тестовый набор вырос до 627 тестов. Но само число уже не настолько интересно.
Гораздо полезнее оказалось намеренно ломать отдельные свойства реализации и смотреть, замечают ли это тесты. Например, убрать source exclusion, сломать normalization centroid, перевернуть heap comparator, разрешить stale runtime или убрать barrier между discovery и Clear.
Если реализация испорчена, а весь test suite остаётся зелёным, значит проблема уже не только в реализации.
Что дальше
Сейчас локальный semantic-слой для меня выглядит почти законченным. Есть chunking, embeddings, incremental indexing, automatic sync, semantic search, Similar Notes и potential duplicates.
До сих пор плагин в основном отвечал на вопрос:
Где находится информация, похожая по смыслу на мой запрос?
Следующий этап немного другой:
Что мой Vault вообще знает по этому вопросу?
То есть логичный следующий шаг это RAG поверх уже существующего retrieval. Сам retrieval у нас уже есть, индекс тоже есть. Остаётся научиться собирать найденный контекст и использовать его для генерации ответа.
Но это уже отдельная история.
Ссылки
Vault Audit AI на GitHub: https://github.com/zinverno/obsidian-ai-hub
Мой GitHub: https://github.com/zinverno
Пока достаточно того, что обычная папка с Markdown-файлами постепенно обзавелась собственным маленьким поисковым движком.
Главное вовремя остановиться, пока рядом с .obsidian случайно не появился Kubernetes.

