В комментариях к прошлой статье мне написали примерно следующее: граф связей выглядит красиво, но хороший поиск по заметкам полезнее.
Спорить было трудно. Я сам регулярно открывал Obsidian, помнил, что где-то писал нужную мысль, но не помнил ни заголовок, ни точную формулировку. Обычный поиск в такой ситуации помогал примерно как человек, который на вопрос «где мои ключи?» отвечает «там, где ты их оставил».
Например, в заметке могло быть написано:
При смене настроек нельзя создавать второй экземпляр хранилища на том же пути.
А через неделю я искал:
гонка при обновлении API-ключа
По смыслу это одна тема. По словам пересечение почти нулевое.
Так в Vault Audit AI появился семантический поиск. Пользователь вводит обычную фразу, плагин ищет не точное совпадение текста, а близкие по смыслу фрагменты заметок. Индекс хранится внутри хранилища Obsidian, отдельный сервер и внешняя векторная база не нужны.
На уровне интерфейса всё выглядит довольно просто: включить функцию, выбрать embedding-модель, проиндексировать хранилище и открыть окно поиска.
Внутри получилось заметно веселее.
В этой статье разберу весь путь: как Markdown превращается в векторы, зачем заметки резать на куски, почему я не стал поднимать Qdrant, как устроено инкрементальное обновление и каким образом 453 зелёных теста всё равно пропустили две неприятные гонки.
Код на TypeScript, фрагменты немного сокращены ради читаемости.
Исходный код открыт: репозиторий Vault Audit AI на GitHub. В статье я показываю сокращённые фрагменты, а в репозитории можно посмотреть production-реализацию, тесты и историю изменений.
Если слова embeddings, cosine similarity и «векторное хранилище» пока звучат как заклинания из чужого README, всё нормально. Я буду разбирать систему снизу вверх и каждый термин привязывать к простой задаче: заметку нужно разрезать на понятные куски, кусочки превратить в координаты, координаты сохранить, а потом найти среди них ближайшие. То есть это не учебник по линейной алгебре, а история о том, как собрать полезную функцию и по дороге не наступить на собственные асинхронные грабли.
Обычный полнотекстовый поиск работает со словами. Он отлично находит PostgreSQL, если в заметке написано PostgreSQL. Иногда умеет морфологию, иногда опечатки, иногда подсвечивает совпадения так бодро, будто уже решил задачу.
Но он плохо отвечает на запросы вроде:
почему индекс сломался после смены модели;
как не анализировать заметку повторно;
что делать с двумя параллельными писателями;
где я рассуждал про автоматическую организацию базы знаний.
Формулировка запроса может сильно отличаться от текста заметки.
Семантический поиск решает это через embeddings. Модель получает текст и возвращает массив чисел, вектор. У фрагментов с похожим смыслом векторы оказываются рядом.
Упрощённо процесс выглядит так:
«смена API-ключа во время индексации» ↓ embedding-модель ↓ [0.013, -0.082, 0.141, ...] ↓ сравнение с векторами заметок ↓ наиболее близкие фрагменты
Никакой магии в самом поиске дальше нет. Векторы нормализуются, после чего близость можно считать через скалярное произведение. Чем ближе результат к единице, тем сильнее совпадение.
Самая сложная часть начинается не здесь. Сложная часть начинается с вопроса: что именно отправлять в embedding-модель и как потом поддерживать этот индекс в живом хранилище, где заметки постоянно меняются.
Первой мыслью были Qdrant, PostgreSQL с pgvector или хотя бы отдельный локальный сервис.
Технически всё это решало задачу. Практически получался странный плагин для заметок, которому для поиска нужно сначала объяснить пользователю Docker.
Vault Audit AI работает как Community Plugin. Он должен устанавливаться из каталога Obsidian и запускаться без отдельной инфраструктуры. У части пользователей будет OpenRouter, у части OpenAI-совместимый endpoint, у кого-то локальная Ollama. Но требовать ещё один сервер только ради хранения нескольких тысяч векторов мне не хотелось.
Плюс Obsidian работает не только на десктопе. Плагин не помечен как desktop-only, поэтому Node.js API, системный SQLite и привычный серверный стек сразу становились плохой основой.
Так появился собственный LocalVectorStore.
Звучит немного самоуверенно. Примерно как «не буду ставить базу данных, быстро напишу свою». Обычно после этой фразы где-то начинает тихо плакать инженер по надёжности.
Но задача здесь ограниченная:
хранить векторы одного фиксированного размера;
связывать их с метаданными Markdown-фрагментов;
искать ближайшие результаты линейным проходом;
атомарно применять небольшие изменения;
переживать перезапуск и незавершённую запись.
Для личного хранилища на несколько тысяч заметок линейный поиск вполне приемлем. ANN и HNSW пригодятся позже, если объёмы действительно станут большими. До этого момента отдельная распределённая система была бы архитектурой ради архитектуры.
Индексирование идёт слева направо:
Поиск идёт немного короче:
P.S. Диаграммы я оставил на английском, чтобы названия совпадали с реальными сущностями в коде: MarkdownChunker, EmbeddingProvider, IndexingService и LocalVectorStore. Так между статьёй, схемами и репозиторием меньше лишнего перевода.

