Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи?

Написать
Войти
Дайджесты
Иллюстрация к статье о семантическом поиске в Obsidian на TypeScript

Семантический поиск в Obsidian без Docker и тяжелых БД: бинарное хранилище Float32Array, инкрементальность и решение race conditions

Разработчик плагина Vault Audit AI для Obsidian детально разобрал архитектуру локального семантического поиска без применения векторных БД. Система объединяет плоское бинарное хранилище Float32Array, инкрементальный чанкинг Markdown и барьеры read/write для устранения состояния гонок.

Семантический поиск в Obsidian без Docker и тяжелых БД: бинарное хранилище Float32Array, инкрементальность и решение race conditions

Приложения для ведения личных заметок и баз знаний, такие как Obsidian, опираются на принцип локального хранения данных в формате Markdown-файлов. Однако стандартный текстовый поиск по ключевым словам бессилен, когда пользователь помнит концептуальный смысл мысли, но не помнит точных формулировок.

Внедрение векторного семантического поиска в локальные клиенты традиционно упирается в необходимость разворачивания тяжелых серверных векторных СУБД (Chroma, Qdrant, Milvus) или виртуализации Docker, что неприемлемо для обычных пользователей настольных приложений.

Автор плагина Vault Audit AI для Obsidian поделился подробным техническим разбором создания локального векторного хранилища на чистом TypeScript. Проект обходится без внешних сервисов, сохраняя эмбеддинги в плоский бинарный файл с помощью массивов Float32Array, обеспечивает инкрементальную индексацию и полностью исключает состояния гонки (Race Conditions) при параллельных операциях.

Проблема семантического поиска в локальных заметках Obsidian

Векторный поиск основан на преобразовании фрагментов текста (чанок) в многомерные числовые векторы (эмбеддинги) с помощью нейросетей (например, через локальный Ollama или облачный API OpenRouter). Похожесть смыслов между двумя фрагментами определяется вычислением косинусного расстояния (Cosine Similarity) между их векторами.

Основные вызовы при реализации векторного поиска в Electron/Obsidian:

  • Раздувание объема памяти: хранение миллионов плавающих чисел в формате JSON приводит к гигантскому расходу RAM и задержкам на парсинг.
  • Производительность поиска: сканирование тысяч векторов на клиенте должно занимать считанные миллисекунды.
  • Синхронизация фоновой индексации: при редактировании файла пользователем индексатор должен оперативно обновить векторы без блокировки интерфейса и разрыва целостности данных.

Архитектура Vault Audit AI: нативный TypeScript без внешних векторных БД

Плагин Vault Audit AI полностью отказался от использования SQLite с расширением sqlite-vss или внешних векторных баз данных в пользу легковесного класса LocalVectorStore, написанного на TypeScript.

