Векторный поиск обычно подают как решение, вокруг которого строят систему. Здесь противоположный подход: векторный слой ставится поверх работающей базы как расширение, которое можно не ставить. Если его нет — поиск остаётся полнотекстовым, приложение не падает и даже не жалуется. Такой режим сложнее написать, чем «векторы обязательны», но он единственный, который переживает установку на чужой машине.
Этот гайд показывает обе половины: как включить и как корректно не включить.
#Что понадобится
- 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 перехватывается, список кандидатов пуст |
У тихого отката есть обратная сторона, и её надо назвать: пользователь не узнаёт, что векторный режим не работает. Он видит просто «поиск находит хуже». Поэтому наличие расширения и факт последней индексации стоит выводить в интерфейсе — иначе конфигурация деградирует незаметно.
#Проверка результата
Три проверки, которые надо пройти подряд.
- Расширение загружено.
select vec_version()возвращает версию. - Индекс наполнен.
SELECT COUNT(*) FROM vec_message_meta;— число должно быть сопоставимо с числом записей, которые вы собирались проиндексировать. Если оно на порядок меньше, бэкфилл не доработал. - Поиск возвращает осмысленное. Возьмите запись, которую точно помните, сформулируйте вопрос к ней другими словами и убедитесь, что она попала в первые несколько соседей. Если попадает только при дословной формулировке — векторы фактически не работают, и вы смотрите на результат полнотекстовой ветки.
Третья проверка — единственная содержательная. Первые две могут пройти при полностью бесполезном индексе.
#Что может пойти не так
no such module: vec0. Расширение не загружено в это конкретное соединение. Частая причина — соединение открыто в другом месте кода, где загрузка не выполняется.
Векторы записались, а поиск ничего не находит. Проверьте размерность: если модель сменили после создания таблицы, длина не совпадёт, и KNN вернёт ошибку, которую вызывающий код, скорее всего, проглотит.
Поиск находит, но не то. Скорее всего, префиксы роли применены непоследовательно, либо часть индекса построена другой моделью. Лечится полной переиндексацией, частичной — нет.
Первый запрос после простоя занимает десятки секунд. Это загрузка модели, а не поиск. Лечится удержанием модели в памяти между запросами и разумным потолком ожидания на стороне приложения.
Индекс есть, а память всё равно молчит. Проверьте, что векторный режим вообще включён: он может быть выключен настройкой, и тогда расширение стоит зря. В Persona режим поиска задаётся на странице настроек памяти, а для не-владельцев инстанса векторный режим недоступен вовсе — эмбеддинги считаются по глобальной конфигурации, и чужой текст не должен уходить на железо владельца.
#Частые вопросы
#Обязательно ли ставить sqlite-vec, чтобы память работала?
Нет. Полнотекстовый индекс — самостоятельный, полноценный режим поиска, и в Persona он работает по умолчанию. Векторы добавляют попадания там, где формулировка запроса не совпадает с записью, и не заменяют ключевые слова там, где в вопросе есть имя или код.
#Чем sqlite-vec отличается от отдельной векторной базы?
Отсутствием отдельного процесса и приближённых индексов. Поиск точный, то есть перебирает всех кандидатов — на персональных объёмах это быстро, на десятках миллионов записей уже нет; чем приближённый поиск отличается от точного, разобрано в справке про KNN и ANN. Подробное сравнение классов решений — в обзоре векторных баз данных.
#Что делать при смене модели-эмбеддера?
Создать новую таблицу с правильной размерностью и переиндексировать всё заново. Смешивать в одном индексе векторы от разных моделей нельзя: они лежат в разных системах координат, и поиск начнёт возвращать случайные записи без единой ошибки в логах.
#Можно ли фильтровать выдачу по пользователю прямо в KNN?
В приведённой схеме — нет, фильтр применяется джойном после KNN. Это значит, что при большом числе пользователей часть найденных соседей отсеется, и до потребителя дойдёт меньше кандидатов, чем запрошено. Компенсируется запросом с запасом по k.
#Как это связано с остальными слоями памяти?
Векторный поиск — только один источник кандидатов. Как он объединяется с полнотекстовым и что происходит дальше, разобрано в разборе гибридного поиска, а общая картина слоёв — в статье о том, как работает память у ИИ.
#Вывод
Ставьте sqlite-vec, если у вас уже работает полнотекстовый поиск и вы упираетесь именно в формулировки — когда нужное находится только при дословном совпадении слов. Не ставьте, если поиск пока не настроен вовсе: векторы не чинят пустое хранилище и не заменяют дисциплину записи. И в любом случае проектируйте векторный слой так, чтобы его отсутствие было рабочим режимом, а не поломкой, — иначе установка на новой машине превратится в отладку расширений вместо работы.