IndexingService.Компоненты специально разделены.
MarkdownChunker ничего не знает про embeddings. EmbeddingProvider ничего не знает про Obsidian. LocalVectorStore не читает заметки. IndexingService соединяет их в один pipeline, а Obsidian-слой отвечает только за файлы, команды, настройки и интерфейс.
Такой разнос сначала кажется избыточным. Потом меняешь провайдера, тестируешь хранилище без Obsidian и понимаешь, что избыточным он был ровно до первого изменения требований.
Самый простой вариант: взять полный текст заметки, отправить его модели и сохранить один embedding.
Работает он не очень.
Во-первых, длинная заметка может содержать несколько разных тем. Один вектор усредняет их в нечто среднее. Запрос про маленький фрагмент рискует потеряться внутри большого конспекта.
Во-вторых, пользователь хочет попасть не просто в файл, а примерно в ту секцию, где лежит ответ.

Поэтому заметка режется на chunks. Сейчас базовые настройки такие:
const DEFAULT_CHUNKING_OPTIONS = { targetChars: 1200, maxChars: 1800, overlapChars: 200,
};
Chunk хранит не только текст:
interface NoteChunk { id: string; path: string; ordinal: number; headingPath: string[]; text: string; contentHash: string; source: { startOffset: number; endOffset: number; startLine: number; endLine: number; };
}
Здесь важны четыре вещи.
Если кусок находится внутри:
# Архитектура
## Индексация
### Обновление изменённых chunks
то headingPath сохраняет эту цепочку. В результате интерфейс может показать пользователю не безымянный отрывок, а понятный путь внутри документа.
ID chunk должен одинаково вычисляться при повторном запуске, пока его положение и содержание логически не изменились. Иначе любое обновление заметки превращало бы весь файл в набор «новых» фрагментов.
contentHash нужен для инкрементальности. Если ID и метаданные совпадают, а хеш не изменился, embedding можно не запрашивать повторно.
source.startLine и source.endLine позволяют открыть найденную заметку сразу рядом с нужным фрагментом.
На практике chunker оказался отдельной большой задачей. Markdown любит списки, заголовки, кодовые блоки, таблицы, длинные абзацы и Unicode. Резать всё каждые 1200 символов было бы просто, но результат иногда начинался посреди кода или заканчивался на половине логической секции.
Поэтому chunker сначала ищет естественные границы документа, а жёсткий предел использует уже как страховку.
У плагина три варианта embeddings:
OpenRouter;
OpenAI-совместимый API;
Ollama.
Общий контракт очень маленький:
interface EmbeddingProvider { readonly id: "openrouter" | "openai-compatible" | "ollama"; readonly model: string; embed(texts: string[]): Promise<Float32Array[]>; dimensions(): Promise<number>;
}
На вход приходит массив текстов, на выходе массив Float32Array.
Почему именно Float32Array, а не обычный number[]? Потому что векторов много, и хранить каждое число как полноценный JavaScript number с обвязкой дорого. Плюс typed arrays проще складывать в бинарный файл без промежуточного JSON размером с небольшую энциклопедию.
Есть ещё одна важная сущность, embeddingSpaceId.
Векторы от разных моделей нельзя честно смешивать. Даже если размерность совпадает, координаты у них обозначают разные пространства. Вектор от одной модели и вектор запроса от другой сравнивать можно, но результат будет иметь примерно ту же научную ценность, что и сравнение температуры с номером квартиры.
Поэтому идентичность пространства включает:
provider
model
normalized endpoint
dimensions
API-ключ туда не входит. Смена ключа не меняет математику модели, значит перестраивать индекс не нужно.
А вот смена модели, endpoint или размерности делает существующий индекс несовместимым. Плагин не удаляет его автоматически. Пользователь получает понятный статус и отдельно подтверждает rebuild.
Мне хотелось избежать ситуации, когда опечатка в настройках незаметно стирает несколько тысяч уже рассчитанных embeddings. Автоматизация полезна ровно до момента, пока не начинает слишком уверенно удалять данные.

