Перейти к основному содержимому

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), но ничто ещё не пишет и не читает индекс: без таблицы контракт — дверь без комнаты с другой стороны.

Два вопроса требовали решения до генерации миграции, и оба структурные, а не о реализации:

  1. Живут ли вектор и лексический поиск в одной таблице или в двух? Волна 4 (вне охвата этой миграции) построит ГИБРИДНЫЙ поиск — семантический (pgvector, косинусное сходство) и лексический (tsvector, полнотекстовый поиск Postgres) вместе. Если бы две информации жили в отдельных таблицах, каждому гибридному поиску понадобился бы JOIN по chunk_id, и две таблицы могли бы расходиться (фрагмент с вектором, но без лексической записи, или наоборот) без какого-либо механизма базы, препятствующего этому.
  2. Какие области покрывает индекс? 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.