ADR 0079 — Таблица chunks: вектор и tsvector в одной строке
- Статус: принят
- Дата: 2026-08-15
- Предшествующий контекст: ADR 0075
(embeddings в контракте
LLMProvider, фундамент без потребления —embed?доказан только против Ollama), ADR 0072 (CHECK в базе, чтобы связать взаимоисключающую пару столбцов, тот же шаблон, что используется здесь дляsession_id/source_path), ADR 0078 (почему вкладка по-прежнему подписана «Chat», а не «Chat RAG», — эта миграция НЕ меняет эту метку)
Контекст
Chat RAG, который объявляет handoff дизайна (design_handoff_brabo/designs/Brabo Chat.dc.html), нуждается в месте для хранения проиндексированных фрагментов с вектором
embedding — сегодня для этого не существует НИ ОДНОЙ таблицы. ADR 0075 оставил
контракт готовым (LLMProvider.embed?, capability доказана в Ollama с
nomic-embed-text, векторы размерностью 768), но ничто ещё не пишет и не читает
индекс: без таблицы контракт — дверь без комнаты с другой стороны.
Два вопроса требовали решения до генерации миграции, и оба структурные, а не о реализации:
- Живут ли вектор и лексический поиск в одной таблице или в двух? Волна 4 (вне
охвата этой миграции) построит ГИБРИДНЫЙ поиск — семантический
(
pgvector, косинусное сходство) и лексический (tsvector, полнотекстовый поиск Postgres) вместе. Если бы две информации жили в отдельных таблицах, каждому гибридному поиску понадобился бы JOIN поchunk_id, и две таблицы могли бы расходиться (фрагмент с вектором, но без лексической записи, или наоборот) без какого-либо механизма базы, препятствующего этому. - Какие области покрывает индекс? Handoff намекает на «поиск в проекте», что
слишком расплывчато, чтобы спроектировать столбец. Исследование нашло РОВНО
три источника текста, которые продукт уже производит и откуда они пришли — знает:
файлы
docs/, ADR (которые тоже файлы, но с собственной идентичностью — ADR можно процитировать по номеру, общий doc нельзя) и сессии (в event log текста с избытком). Исходный код и Pull Requests остались вне НАМЕРЕННО: оба меняются с каждымpush, и их индексация без watcher переиндексации заставила бы индекс лгать о покрытии — тот же класс ошибки, от которого ADR 0042 уже отказывается для capability модели («объявлять без доказательства»). Три области с ЧЕСТНЫМ покрытием стоят больше, чем пять с выдуманным числом.
Третий пункт был не продуктовым решением, а технической находкой с
последствием для деплоя: docker/postgres/init.sql:2 выполняет CREATE EXTENSION IF NOT EXISTS vector, но этот файл выполняется только при ПЕРВОЙ инициализации
тома Postgres. Окружение со старым томом — включая, возможно,
production — может не иметь установленного расширения, а его создание требует привилегии, которой
у роли приложения может не быть.
Решение
Одна таблица, chunks, с embedding vector(768) и search_vector tsvector как родственными столбцами одной строки. search_vector — GENERATED ALWAYS AS (to_tsvector('portuguese', content)) STORED: никогда не записывается
приложением, всегда согласован с content по построению Postgres и готов
в той же транзакции INSERT (не зависит от ответа какого-либо LLM-провайдера).
embedding — NULLABLE: конвейер индексации (Волна 4) ещё не
существует, а заставить CHUNK (вырезку текста) ждать ВЕКТОР смешало бы два
сбоя разной природы — разбор документа против сетевого вызова
провайдера — в одну атомарную запись.
Индекс HNSW по embedding (vector_cosine_ops), а не IVFFlat. IVFFlat
нуждается в уже загруженных строках, чтобы обучить списки (lists), — построенный
на пустой таблице, а это ровно состояние этой таблицы при рождении (ещё без
конвейера индексации), индекс остаётся плохим, пока кто-нибудь не перестроит его
вручную после наполнения. HNSW строит граф инкрементально,
вставка за вставкой, без этапа обучения — индекс хорош с
первой строки. vector_cosine_ops, потому что это метрика, которую текстовые embeddings
обычно ожидают (ранжирование по сходству не должно меняться с
величиной вектора).
Индекс GIN по search_vector — лексическая половина, готовая для гибридного поиска
Волны 4 через @@/ts_rank, без необходимости что-либо вычислять во время
запроса.
Три области (RN-219) — это pgEnum — docs | adr | session, — а
session_id/source_path взаимно исключают друг друга через CHECK, а не по
соглашению приложения, тот же шаблон, что ADR 0072 использовал для
workspace_mode/workspace_path: scope = 'session' требует заполненного session_id
и отклоняет source_path; docs/adr требуют source_path (относительный путь
исходного файла) и отклоняют session_id. Защёлка стоит в базе, потому что
писать эту таблицу будет конвейер (Волна 4), который не обязательно
каждый раз проходит через один и тот же сценарий использования, — пакетный скрипт
переиндексации — очевидный кандидат на обход проверки только на уровне приложения.
Миграция сама несёт CREATE EXTENSION IF NOT EXISTS vector, вместо
предположения, что docker/postgres/init.sql уже выполнился. IF NOT EXISTS
идемпотентен — локальное окружение (где расширение уже установлено, подтверждено
SELECT * FROM pg_extension WHERE extname='vector' до написания этого
ADR) и новое окружение проходят одну и ту же строку без видимой разницы в
поведении.
Эта миграция рождается в ветке breaking/, а не feature/. Создание
расширения требует, чтобы у роли приложения был CREATEDB (или чтобы расширение
было помечено «trusted» DBA). Локально роль — суперпользователь
(подтверждено SELECT rolsuper FROM pg_roles WHERE rolname=current_user),
но ничто не гарантирует этого в production — управляемые Postgres
часто не дают приложению суперпользователя. Если миграция там провалится, это
действие ОПЕРАТОРА перед деплоем (выполнить CREATE EXTENSION vector; один раз,
как суперпользователь), а не ошибка продукта, — ровно критерий, который
CLAUDE.md уже использует, чтобы решить breaking/ против bugfix//feature/: «изменение,
требующее действия оператора перед деплоем, рождается в breaking/, даже когда
содержимое — исправление».
Последствия
Эта миграция поставляет только ФУНДАМЕНТ: таблицу, индексы,
репозиторий базовой записи/чтения (ChunkRepository). Она не пишет
ни одной строки — нет конвейера индексации, нет гибридного поиска, нет UI.
Вкладка остаётся «Chat» (ADR 0078); ничего не меняется в метке, пока Волна 4 не поставит
то, что обещает имя «Chat RAG».
Переиндексация docs/adr — ответственность того, кто напишет конвейер
(Волна 4), а не этой миграции: у таблицы нет столбца хеша/версии
исходного файла для обнаружения изменений, потому что это решение принадлежит тому, кто будет
фактически выполнять переиндексацию и знает, какая стратегия (хеш содержимого,
mtime, версия commit) имеет смысл для ещё не существующего watcher.
Исходный код и PR остаются вне индекса. Если однажды они войдут, решение
должно прийти вместе с механизмом переиндексации по push — это не тривиальное
расширение enum chunk_scope, потому что гарантия честного покрытия этой таблицы
зависит от того, что у каждой области есть ясная история о том, «когда этот chunk
устаревает».
В production эта миграция может остановиться на CREATE EXTENSION, если у роли
приложения нет привилегии, — намеренное поведение (падать громко и
рано), вместо того чтобы таблица рождалась без доступного типа vector и падала
более запутанным образом позже, при первой попытке INSERT.