Индекс состоит из manifest и бинарного файла.
В manifest лежат:
версия схемы;
поколение индекса;
размерность;
embeddingSpaceId;
количество записей;
метаданные chunks;
имя бинарного файла.
Сами векторы хранятся подряд в бинарном виде:
vector 0: Float32 x dimensions
vector 1: Float32 x dimensions
vector 2: Float32 x dimensions
...
Это даёт простую адресацию. Если размерность равна d, то вектор с индексом i начинается с позиции i * d.
В памяти используется один непрерывный Float32Array. Поиск проходит по нему линейно и считает score для каждой записи.
Для небольших и средних личных хранилищ этого достаточно. Зато нет отдельного процесса, сетевого протокола и миграций внешней базы.
Но обычной записи двух файлов мало. Представим, что Obsidian закрыли между обновлением manifest и binary. После запуска один файл описывает поколение 8, другой содержит поколение 7. Индекс превращается в коробку с деталями от двух разных конструкторов.
Поэтому сохранение идёт через временные и резервные файлы. Новая пара сначала полностью записывается и проверяется. Предыдущее подтверждённое состояние остаётся доступным как backup. Только после валидации новое поколение становится основным.
Внутреннее состояние в памяти переключается в самом конце, после успешной записи.
То есть mutation работает так:
собрать новое состояние ↓
записать временную пару ↓
прочитать и проверить её ↓
сохранить подтверждённый backup ↓
установить новое основное состояние ↓
ещё раз проверить ↓
переключить память на новое поколение
Если что-то падает раньше последнего шага, поиск продолжает видеть старый committed snapshot.
Это важно не только при аварии. Та же модель делает обычный поиск предсказуемым во время индексации.
Первый запуск дорогой: нужно разбить заметки, отправить все chunks провайдеру и сохранить векторы.
Повторный запуск не должен делать то же самое ещё раз.
IndexingService читает только метаданные существующего индекса и строит две карты:
const existingById = new Map<string, VectorChunkMetadata>();
const existingByPath = new Map<string, VectorChunkMetadata[]>();
Дальше для каждого нового chunk сравниваются:
ID;
путь;
заголовки;
порядковый номер;
content hash;
диапазон исходного текста;
preview.
Если всё совпало, chunk считается неизменённым. Его embedding остаётся прежним.
Если изменилось одно поле, chunk отправляется на пересчёт.
Если старый chunk исчез, его ID попадает в удаление.
Если при полном reconcile заметка вообще пропала из Vault, удаляется весь её path.
Упрощённо:
if (!previous || !metadataEqual(previous, chunk.metadata)) { changedChunks.push(chunk);
}
await embedAll(changedChunks);
await store.applyChanges({ deletePaths, deleteIds, upserts,
});

Здесь принципиально важна последняя часть. Все embedding-batches сначала полностью получаются и проверяются. Только потом выполняется одна mutation.
Если второй batch упал, первый не успевает частично записаться. Старый индекс остаётся целым.
Полный no-op тоже настоящий. Если ничего не изменилось, provider не вызывается, applyChanges() не вызывается и generation не растёт.
Кажется мелочью, пока embedding API не начинает тарифицировать каждую «мелочь» отдельно.
Поисковый запрос проходит тот же embedding provider, что использовался для индекса.
Перед запросом проверяются базовые вещи:
строка не пустая;
нет NUL;
длина не превышает лимит;
индекс не пустой;
provider вернул один Float32Array;
размерность совпадает;
все значения конечные;
вектор не нулевой.
После этого LocalVectorStore.search() возвращает совпавшие chunks.
Но показывать пользователю список из двадцати фрагментов одной заметки неудобно. Поэтому результаты группируются по документам.
Score заметки равен лучшему score её chunk. Внутри остаются несколько сильнейших совпадений:
const grouped = new Map<string, SemanticDocumentResult>();
for (const result of results) { const document = grouped.get(result.path) ?? { path: result.path, score: result.score, matches: [], }; document.score = Math.max(document.score, result.score); document.matches.push(result); grouped.set(result.path, document);
}
Дальше заметки сортируются по score, а при равенстве по пути. Это нужно не только для красоты. Детерминированный порядок делает поведение повторяемым и сильно упрощает тесты.
В окне поиска пользователь видит:
путь к заметке;
score;
заголовок секции;
короткий preview;
несколько совпавших фрагментов.
Клик открывает точный vault-relative path и пытается перейти к строке лучшего chunk.

