← Knowledge Store

Модель данных

knowledge-store · workzone

Ядро Knowledge Store — сущность знаний. Один логический контракт Entity, который производит Harvester, здесь персистится и расходится на три проекции в одной базе Postgres: реляционное тело (entities), фрагменты с векторами и полнотекстом (chunks) и граф связей (entity_edge). На entities нанизано всё остальное: права (entity_acl → entities), поиск (четыре примитива) и доводка графа (Curation Pass). Как контракт Harvester ложится в эти таблицы — на границе с производителем.

Сущность → три проекции в одной базе

Сущность — это не одна строка в одной базе и не три копии в трёх СУБД. Это одна запись с тремя проекциями внутри одного Postgres: тело фильтруют и джойнят точно, фрагменты ищут по смыслу, рёбра обходят по связям. Все три живут рядом, поэтому единый ACL pre-filter и слияние четырёх парадигм идут одним SQL-запросом, без кросс-базовой синхронизации.

entityодин логический контракт
Реляционное тело entities · структура · точные фильтры

Идентичность, тип, статус, метаданные, текст для выдачи — и source of truth для прав (entity_acl ссылается сюда). Всё, что фильтруют и джойнят точно.

Текст фрагментов chunks · вектор + лексика

Фрагменты сущности — дочерние строки (ключ — её id), каждый с двумя индексами: плотным эмбеддингом для поиска по смыслу и разреженным tsvector для поиска по словам. Опора текстового retrieval — оба сигнала рядом.

Граф entity_edge · связи · обход

Сущность — узел, отдельной node-таблицы нет; связи — рёбра между сущностями. Структурные приносит Harvester, cross-source — материализует Curation Pass.

один PostgreSQL · pgvector + recursive SQL (CTE) · одна транзакция, один ACL pre-filter
Порядок обновления проекций — тело → векторы → рёбра. Загрузка пишет проекции в одной транзакции, в одном порядке: сперва строка entities (на неё ссылаются остальные по FK), затем её chunks (переэмбеддинг только при смене content_hash), затем структурные entity_edge, чьи оба узла уже в базе. Cross-source рёбра и доводка — асинхронны, их ведёт Curation Pass.
Реляционное тело: entities

Центральная таблица модуля — каноничный снимок записи источника и source of truth, на который ссылаются все остальные проекции и права. Идентичность совпадает с ключом идемпотентности контракта Harvester: одна строка на запись источника, повторный прогон делает upsert.

1 Сущность Тело записи — снимок и source of truth.
entities узлы знаний
FK → sources FK → source_principal UNIQUE (source_id, source_type, source_entity_id)
id BigInteger PK
source_id BigInteger FK→sourcesIDX источник · CASCADE
source_type Text NOT NULL подвид · ticket · page · message
source_entity_id Text NOT NULL нативный id записи · часть ключа upsert
title Text NULL заголовок
body Text NULL текст для выдачи · режется на chunks
url Text NULL пермалинк в источнике
status Text NULLCHECK draft · final · archived · ставит шаг classify
author_principal_id BigInteger FK→principalNULLIDX автор · мост в мир личностей
source_created_at DateTime(tz) NULL создано в источнике
source_updated_at DateTime(tz) NULLIDX правка в источнике · вход staleness
is_deleted Boolean NOT NULLDEFAULT soft-delete · отдельная ось от status
deleted_at DateTime(tz) NULL когда исчезла из источника · ставит reconciliation
content_hash Text NULL детект изменений · экономит переэмбеддинг
trust_score Real NULL ранжирующий вес · ведёт Curation Pass: авторитет источника, сниженный давностью и спросом (decay)
meta JSONB NULL гибкие атрибуты по типу сущности
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger
Снимок, не лента · soft-delete отдельной осью
Храним последнее состояние записи: upsert перезаписывает строку, прошлых редакций сущности не накапливаем (changelog полей не ведём — только updated_at). Пропажа записи из источника — это is_deleted, отдельная ось от status: запись прячут из выдачи, но физически держат ради аудита и восстановления. Момент исчезновения проставляет полный скан reconciliation — в инкремент удаление не приходит.
Текстовая проекция: chunks

