Ядро Knowledge Store — сущность знаний. Один логический контракт
Entity, который производит Harvester, здесь персистится и расходится на
три проекции в одной базе
Postgres: реляционное тело (entities), фрагменты с векторами и полнотекстом (chunks) и граф связей (entity_edge).
На entities нанизано всё остальное: права
(entity_acl → entities), поиск
(четыре примитива) и доводка графа
(Curation Pass). Как контракт Harvester ложится в эти таблицы — на
границе с производителем.
Сущность — это не одна строка в одной базе и не три копии в трёх СУБД. Это одна запись с тремя проекциями внутри одного Postgres: тело фильтруют и джойнят точно, фрагменты ищут по смыслу, рёбра обходят по связям. Все три живут рядом, поэтому единый ACL pre-filter и слияние четырёх парадигм идут одним SQL-запросом, без кросс-базовой синхронизации.
Идентичность, тип, статус, метаданные, текст для выдачи — и
source of truth для прав (entity_acl ссылается сюда).
Всё, что фильтруют и джойнят точно.
Фрагменты сущности — дочерние строки (ключ — её id),
каждый с двумя индексами: плотным эмбеддингом для поиска по смыслу
и разреженным tsvector для поиска по словам. Опора
текстового retrieval — оба сигнала рядом.
Сущность — узел, отдельной node-таблицы нет; связи — рёбра между сущностями. Структурные приносит Harvester, cross-source — материализует Curation Pass.
entities (на неё ссылаются остальные по FK),
затем её chunks (переэмбеддинг только при смене
content_hash), затем структурные entity_edge,
чьи оба узла уже в базе. Cross-source рёбра и доводка — асинхронны, их ведёт
Curation Pass.
entities
Центральная таблица модуля — каноничный снимок записи источника и source of truth, на который ссылаются все остальные проекции и права. Идентичность совпадает с ключом идемпотентности контракта Harvester: одна строка на запись источника, повторный прогон делает upsert.
| 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 |
updated_at). Пропажа записи из
источника — это is_deleted, отдельная ось от
status: запись прячут из выдачи, но физически
держат ради аудита и восстановления. Момент исчезновения
проставляет полный скан
reconciliation — в инкремент удаление не приходит.
chunks
Эмбеддинги — не один вектор на запись, а по фрагментам: длинный
документ
режется на куски, каждый эмбеддится отдельно. Тот же текст фрагмента несёт и
разреженный полнотекстовый индекс (tsvector + GIN) —
лексический поиск по словам рядом с векторным по смыслу. Фрагмент —
дочерняя строка сущности, своего ACL не имеет: он наследует
права родителя
через entity_id, поэтому отбор по правам идёт тем же
SQL-запросом, что и поиск по близости.
chunks по source_id ради
объёма обманчив: у каждой партиции свой HNSW-индекс, а семантический
поиск идёт по всем источникам разом — запрос без предиката по ключу
партиции вынужден сканировать индекс каждой партиции и сливать, а
недобор под ACL множится по партициям (partition pruning не сработает,
пока запрос не сужен до источника — редкий случай). Профит партиций —
лишь дешёвое удаление источника (DROP PARTITION), но это
уже закрывает ON DELETE CASCADE. Поэтому одно глобальное
хранилище, не шардированное по источнику. Выделённый шардинг
(v2) — только при доказанном объёме и
сдвиге паттерна на source-scoped поиск: добавление, не переписывание.
| 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 · правка при переэмбеддинге |
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.
N колонки
halfvec(N), и метрика поиска наследуются от неё —
Knowledge Store их не выбирает, а потребляет
(N = meta.embedding_dim назначенной
модели). Дефолта нет: платформа везёт встроенные модели из
коробки (Platform), но N фиксируется при первом назначении —
встроенной или своей, — до первого приёма. Метрика —
косинус (эмбеддинги нормализованы, класс
операторов vector_cosine_ops у HNSW). Один
глобальный выбор задаёт и границу между обновлениями: смена на
модель той же размерности — это
refresh
(перегенерация векторов на месте); смена размерности
перестраивает тип колонки и индекс — это миграция схемы, не
refresh. В v1 колонка провизирована как
halfvec(1024) (размерность обеих встроенных
моделей), а назначение модели другой — или незаявленной —
размерности отклоняется на уровне реестра (409); динамический
N придёт вместе со сменой размерности как
schema-операцией.
halfvec по умолчанию: половина хранения и индекса
halfvec (half-precision, 2 байта на измерение против
4 у vector; pgvector 0.7.0+) режет вдвое и то, и
другое — индекс лучше держится в памяти, поиск быстрее. Потеря recall на half-precision
практически нулевая, но абсолютное значение замерить на своих
данных.
N задаёт модель — и при её выборе стоит
предпочесть MRL-совместимую (Matryoshka Representation Learning:
ранние измерения несут больше смысла, усечение хвоста почти не
роняет recall). Тогда сохраняется опцион
v2 — индексировать по усечённому
вектору (subvector) при хранении полного: меньше
индекс без переэмбеддинга. Усечение валидно
только для MRL-моделей — обычной модели хвост
резать нельзя, ломается пространство.
halfvec — оперативная память
под HNSW; путь эскалации v2:
бинарная квантизация (binary_quantize →
bit + расстояние Хэмминга) грубым первым проходом,
затем 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, если понадобится.
| 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 |
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.
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 связывает только сущности.
Ссылка, чью цель ещё нельзя связать (узел не загружен или живёт в другом источнике). Harvester пишет заявку при захвате, Curation Pass резолвит цель и гасит строку, материализуя ребро.
| 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 производит Entity и набрасывает три проекции, Knowledge Store даёт им точную форму и хранит. Та же граница на стороне производителя — в его cross-source.
Нормализует запись в Entity, считает chunks и их
эмбеддинги, проводит структурные связи внутри источника и
захватывает права. Делает upsert по ключу идемпотентности — exact-match
дедупликация на загрузке.
Держит три проекции и права как source of truth, отдаёт ACL-фильтрованный поиск, и доводит граф поверх всех источников (Curation Pass): cross-source рёбра и нечёткое сведение — догадка между источниками, которой Harvester по одному источнику сделать не может.
Граница: «точно внутри источника → Harvester, догадка между источниками → Knowledge Store». Применение прав на выдаче — → Query Engine.