Обычный поиск отвечает: «слово встречается в этих файлах».
Семантический отвечает: «вот заметки, где ты, скорее всего, рассуждал об этой идее, даже если называл её иначе».
После первой реализации все тесты проходили. Их было 453.
Можно было выдохнуть, сделать релиз и через пару дней получить очень творческий баг от пользователя. К счастью, вместо этого я устроил отдельный adversarial review.
Нашлась гонка.
Обычный search мог выполняться одновременно с clear или rebuild. Сам LocalVectorStore работал атомарно, поэтому физической порчи данных не происходило. Но controller продолжал держать старый runtime, пока файлы индекса уже удалялись или заменялись.
Получалась последовательность:
1. Search захватил runtime поколения 1
2. Query отправился в embedding provider
3. Пользователь запустил rebuild
4. Старый индекс удалился
5. Новый runtime построил поколение 1 с другими данными
6. Старый search вернулся и показал результат из удалённого индекса
Пользователь только что подтвердил очистку, а поиск радостно показывает то, чего уже как бы нет.
На уровне памяти всё объяснимо. На уровне интерфейса выглядит как полтергейст.
Простой флаг busy в начале метода не решал проблему. Search мог стартовать раньше rebuild и завершиться позже него. Нужно было блокировать весь async-проход, включая ожидание embedding API.
Так появился writer-preferred read/write barrier.
Shared lease получают:
search;
обычная индексация;
обновление текущей заметки;
чтение статуса с подготовкой runtime.
Exclusive lease получают:
clear;
rebuild.
async withShared<T>(operation: () => Promise<T>): Promise<T> { const lease = await this.acquireShared(); try { return await operation(); } finally { lease.release(); }
}
Search и обычная индексация могут идти параллельно. Search в этот момент видит последнее подтверждённое поколение, что нормально.
Clear и rebuild ждут завершения уже начатых shared-операций. Как только exclusive встал в очередь, новые readers его не обгоняют. Иначе при постоянном потоке поисков перестройка могла бы ждать до следующего геологического периода.
Вторая гонка оказалась неприятнее.
Runtime зависит от настроек:
provider;
model;
endpoint;
API key;
enabled.
При изменении настроек controller инвалидировал текущий runtime. Логика выглядела разумно: настройки новые, значит создаём новый provider и новый runtime.
Проблема была в том, что runtime создавал ещё и новый LocalVectorStore.
Сценарий:
1. Runtime A открыл store на semantic-index/, generation 0
2. Первая индексация A зависла на embedding provider
3. Пользователь поменял API key
4. Создался runtime B
5. Runtime B открыл второй store на том же semantic-index/
6. Оба экземпляра запомнили generation 0
7. Runtime A записал generation 1
8. Runtime B остался в памяти на generation 0
Search через B видел пустой индекс. Следующая запись B обнаруживала, что durable snapshot изменился снаружи, и падала с контролируемой persistence error.
Данные не потерялись, что уже хорошо. Но активный runtime становился устаревшим и непригодным для записи.
Особенно обидно, что смена API key вообще не меняет embedding space. Новый ключ должен заменить provider, но индекс остаётся тем же.
Исправлением стал controller-owned registry по basePath.
const existing = entries.get(normalizedBasePath);
if (existing) { if ( existing.dimensions !== dimensions || existing.embeddingSpaceId !== embeddingSpaceId ) { throw new SemanticCompatibilityError(); } return existing.store;
}
Теперь для одного пути существует один mutable VectorStore на lifecycle controller.
Смена API key создаёт новый provider и новый runtime, но получает тот же объект store.
Смена model, provider, endpoint или dimensions проверяется на совместимость. Второй writer не создаётся.
Плюс появился epoch guard. Старый pending runtime может спокойно завершить уже начатую локальную операцию, но после изменения настроек не имеет права снова стать активным slot.