Эмбеддинги — не один вектор на запись, а по фрагментам: длинный документ режется на куски, каждый эмбеддится отдельно. Тот же текст фрагмента несёт и разреженный полнотекстовый индекс (tsvector + GIN) — лексический поиск по словам рядом с векторным по смыслу. Фрагмент — дочерняя строка сущности, своего ACL не имеет: он наследует права родителя через entity_id, поэтому отбор по правам идёт тем же SQL-запросом, что и поиск по близости.

Не партиционируем по источнику — глобальный ANN важнее. Соблазн нарезать chunks по source_id ради объёма обманчив: у каждой партиции свой HNSW-индекс, а семантический поиск идёт по всем источникам разом — запрос без предиката по ключу партиции вынужден сканировать индекс каждой партиции и сливать, а недобор под ACL множится по партициям (partition pruning не сработает, пока запрос не сужен до источника — редкий случай). Профит партиций — лишь дешёвое удаление источника (DROP PARTITION), но это уже закрывает ON DELETE CASCADE. Поэтому одно глобальное хранилище, не шардированное по источнику. Выделённый шардинг (v2) — только при доказанном объёме и сдвиге паттерна на source-scoped поиск: добавление, не переписывание.
2 Фрагмент Часть текста, её вектор и полнотекст.
chunks фрагменты и эмбеддинги
FK → entities UNIQUE (entity_id, ordinal) HNSW (embedding) · halfvec GIN (text_tsv) partial · WHERE NOT is_deleted
id BigInteger PK
entity_id BigInteger FK→entitiesIDX родитель · CASCADE · носитель ACL
is_deleted Boolean NOT NULLDEFAULT зеркало entities.is_deleted · условие частичных HNSW/GIN
ordinal Integer NOT NULL позиция фрагмента в сущности
text Text NOT NULL текст фрагмента
embedding halfvec(N) pgvectorHNSW N — размерность назначенной embedding-модели (встроенные — 1024) · half-precision · ANN по близости (cosine)
text_tsv tsvector GENERATEDGIN разреженный индекс из text · лексический поиск (ts_rank)
token_count Integer NULL длина фрагмента в токенах
content_hash Text NULL переэмбеддинг только при смене текста
embedding_model Text NULLIDX чем эмбеддили · ключ refresh
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · правка при переэмбеддинге
Фильтр поверх приближённого индекса · денормализация и частичные индексы
pgvector применяет фильтр после скана индекса, не внутри обхода графа: жёсткий ACL pre-filter может недобрать — top-K приближённого HNSW частью отсекается уже после выдачи, без ошибки. Лечится итеративными сканами (hnsw.iterative_scan, pgvector 0.8.0+) — но только для условий на самой chunks; предикат через JOIN на entities итеративный скан компенсировать не умеет. А таких предиката у нас два — is_deleted и сам ACL, — и решаются они врозь. is_deleted денормализуется: ось зеркалится на chunks, оба текстовых индекса (HNSW и GIN) строятся частичнымиWHERE NOT is_deleted, так удалённое вообще вне обхода. ACL так не убрать — он остаётся самым жёстким фильтром, приходящим JOIN-ом; его recall под наши права замерить на реальных данных, не полагаться на общие оценки. См. ACL pre-filter.
Модель и метрика — единый платформенный выбор
Эмбеддинг-модель — один глобальный выбор на платформу (ai-models); и размерность N колонки halfvec(N), и метрика поиска наследуются от неё — Knowledge Store их не выбирает, а потребляет (N = meta.embedding_dim назначенной модели). Дефолта нет: платформа везёт встроенные модели из коробки (Platform), но N фиксируется при первом назначении — встроенной или своей, — до первого приёма. Метрика — косинус (эмбеддинги нормализованы, класс операторов vector_cosine_ops у HNSW). Один глобальный выбор задаёт и границу между обновлениями: смена на модель той же размерности — это refresh (перегенерация векторов на месте); смена размерности перестраивает тип колонки и индекс — это миграция схемы, не refresh. В v1 колонка провизирована как halfvec(1024) (размерность обеих встроенных моделей), а назначение модели другой — или незаявленной — размерности отклоняется на уровне реестра (409); динамический N придёт вместе со сменой размерности как schema-операцией.
halfvec по умолчанию: половина хранения и индекса
Эмбеддинги — доминирующая статья хранения: на миллионах фрагментов и колонка, и HNSW-индекс уходят в десятки ГБ. halfvec (half-precision, 2 байта на измерение против 4 у vector; pgvector 0.7.0+) режет вдвое и то, и другое — индекс лучше держится в памяти, поиск быстрее. Потеря recall на half-precision практически нулевая, но абсолютное значение замерить на своих данных.
Размерность как рычаг: предпочесть MRL-модель
Размерность N задаёт модель — и при её выборе стоит предпочесть MRL-совместимую (Matryoshka Representation Learning: ранние измерения несут больше смысла, усечение хвоста почти не роняет recall). Тогда сохраняется опцион v2 — индексировать по усечённому вектору (subvector) при хранении полного: меньше индекс без переэмбеддинга. Усечение валидно только для MRL-моделей — обычной модели хвост резать нельзя, ломается пространство.
Когда индекс перестанет влезать в память
Следующий потолок за halfvec — оперативная память под HNSW; путь эскалации v2: бинарная квантизация (binary_quantizebit + расстояние Хэмминга) грубым первым проходом, затем rerank top-кандидатов на halfvec. Бинарь — 1 бит на измерение против 16 у halfvec: ~16× по объёму данных вектора относительно нашего дефолта (полные 32× — лишь против fp32-vector, который мы не держим), и то по данным, не по размеру HNSW-индекса 1:1; halfvec остаётся для rerank. Добавление второго индекса, не переписывание — в v1 незачем.
Полнотекст — конфигурация simple
text_tsv генерируется с конфигурацией simple — токенизация и приведение к нижнему регистру без стемминга и стоп-слов, язык-агностично. Две причины. Контент смешанный (RU + EN), а GENERATED … STORED требует immutable-выражения — язык по строке в генерируемой колонке невозможен. И роль: в гибриде лексический примитив несёт точные совпадения — имена, ID, аббревиатуры, код, — а смысл и кросс-язык даёт векторный примитив, поэтому потеря стемминга для lexical некритична. Стемминг по языку (детект + per-language конфигурация) — отложенная доводка, не переписывание схемы.
Графовая проекция: entity_edge