Архитектура плагина включает четыре изолированных модуля:

  1. Markdown Chunking Engine: сплиттер заметок, учитывающий структуры заголовков (# H1, ## H2).
  2. Embedding Provider: универсальный клиент для вызова API Ollama (nomic-embed-text) или OpenRouter.
  3. LocalVectorStore: бинарный накопитель векторов на ArrayBuffer / Float32Array.
  4. Store Registry & Barrier Manager: координатор параллельного доступа и разрешитель состояний гонки.

Структура хранилища LocalVectorStore на бинарных массивах Float32Array

Вместо сохранения векторов в виде JSON-массивов [0.0123, -0.0456, ...], класс LocalVectorStore сериализует данные в непрерывный бинарный буфер памяти.

Если размерность вектора составляет 1536 измерений (стандарт для моделей OpenAI / OpenRouter), один вектор занимает ровно 6144 байта (1536 элементов × 4 байта на число типа Float32Array).

Структура бинарного файла на диске:

// Инициализация бинарного вектора в TypeScript
const vectorDimension = 1536;
const buffer = new ArrayBuffer(vectorCount * vectorDimension * 4);
const floatView = new Float32Array(buffer);

// Вычисление косинусного сходства за один проход в цикле
function cosineSimilarity(a: Float32Array, b: Float32Array, dim: number): number {
    let dotProduct = 0.0;
    let normA = 0.0;
    let normB = 0.0;
    for (let i = 0; i < dim; i++) {
        dotProduct += a[i] * b[i];
        normA += a[i] * a[i];
        normB += b[i] * b[i];
    }
    return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

Такой подход позволяет выполнять косинусное сравнение 50 000 векторов менее чем за 15 миллисекунд прямо в основном потоке V8, не создавая нагрузки на сборщик мусора.

Инкрементальная индексация Markdown-заметок с сохранением иерархии заголовков

Сплиттер заметок не режет текст на слепые фиксированные куски по 500 символов. Вместо этого учитывается иерархическая структура Markdown.

Каждый чанк содержит:

  • Иерархический путь заголовков (например, Заметка > Раздел 2 > Подраздел А).
  • Метаданные файла (путь, дата изменения mtime, хэш содержимого SHA-256).
  • Текстовый фрагмент с сохранением контекста.

При изменении заметки плагин сравнивает текущий хэш SHA-256 файла с записанным в реестре. Если файл не менялся, индексация пропускается. Если изменён один раздел, удаляются и пересчитываются только векторы этого раздела, избегая повторных платных вызовов API.

Предотвращение состояния гонок через синхронизационный барьер read/write

Критической технической сложностью при работе с файловым векторизатором стали состояния гонки (Race Conditions). Если пользователь быстро меняет настройки плагина (например, переключает модель эмбеддингов с 768 на 1536 измерений) во время активного фонового процесса индексации хранилища, возможна порция данных с несогласованной размерностью векторов.

Для решения этой проблемы был введен класс ReadWriteBarrier:

export class ReadWriteBarrier {
    private isWriting = false;
    private writeQueue: (() => void)[] = [];

    async acquireWrite(): Promise<void> {
        if (this.isWriting) {
            await new Promise<void>((resolve) => this.writeQueue.push(resolve));
        }
        this.isWriting = true;
    }

    releaseWrite(): void {
        this.isWriting = false;
        const next = this.writeQueue.shift();
        if (next) next();
    }
}

Все операции записи и сброса кэша блокируются барьером acquireWrite(). При смене настроек плагин мгновенно отменяет текущие фоновые таски (через AbortController), дожидается освобождения барьера, очищает реестр и перезапускает индексацию с новой размерностью векторов.

Пошаговый алгоритм создания клиентского векторного поиска для приложения

Для реализации нативного клиентского векторного поиска в Electron или веб-приложении рекомендуется соблюдать следующий алгоритм:

  1. Разработайте контекстный сплиттер: разбивайте тексты по смысловым абзацам и заголовкам с сохранением метаданных родительских секций.
  2. Используйте Float32Array для хранения векторов: откажитесь от JSON в пользу плоских бинарных файлов для хранения числовых массивов.
  3. Реализуйте хэширование чанков: сохраняйте SHA-256 от каждого чанка для пропуска неизмененных файлов при инкрементальных проверках.
  4. Внедрите барьер синхронизации (ReadWriteBarrier): используйте примитивы синхронизации для безопасной отмены тасков и предотвращения гонок при смене настроек.
  5. Оптимизируйте вычисление Similarity: используйте линейные циклы по бинарному буферу без аллокаций промежуточных объектов в памяти.

Резюме и инженерные выводы: потенциал локальных ИИ-инструментов

Кейс Vault Audit AI доказывает, что производительный семантический поиск не требует громоздкой инфраструктуры векторных СУБД или контейнеризации. Использование компактных бинарных структур Float32Array в сочетании с грамотной инкрементальной индексацией на TypeScript позволяет создавать быстрые, приватные и полностью автономные ИИ-инструменты прямо на устройстве пользователя.