Документы и векторы в PostgreSQL
Теперь у библиотеки есть договор фрагментов и адаптер векторов. Перенесём эти объекты в PostgreSQL, чтобы сохранять их между запросами и выбирать источники SQL-запросом. Расширение pgvector добавляет тип вектора и операции расстояния; само по себе оно не скачивает документы, не вызывает модель и не проверяет смысл ответов.
Состояние lesson-04 содержит schema.sql, db.py и файл зависимостей Python. Учебная среда рассчитана на PostgreSQL 18, pgvector 0.8.7 и psycopg 3.3.6. Установка, создание базы и применение схемы автором не выполнялись. Используйте отдельную лабораторную базу: этот файл не является миграцией рабочего сайта.
Документ и его сохранённое состояние
Таблица documents хранит устойчивую личность источника: doc_id, адрес, заголовок и публичность. Поля active_revision и active_space указывают, какое состояние участвует в поиске сейчас. Сначала они могут быть пустыми: документ уже известен, но ни одна подготовленная ревизия ещё не активирована.
Таблица revisions описывает конкретную комбинацию документа, отпечатка и пространства векторов. Если поменяется текст или модель, это будет новая комбинация. Старый набор не нужно удалять до завершения нового импорта: он может продолжать обслуживать читателей, пока готовятся новые фрагменты.
В chunks каждый фрагмент связан с ревизией составным внешним ключом. Номер part имеет смысл внутри этой комбинации. Например, нулевой фрагмент документа cache после изменения разбиения может содержать другой текст. Поэтому идентификатор одного номера без ревизии недостаточен для хранения истории.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS documents (
doc_id text PRIMARY KEY, url text NOT NULL UNIQUE, title text NOT NULL,
visibility text NOT NULL CHECK (visibility IN ('public','editor')),
active_revision text, active_space text, deleted boolean NOT NULL DEFAULT false
);
CREATE TABLE IF NOT EXISTS revisions (
doc_id text REFERENCES documents(doc_id), revision text, space text,
fragment_count integer NOT NULL CHECK(fragment_count>0),
PRIMARY KEY(doc_id,revision,space)
);
CREATE TABLE IF NOT EXISTS chunks (
doc_id text NOT NULL, revision text NOT NULL, space text NOT NULL,
part integer NOT NULL CHECK(part>=0), heading text NOT NULL, body text NOT NULL,
embedding vector(768) NOT NULL,
search_text tsvector GENERATED ALWAYS AS
(to_tsvector('russian'::regconfig,heading || ' ' || body)) STORED,
PRIMARY KEY(doc_id,revision,space,part),
FOREIGN KEY(doc_id,revision,space) REFERENCES revisions(doc_id,revision,space) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS chunks_text_gin ON chunks USING gin(search_text);
Первичный ключ предотвращает повторное сохранение одного фрагмента одной ревизии. Ограничение fragment_count > 0 исключает пустое подготовленное состояние. Однако сама схема не проверяет, что активный указатель документа всегда ссылается на полный набор фрагментов. Эту проверку и согласованное переключение реализуем в транзакции следующего урока.
Векторный тип как структурное ограничение
Поле embedding vector(768) фиксирует размерность на уровне базы. Попытка вставить другой размер должна завершиться ошибкой. Это вторая граница после проверки адаптера: данные могут прийти не только из текущего Python-кода, поэтому схема сохраняет собственные правила.
Но тип не защищает от смешения двух моделей с одинаковой размерностью. Для этого храним space и далее проверяем его в запросе. Одновременно нужны обе границы: размерность определяет форму числа, пространство определяет договор сравнения. Если убрать одно поле, ошибка может проявиться только как странная выдача.
Сериализовать массив будем стандартным JSON-представлением и передавать его параметром с приведением %s::vector. В текущем проекте не требуется отдельный NumPy-адаптер. Это выбранный путь для небольшой лаборатории, а не утверждение о максимальной скорости загрузки. Для большого корпуса стоит отдельно рассмотреть пакетные вставки и формат обмена.
Текстовый индекс рядом с векторами
Сгенерированное поле search_text превращает заголовок и тело фрагмента в tsvector с конфигурацией russian. Оно обновляется вместе с текстом. В седьмом уроке используем его для поиска по словам и объединения выдач. Пока достаточно понять, что числовое представление не заменяет исходный текст и другой механизм поиска.
Индекс GIN относится к полнотекстовому полю. Векторного индекса на этом этапе нет: сначала будем изучать точное сравнение и понятный порядок результатов. При маленьком корпусе это также помогает разбирать расстояние без влияния приближённого алгоритма. HNSW появится отдельным изменением, с собственными ограничениями.
Конфигурация русского языка подходит выбранным авторским заметкам, но не универсальна для любой библиотеки. Кодовые идентификаторы и англоязычные термины могут требовать другой обработки. Нельзя считать выбор russian измеренным оптимумом только потому, что основная проза написана по-русски.
Подключение и параметры запросов
Файл db.py получает строку подключения из DATABASE_URL. Она не зашивается в статью или архив. Для лаборатории оператор задаёт её в своей среде; при сопровождении нужно учитывать, что такая строка может содержать пароль. Не выводите её полностью в журналы при разборе ошибки.
import os
import psycopg
from psycopg.rows import dict_row
def connect():
return psycopg.connect(os.environ['DATABASE_URL'],row_factory=dict_row,
connect_timeout=5,options='-c statement_timeout=5000')
Таймаут подключения ограничивает ожидание установления связи. statement_timeout ограничивает отдельный SQL-оператор, но не весь пользовательский маршрут. Вызов модели, несколько SQL-запросов и сериализация ответа могут суммарно занимать больше времени. Общий бюджет запроса потребует дополнительного контроля вне этой функции.
dict_row возвращает строки с именованными полями. Это помогает читать выражения вроде row['title'], однако не снимает обязанности согласовать имена выбранных колонок. При объединении таблиц одно и то же имя может встретиться несколько раз; дальнейшие запросы явно перечислят нужные поля и их значения.
Где проходит граница полномочий
Команда создания расширения требует соответствующих прав и наличия extension на сервере. Пользователь для подготовки схемы и пользователь для чтения веб-запросов не должны автоматически считаться одним субъектом. В лаборатории это оставлено настройке оператора. Production-проекту нужны отдельные роли, миграции и минимальные разрешения.
В текущей схеме нет политики Row-Level Security и полноценного входа. Доступ позднее фильтруется приложением по доверенной локальной роли. Поэтому подключение к базе нельзя раздавать пользователям или считать произвольный SQL безопасным из-за наличия поля visibility. Это поле становится ограничением только там, где код действительно проверяет его.
Команда CREATE TABLE IF NOT EXISTS удобна для первоначальной подготовки, но не является механизмом миграций схемы. Если таблица уже существует с другой размерностью или без нужного ограничения, эта команда не приведёт её автоматически к описанному состоянию. Поэтому лаборатория начинает с отдельной пустой базы. При развитии проекта изменения структуры следует описывать последовательными миграциями и связывать с версиями приложения. Перед заменой модели с другой размерностью нужно отдельно решить судьбу прежних столбцов и индексов. Версия Python-пакета также не устанавливает серверное расширение pgvector: это разные компоненты среды, которые оператор готовит независимо. Такое различие помогает правильно найти причину ошибки подключения или неизвестного типа.
После самостоятельного применения схемы ожидаются три таблицы, текстовый индекс и установленное расширение vector. Это ожидаемое состояние, а не отчёт выполненной авторской проверки. Следующий шаг заполнит таблицы и покажет, как не допустить выдачи наполовину подготовленного документа при импорте.
Исходники и документация
Оглавление серии · Все состояния проекта. Для этого урока откройте папку lesson-04.
pgvector, транзакции Psycopg, Ollama Embed, структурированные ответы.
Примеры подготовлены как исходники. Их выполнение, замеры, тесты и публикация при написании не проводились. Описанные результаты ожидаемые.