Жизненный цикл данных Knowledge Store вращается вокруг Curation Pass — периодического фонового прогона по уже собранному графу, не по источнику. Это оркестратор поверх набора lifecycle-операций: материализация рёбер, нечёткое сведение, занижение устаревшего идут одной цепочкой по расписанию, а retention v2 примыкает к ней следующим шагом; переэмбеддинг стоит отдельно — по событию смены модели. Видимость прогонов держит control-plane таблица curation_runs; взаимное исключение конкурентных прогонов с доставкой Harvester держит координация полос, а резервное копирование и согласованность одной базы — backup и consistency.
Harvester доставляет данные и чинит каждый источник по отдельности; Curation Pass доводит граф поверх всех источников разом. Это зеркало Harvester · Reconciliation: Reconciliation читает API источника и приводит снимок per-source в соответствие с ним; Curation Pass читает саму базу Knowledge Store и сводит то, что видно только при взгляде на все источники сразу. Граница ровно та же, что на границе с производителем: «точно внутри источника → Harvester, догадка между источниками → Knowledge Store».
Расписание — одно платформенное, не per-source: прогон идёт по всему графу целиком, дробить его по источникам незачем. Частоту задаёт → Admin · Knowledge Store. Под одним оркестратором — три шага по расписанию в v1 (retention v2 — четвёртым) плюс один по событию:
entity_ref →
рёбра, когда оба узла в базе
→
2
Entity resolution
нечёткое cross-source сведение дублей и
личностей
→
3
Staleness decay
давность и обращения занижают
trust_score поверх веса источника
→
4
Retentionv2
архивация и удаление по типу и
политике
Детерминированный шаг, не догадка. Harvester при захвате оставляет
явные ссылки заявками в
entity_ref,
когда целевой узел ещё не в базе (другой источник или forward внутри
своего). Прогон проходит нерешённые заявки, ищет цель по натуральному
идентификатору и, найдя, пишет ребро в
entity_edge
(origin=curation) и гасит заявку; цель не нашлась — заявка
ждёт следующего прогона. Так граф eventually complete: связь
появляется, как только оба её конца оказались в базе. Это отделяет
детерминированную сборку явных связей от
нечёткого сведения
ниже.
Это deep entity resolution — то, что не поймал exact-match Harvester на загрузке. Внутри одного источника дубли схлопывает полный скан Reconciliation; между источниками точного ключа нет, и сведение становится догадкой — задача Curation Pass. Jira «Иван Петров» и Slack «ipetrov» — один человек; две страницы об одном релизе в разных пространствах — одна тема.
source_principal и личности под
разными email прогон сопоставляет нечётко и проставляет
identity_id — v2, шаг
Curation Pass. В v1 мост identity ↔ users досвязывает
только по совпавшему email; разошлись адреса — личность уходит на
ручной разбор (ниже). Модель и точный exact-свод — на
ACL и личности.
duplicate_of. Узел графа — это
сама запись entities, поэтому мёрж — операция над реляционным телом, а не над
отдельной node-таблицей.
Несведённое прогон не выбрасывает: что нечётко связать не удалось, остаётся на ручной разбор в → Admin · Identity Mapping — так автоматика и ручной разбор работают по одной очереди.
Данные устаревают и не равны по достоверности: год назад закрытый тикет
и вчерашний релиз, официальный регламент и реплика в чате — разный вес.
Curation Pass ведёт ранжирующий
entities.trust_score
— без удаления, только понижение приоритета в выдаче. Базу задаёт
авторитет источника, давность и спрос её
модулируют: trust = вес источника, сниженный
устареванием и невостребованностью.
low · normal · high):
регламент-база и вики выше, тикеты средне, чат и боты ниже. Уровень
задаёт админ на карточке источника
(Harvester.sources.authority_tier, читается JOIN по source_id); decay разворачивает
уровень в числовой множитель — соответствие
уровень → вес живёт в коде одним местом
source_updated_at правился в источнике; старое
опускается decay-функцией
access_counter.hits · last_accessed_at,
читается JOIN по id сущности при пересчёте — тем же приёмом, что
authority_tier); давно не запрашиваемое затухает к
нейтрали
trust_score ↓
trust_score — произведение нормализованных множителей:
авторитет × свежесть × востребованность. Свежесть — экспоненциальный
спад с настраиваемым периодом полураспада; множители уровней авторитета
и веса осей живут в коде/конфигурации одним местом, не магическими
числами. Простая объяснимая функция намеренно: усложнять — после
замеров на реальной выдаче, не вперёд. Два инварианта держат прогон
предсказуемым:
trust_score заново из текущих входов, поэтому
повторный прогон идемпотентен: тот же результат, без накопления
дрейфа
access_counter, множитель
востребованности нейтрален
(decay едет на авторитете и давности); сигнал подключается без
смены формулы и схемы
Decay — не статус и не soft-delete: оси
status и is_deleted ведёт загрузка, а
trust_score — это непрерывный вес, который пересчитывает
прогон. Пропажу записи из источника фиксирует
Reconciliation, а не staleness.
Что хранить вечно, что архивировать, что удалять физически — завершающий шаг цепочки, отложенный в v2: в первой версии данные просто накапливаются (источник вычищает только то, что сам пометил удалённым, — через Reconciliation), а собственную политику хранения вводим позже. Политика зависит от типа сущности и требований клиента (нормативные сроки, объём, чувствительность), поэтому задаётся не в коде, а в → Admin · Knowledge Store рядом с расписанием прогона.
Этот шаг стоит вне цепочки расписания: его триггерит
не таймер, а событие — смена модели эмбеддингов. Векторы,
посчитанные старой моделью, несовместимы с новой: их близость в общем
пространстве теряет смысл, и поиск ломается, пока база смешана. Поэтому
при смене модели Curation Pass перегенерирует
chunks.embedding
и обновляет chunks.embedding_model — массовый прогон,
который сам по себе — инференс
AI.
Тонкость размерности: при равной размерности старые и новые эмбеддинги
сосуществуют построчно, пока идёт прогон (различает
embedding_model). Смена самой размерности N — не
построчный refresh, а schema-операция: chunks.embedding
типизирована размерностью (halfvec(N)) и двух N разом не
держит, поэтому новый размер требует реиндекса колонки.
UPDATE колонки embedding у живого
HNSW — тотальный churn графа (каждая строка переэмбеддинга = удаление и
вставка узла под локами), худший режим поддержания индекса. Поэтому
прогон идёт пересборкой без эксклюзивного лока на запись: новая колонка
→ CREATE INDEX CONCURRENTLY → атомарный своп, либо
REINDEX INDEX CONCURRENTLY — ценой времени прогона и места
под два индекса разом. Тем же приёмом строится индекс на первичном
bulk-импорте источника: сперва заливка фрагментов, HNSW — одним
проходом после, а не инкрементом во время заливки.
Отмена безопасна — своп единственная точка фиксации. Раз новое поколение собирается в отдельной колонке, а старый индекс авторитетен до свопа, прерывание прогона просто отбрасывает недостроенную колонку: смешанного построчного состояния, видимого поиску, не возникает, и платформа остаётся на прежней модели. Цена не в целостности, а в потраченном инференсе — поэтому в Admin отмена переэмбеддинга идёт через подтверждение, а перезапуск — повторной сменой модели.
curation_runs
→ Cache & Workers
Зеркало журнала прогонов Harvester
(sync_runs) на стороне Knowledge Store: одна строка на прогон, видимость хода и
итога. UI ничего не вычисляет — читает curation_runs и
рисует прогресс, статус и статистику по шагам.
| id | BigInteger | PK | — |
| trigger | Text | NOT NULLCHECK | что подняло прогон · schedule (таймер) · model_change (смена модели эмбеддингов) · manual (ручной запуск из Admin) |
| state | Text | NOT NULLDEFAULTCHECK | queued · running · succeeded · failed · cancelled |
| started_at | DateTime(tz) | NULLIDX | NULL пока queued · длительность = finished − started |
| finished_at | DateTime(tz) | NULL | NULL пока running · терминальное время |
| heartbeat_at | DateTime(tz) | NULL | живость воркера прогона · протухание отпускает замок |
| steps | JSONB | NULL | какие шаги прогнаны + статистика · рёбер материализовано · дублей сведено · сущностей занижено |
| error | Text | NULL | краткая причина провала |
| created_at | DateTime(tz) | DEFAULT | now() · без updated_at — ход прогона несут started_at / finished_at / heartbeat_at, отдельная отметка правки избыточна |
UNIQUE ((true)) WHERE state IN ('queued','running')
— на всю платформу максимум один незавершённый прогон доводки.
Второй запрос (таймер совпал с running, ручной триггер поверх
планового) встаёт в queued либо отклоняется, а не
плодит параллельную доводку. Воркер бьёт
heartbeat_at ~раз в 30с; молчание дольше
~90с (три пропуска, как у
sync_runs
и agent_runs)
— сторож отпускает замок зомби, не закрывшего прогон;
cancelled терминализует — следующий стартует
свежим. Координацию с доставкой Harvester (что идёт
параллельно, что взаимоисключается) держит
отдельная секция.
steps, не в столбцах
JSONB, а не фиксированными колонками: какой
шаг отработал и с какой статистикой. trigger отвечает
почему подняли прогон, steps — что внутри
сделали: событийный переэмбеддинг — это строка с
trigger = model_change и единственным шагом
переэмбеддинга в steps. Само наполнение
steps — ориентировочное, устаканивается при
реализации.
В один граф пишут две фоновые полосы: доставка — Harvester тянет источники, и доводка — Curation Pass наводит порядок в уже собранном графе. Правило одно: обычно они идут рядом, не мешая друг другу, и лишь на короткое разрушительное окно доводки синхронизация источника уступает ей дорогу.
queued и стартует следом — не падает. Упади воркер
доводки, не закрыв прогон, — замок отпускается сам, зависших
блокировок не остаётся. Прогонов доводки на платформе один
(curation_runs),
второй — в очередь. Переэмбеддинг сюда не входит: он только
добавляет векторы и идёт без замка.
UI. Кнопки просто показывают это состояние, ничего не вычисляя. Источник под прогоном — «Синхронизировать» неактивна; идёт разрушительное окно доводки — запуск помечен «в очереди · идёт доводка графа». Общий индикатор активности — на → Admin · Knowledge Store, когда экран выйдет из заглушки.
Резервное копирование идёт автоматически по расписанию, отдельно от доводки графа. Назначение (внешнее хранилище / S3), периодичность и retention самих бэкапов задаются в → Admin · Backups; оттуда же запускается восстановление. Одна база Postgres под всем модулем означает, что снимок целостен по построению: тело, векторы, граф и права попадают в бэкап одной согласованной точкой, без сборки из нескольких СУБД.
| id | BigInteger | PKCHECK | всегда 1 · одна строка на инстанс |
| destination_url | Text | NULL | S3-совместимое назначение (s3://…) · NULL = не настроено |
| destination_creds_enc | Text | NULL |
ключ доступа к хранилищу · write-only, шифруется крипто-ядром
(как api_key_enc), в UI и data export не возвращается ·
NULL = ambient IAM-роль инстанса (внутри AWS)
|
| frequency | Text | NOT NULLDEFAULTCHECK | каденс снятия дампа · DEFAULT 'daily' · daily · weekly |
| weekday | Integer | NULLCHECK | 0–6 · только для weekly · NULL для daily |
| time | Text | NOT NULLDEFAULT | 'HH:MM' локального времени · DEFAULT '02:00' |
| retention_count | Integer | NOT NULLDEFAULTCHECK | сколько снимков хранить · DEFAULT 14 · старые ротируются |
| created_at | DateTime(tz) | DEFAULT | now() |
| updated_at | DateTime(tz) | DEFAULT | now() + trigger · правится по месту |
| id | BigInteger | PK | — |
| state | Text | NOT NULLDEFAULTCHECK | running · succeeded · failed · UI-чип «готов» = succeeded |
| started_at | DateTime(tz) | NOT NULLIDX | когда снят · сортировка журнала |
| finished_at | DateTime(tz) | NULL | NULL пока running |
| heartbeat_at | DateTime(tz) | NULL | живость воркера снятия дампа · протухание отпускает замок |
| size_bytes | BigInteger | NULL | размер дампа · показывается в журнале (12.4 GB) |
| location | Text | NULL | путь снимка в хранилище · для restore |
| error | Text | NULL | краткая причина провала |
| created_at | DateTime(tz) | DEFAULT | now() · без updated_at — ход несёт started_at / finished_at / heartbeat_at |
heartbeat_at отпускает замок
running
навсегда. Тот же паттерн, что у
curation_runs:
замок single-flight — partial unique index
UNIQUE ((true)) WHERE state = 'running', один
активный бэкап на платформу; следующий по расписанию, совпав с
незакрытым, отклоняется, а не плодит параллельный дамп. Воркер
бьёт heartbeat_at ~раз в 30с; молчание дольше
~90с (три пропуска) — сторож жнёт зомби в
failed и отпускает замок, расписание идёт дальше.
retention_count: при наборе
сверх порога самый старый удаляется и из журнала, и из хранилища.
Креды хранилища write-only — задаются на
экране бэкапов,
обратно не показываются.
Реляционное тело, pgvector и граф живут в одном Postgres, поэтому проекции и ACL меняются транзакционно — без кросс-базовой синхронизации, без окна, где права и контент разошлись между СУБД. Source of truth — реляционное тело; векторы и рёбра суть его проекции.
Синхронную загрузку (одна транзакция, фиксированный порядок проекций) держит модель данных. Доводка же асинхронна: Curation Pass досводит cross-source рёбра и нечёткие связи отдельным прогоном, поверх уже консистентного тела. Поэтому граф eventually complete — ребро видно сразу, когда оба узла уже в базе; ссылка на ещё не пришедший узел (как и cross-source) досводится прогоном, — а реляционное тело верно всегда.