Граф сущностей живёт в том же Postgres: узел — это сама запись entities (отдельной node-таблицы нет), а связи — рёбра между сущностями. Обход — рекурсивным SQL (CTE) на ближний контекст (1–3 шага), под тем же ACL pre-filter, что и остальные проекции. Выделенный графовый движок — v2, если понадобится.

Граф — в том же Postgres, не в Neo4j. Профиль модуля — связный RAG-retrieval ближнего контекста (1–3 шага): рост источников расширяет граф вширь, не вглубь, а на такой глубине рекурсивный CTE держит нагрузку — вторая СУБД с кросс-базовой синхронизацией не нужна. Выделенный графовый движок (Neo4j) — v2, только при устойчивом сдвиге к глубоким (4+ шага) обходам или граф-аналитике: тогда отдельный графовый слой добавляется на часть данных, реляционка остаётся source of truth — добавление, не переписывание.
3 Ребро Связь между двумя сущностями.
entity_edge рёбра графа
FK → entities ×2 UNIQUE (src, dst, rel_type)
id BigInteger PK
src_entity_id BigInteger FK→entitiesIDX сущность-исток · CASCADE
dst_entity_id BigInteger FK→entitiesIDX сущность-цель · CASCADE
rel_type Text NOT NULLIDX вид связи · mentions · replies_to · links_to · child_of · duplicate_of
weight Real NULL вес / уверенность ребра
origin Text NOT NULLCHECK harvester (структурное на загрузке) · curation (материализовано прогоном: cross-source и отложенные forward-ссылки)
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · правка веса при Curation Pass
Две породы рёбер · единый ACL на обходе
origin различает, кто провёл ребро. Структурные (harvester) приходят с загрузкой — связи внутри источника, что уже видны в записи (тред, иерархия страниц). Cross-source (curation) материализует Curation Pass: упоминание становится ребром, когда оба узла уже в графе. Так как узлы — это entities, обход и его ACL — обычный JOIN entity_acl на посещённых id: единый pre-filter покрывает и граф, без отдельной проекции прав. Обход стартует от узла и идёт по виду связи, поэтому рёбра несут составной индекс (src_entity_id, rel_type), а не только одиночные FK — рекурсивный CTE берёт следующий шаг по нему. Обход ограничен не только глубиной (1–3 шага), но и шириной: cap на fanout с узла, отсев по weight, явный visited-set против циклов — иначе хаб-узел (популярный автор, корневая страница пространства) разворачивает обход в десятки тысяч рёбер ещё до применения ACL.
Из refs в рёбра: канонический словарь и отложенная материализация → Harvester · refs
rel_type — канонический словарь графа; заявки на связь (entity_ref) переводятся в него на материализации: parent → child_of, duplicate → duplicate_of, mentions → mentions, … Ребро рождается, когда оба узла уже в базе — иначе заявка ждёт в entity_ref и материализуется позже, наравне с cross-source: FK на оба конца не терпит висящей цели, в том числе для forward-ссылки внутри одного источника. Заявка, чья цель — не сущность (author → user), разрешается в реляционное тело (author_principal_id), а не в ребро: entity_edge связывает только сущности.
Заявка на связь Staging-очередь рёбер до резолва.

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