Это одна из тех ошибок, которые не видно в обычном happy path. Нажал кнопку, дождался результата, всё работает. Нужна именно неудобная последовательность событий и задержанный provider.
После исправления двух concurrency-блокеров набор вырос с 453 до 477 тестов. Перед релизом я добавил проверки настроек, модального окна поиска и дополнительные интеграционные сценарии, поэтому финальный прогон уже содержал 499 тестов.
Хорошее напоминание, что число зелёных тестов само по себе ничего не гарантирует. Важно не только их количество, но и то, какие неудобные вопросы они задают коду.

Здесь есть важная оговорка.
Индекс хранится локально внутри каталога плагина. Векторы и metadata плагин сам никуда не загружает.
Но embeddings должны откуда-то взяться.
Если выбран OpenRouter или другой удалённый endpoint, chunks заметок отправляются этому провайдеру при индексации, а поисковые запросы отправляются при поиске.
Если выбрана Ollama и она доступна локально, generation embeddings может оставаться на устройстве.
То есть «локальный векторный индекс» не означает автоматически «весь pipeline локальный». Это зависит от выбранного provider.
Semantic-функции по умолчанию выключены. Пользователь сам включает их, выбирает endpoint и запускает индексацию вручную.
В ошибки не попадают:
текст заметки;
поисковый запрос;
API key;
Authorization header;
response body провайдера;
значения embeddings.
Это выглядит параноидально ровно до первого лога, который кто-нибудь прикладывает к публичному issue.
В версии 1.5.0 появились пять команд:
Semantic search
Update the Vault semantic index
Update the current note in the semantic index
Clear the semantic index
Rebuild the semantic index
Первый запуск строит индекс вручную. Следующие обновления пересчитывают только новые и изменённые chunks.
Индекс сохраняется между перезапусками Obsidian.
При смене только API key перестройка не нужна.
При смене embedding model, provider, endpoint или dimensions плагин сообщает о несовместимости и предлагает явный rebuild.
Clear заменяет индекс пустым совместимым состоянием.
Rebuild физически пересоздаёт semantic-артефакты и заново индексирует Vault.
Исходные Markdown-файлы эти операции не меняют.
Я прогнал smoke-тесты в отдельном тестовом хранилище: полная индексация, повторный no-op, изменение текущей заметки, поиск, переход к нужному фрагменту, clear, rebuild и смена настроек. После этого выпустил 1.5.0 и отправил обновление в Community Plugins Obsidian.

Другие мои проекты и эксперименты с AI, автоматизацией и Obsidian можно посмотреть в моём профиле GitHub. Идеи и найденные проблемы по Vault Audit AI можно оставлять прямо в репозитории плагина.
Самая приятная часть получилась довольно будничной. Пишешь запрос не теми словами, которыми когда-то оформил заметку, и всё равно находишь её.
Именно ради этой будничности под капотом появились chunker, бинарный store, поколения snapshot, atomic persistence, read/write barrier и registry единственного writer.
Пользователь нажимает одну кнопку. Архитектура в это время старается не напоминать о своём существовании. Значит, всё примерно получилось.
Сейчас индексация ручная.
Плагин пока не слушает события создания, изменения, удаления и переименования файлов. Нет debounce, фонового scheduler, progress с отменой и автоматического index-on-startup.
Поиск выполняется линейным проходом. Для небольших и средних личных хранилищ этого хватает, но для действительно больших коллекций понадобится ANN-индекс.
Ещё нет отдельной команды «похожие заметки» для текущего файла. Техническая база уже есть, но интерфейс и правила исключения самой заметки требуют отдельного этапа.
И наконец, semantic index пока не связан напрямую с глубоким LLM-аудитом. Сейчас это две соседние системы:
LLM-аудит строит сводки, кластеры и рекомендации;
semantic index быстро ищет близкие фрагменты.
В будущем их можно соединить. Например, находить кандидатов на связи через embeddings, а LLM отдавать только небольшой shortlist для содержательной проверки. Это дешевле и точнее, чем каждый раз отправлять модели всё хранилище.
Семантический поиск начинался как ещё одна кнопка в плагине.
На деле он потребовал построить маленькую локальную поисковую систему: детерминированно резать Markdown, поддерживать несколько embedding-провайдеров, хранить векторы, обновлять только изменённые chunks, восстанавливаться после незавершённой записи и не путаться в собственных runtime во время смены настроек.
Но результат меняет сам способ работы с заметками.
Раньше для поиска нужно было помнить, как именно я сформулировал мысль. Теперь достаточно помнить, о чём она была.
Для базы знаний это довольно важная разница. Память редко возвращает точную строку. Обычно она возвращает смутное «я где-то об этом писал».
Теперь этого наконец хватает.