Технологии

sqlite-vec: векторный поиск прямо в SQLite, с тихим откатом

Ставим векторный поиск как необязательное расширение поверх обычной базы: команды, схема таблицы, запрос ближайших соседей и логика отката, если расширения нет.

Векторный поиск обычно подают как решение, вокруг которого строят систему. Здесь противоположный подход: векторный слой ставится поверх работающей базы как расширение, которое можно не ставить. Если его нет — поиск остаётся полнотекстовым, приложение не падает и даже не жалуется. Такой режим сложнее написать, чем «векторы обязательны», но он единственный, который переживает установку на чужой машине.

Этот гайд показывает обе половины: как включить и как корректно не включить.

#Что понадобится

  • SQLite с поддержкой загружаемых расширений. Сборка Python на Windows и в большинстве дистрибутивов Linux это умеет; отдельные урезанные сборки — нет, проверяется одной строкой (см. ниже).
  • Пакет sqlite-vec. Ставится в то же окружение, где живёт приложение.
  • Модель-эмбеддер. В примерах — nomic-embed-text через Ollama. Ollama должна быть запущена и доступна по сети из приложения; как её подключить, описано в гайде подключаем Ollama к Persona.
  • Понимание, что векторы придётся посчитать для всего накопленного. Это отдельный проход, он не бесплатный.

Требования к железу скромные: эмбеддер небольшой и считается на процессоре. Узкое место — не вычисления, а первая загрузка модели в память после простоя.

#Шаг 1. Проверить, что расширения вообще загружаются

python -c "import sqlite3; c=sqlite3.connect(':memory:'); print(hasattr(c,'enable_load_extension'))"

Если напечатано False, дальше идти некуда: ваша сборка Python собрана без поддержки загружаемых расширений, и векторный слой на ней не заработает. Это не редкость и не ошибка установки — просто такая сборка. Полнотекстовый поиск при этом работает: он встроен в SQLite, а не подгружается.

#Шаг 2. Установить пакет

pip install sqlite-vec

Пакет привозит скомпилированную библиотеку под вашу платформу. Проверка, что путь к ней резолвится:

python -c "import sqlite_vec; print(sqlite_vec.loadable_path())"

Должен напечататься путь к файлу расширения. Именно этот вызов Persona использует в app/storage/db.py: если импорт или резолв пути падают, флаг доступности остаётся выключенным, событие пишется в лог один раз, и весь векторный путь отключается.

#Шаг 3. Загрузить расширение в соединение

Расширение загружается для каждого соединения, а не один раз на базу. Порядок жёсткий: разрешить загрузку, загрузить, запретить обратно.

import sqlite3, sqlite_vec

conn = sqlite3.connect("persona.db")
conn.enable_load_extension(True)
conn.load_extension(sqlite_vec.loadable_path())
conn.enable_load_extension(False)
print(conn.execute("select vec_version()").fetchone())

Если последняя строка напечатала версию — расширение работает. Если она упала с no such function: vec_version, значит загрузка молча не состоялась.

#Шаг 4. Создать таблицу под векторы

CREATE VIRTUAL TABLE IF NOT EXISTS chat_message_vec USING vec0(
    message_id INTEGER PRIMARY KEY,
    embedding FLOAT[768]
);