entity_ref нерешённые заявки на связь
FK → entities UNIQUE (src_entity_id, relation, target_kind, target_ref) снимок нерешённых
id BigInteger PK
src_entity_id BigInteger FK→entitiesIDX сущность-владелец заявки · CASCADE
relation Text NOT NULL вид связи в терминах источника · mentions · blocks · parent · author · duplicate · …
target_kind Text NOT NULL вид цели · issue · page · user · commit · mr · message · …
target_ref Text NOT NULL натуральный id цели в источнике · ключ резолва
source_hint Text NULL предполагаемый источник · jira · gitlab · NULL если неизвестен
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · при повторном захвате
Очередь нерешённых · гаснет при материализации
Зеркало dead_letters Harvester: строка живёт, пока заявку нельзя связать. Резолв нашёл цель — Curation Pass пишет ребро в entity_edge и удаляет строку; провенанс остаётся на ребре (origin=curation), дублировать его заявкой незачем. Повторный захват той же ссылки не плодит дубль — upsert по натуральному ключу (src_entity_id + relation + target_kind + target_ref).
Граница с производителем: Harvester → Knowledge Store

Контракт между модулями уже зафиксирован: Harvester производит Entity и набрасывает три проекции, Knowledge Store даёт им точную форму и хранит. Та же граница на стороне производителя — в его cross-source.

Harvester производит

Нормализует запись в Entity, считает chunks и их эмбеддинги, проводит структурные связи внутри источника и захватывает права. Делает upsert по ключу идемпотентности — exact-match дедупликация на загрузке.

Knowledge Store хранит и доводит

Держит три проекции и права как source of truth, отдаёт ACL-фильтрованный поиск, и доводит граф поверх всех источников (Curation Pass): cross-source рёбра и нечёткое сведение — догадка между источниками, которой Harvester по одному источнику сделать не может.

Граница: «точно внутри источника → Harvester, догадка между источниками → Knowledge Store». Применение прав на выдаче — → Query Engine.