CREATE TABLE IF NOT EXISTS vec_message_meta (
    message_id INTEGER PRIMARY KEY,
    user_id INTEGER NOT NULL,
    session_id INTEGER,
    created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_vec_meta_user ON vec_message_meta(user_id);

Два решения в этой схеме требуют объяснения.

Размерность зашита в объявление. FLOAT[768] — это nomic-embed-text. Модель с другой размерностью, например bge-m3 с 1024, потребует новой таблицы и полной переиндексации. Это не параметр, который меняется настройкой; выбирайте эмбеддер до того, как накопите индекс — критерии есть в обзоре embed-моделей.

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

Если расширение не установлено, первый CREATE VIRTUAL TABLE ... USING vec0 падает с no such module: vec0. В Persona раннер миграций проглатывает именно эту ошибку и идёт дальше — база остаётся консистентной, просто без векторной таблицы.

#Шаг 5. Записать вектор

Вектор упаковывается в бинарную строку по числу с плавающей точкой на измерение:

import struct

def pack(vec):
    return struct.pack(f"{len(vec)}f", *vec)

conn.execute(
    "INSERT OR REPLACE INTO chat_message_vec(message_id, embedding) VALUES(?, ?)",
    (message_id, pack(vector)),
)
conn.execute(
    "INSERT OR REPLACE INTO vec_message_meta(message_id, user_id, session_id, created_at)"
    " VALUES(?,?,?,?)",
    (message_id, user_id, session_id, created_at),
)
conn.commit()

INSERT OR REPLACE делает операцию идемпотентной: повторная индексация той же записи не плодит дублей. Строка в vec_message_meta заодно служит маркером «уже проиндексировано» — по её отсутствию находят необработанные записи.

#Шаг 6. Запрос ближайших соседей

SELECT v.message_id, m.content, m.created_at
  FROM chat_message_vec v
  JOIN vec_message_meta meta ON meta.message_id = v.message_id
  JOIN chat_message m ON m.id = v.message_id
 WHERE v.embedding MATCH ? AND k = ? AND meta.user_id = ?
 ORDER BY v.distance

Три особенности синтаксиса, на которых спотыкаются:

  • MATCH принимает упакованный вектор запроса, а не текст;
  • k = ? — это не LIMIT, а параметр самого KNN-запроса, число соседей;
  • сортировка идёт по v.distance, где меньше значит ближе.

Вектор запроса считается той же моделью, что и документы. Для семейства nomic роль текста задаётся префиксом: search_document: для хранимого фрагмента, search_query: для запроса. Ollama их не добавляет — это делает вызывающий код. Разные префиксы при индексации и при поиске тихо ухудшают выдачу, не давая никакой ошибки.

#Шаг 7. Индексация накопленного

Новые записи индексируются по мере поступления, а всё, что было до включения векторов, требует отдельного прохода. Разумная реализация — батчами, в простое, идемпотентно: выбрать записи, у которых нет строки в таблице метаданных, посчитать вектор, записать. В Persona это функция backfill_index в app/memory_vec.py, ограниченная сверху размером батча.

Пока бэкфилл не прошёл, векторный поиск видит только часть архива — и это выглядит как «поиск стал хуже», хотя ничего не сломалось.

#Логика тихого отката

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

Что отказало Что происходит
Пакет sqlite-vec не установлен Флаг доступности выключен, векторный путь не вызывается
Ollama не отвечает или нет модели Эмбеддинг возвращает пусто, поиск идёт по ключевым словам
Эмбеддер грузится дольше потолка ожидания Запрос обрывается по таймауту, дальше — ключевые слова
Индекс пуст или неполон Векторных кандидатов нет, работает полнотекстовая ветка
Размерность вектора не совпала со схемой Ошибка KNN перехватывается, список кандидатов пуст

У тихого отката есть обратная сторона, и её надо назвать: пользователь не узнаёт, что векторный режим не работает. Он видит просто «поиск находит хуже». Поэтому наличие расширения и факт последней индексации стоит выводить в интерфейсе — иначе конфигурация деградирует незаметно.

#Проверка результата

Три проверки, которые надо пройти подряд.

  1. Расширение загружено. select vec_version() возвращает версию.
  2. Индекс наполнен. SELECT COUNT(*) FROM vec_message_meta; — число должно быть сопоставимо с числом записей, которые вы собирались проиндексировать. Если оно на порядок меньше, бэкфилл не доработал.
  3. Поиск возвращает осмысленное. Возьмите запись, которую точно помните, сформулируйте вопрос к ней другими словами и убедитесь, что она попала в первые несколько соседей. Если попадает только при дословной формулировке — векторы фактически не работают, и вы смотрите на результат полнотекстовой ветки.

Третья проверка — единственная содержательная. Первые две могут пройти при полностью бесполезном индексе.

#Что может пойти не так

no such module: vec0. Расширение не загружено в это конкретное соединение. Частая причина — соединение открыто в другом месте кода, где загрузка не выполняется.

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

Поиск находит, но не то. Скорее всего, префиксы роли применены непоследовательно, либо часть индекса построена другой моделью. Лечится полной переиндексацией, частичной — нет.

Первый запрос после простоя занимает десятки секунд. Это загрузка модели, а не поиск. Лечится удержанием модели в памяти между запросами и разумным потолком ожидания на стороне приложения.

Индекс есть, а память всё равно молчит. Проверьте, что векторный режим вообще включён: он может быть выключен настройкой, и тогда расширение стоит зря. В Persona режим поиска задаётся на странице настроек памяти, а для не-владельцев инстанса векторный режим недоступен вовсе — эмбеддинги считаются по глобальной конфигурации, и чужой текст не должен уходить на железо владельца.

#Частые вопросы

#Обязательно ли ставить sqlite-vec, чтобы память работала?

Нет. Полнотекстовый индекс — самостоятельный, полноценный режим поиска, и в Persona он работает по умолчанию. Векторы добавляют попадания там, где формулировка запроса не совпадает с записью, и не заменяют ключевые слова там, где в вопросе есть имя или код.

#Чем sqlite-vec отличается от отдельной векторной базы?

Отсутствием отдельного процесса и приближённых индексов. Поиск точный, то есть перебирает всех кандидатов — на персональных объёмах это быстро, на десятках миллионов записей уже нет; чем приближённый поиск отличается от точного, разобрано в справке про KNN и ANN. Подробное сравнение классов решений — в обзоре векторных баз данных.

#Что делать при смене модели-эмбеддера?

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

#Можно ли фильтровать выдачу по пользователю прямо в KNN?

В приведённой схеме — нет, фильтр применяется джойном после KNN. Это значит, что при большом числе пользователей часть найденных соседей отсеется, и до потребителя дойдёт меньше кандидатов, чем запрошено. Компенсируется запросом с запасом по k.

#Как это связано с остальными слоями памяти?

Векторный поиск — только один источник кандидатов. Как он объединяется с полнотекстовым и что происходит дальше, разобрано в разборе гибридного поиска, а общая картина слоёв — в статье о том, как работает память у ИИ.

#Вывод

Ставьте sqlite-vec, если у вас уже работает полнотекстовый поиск и вы упираетесь именно в формулировки — когда нужное находится только при дословном совпадении слов. Не ставьте, если поиск пока не настроен вовсе: векторы не чинят пустое хранилище и не заменяют дисциплину записи. И в любом случае проектируйте векторный слой так, чтобы его отсутствие было рабочим режимом, а не поломкой, — иначе установка на новой машине превратится в отладку расширений вместо работы.

Хотите ИИ, который помнит вас?

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

Создать аккаунт