← Knowledge Store

Lifecycle

knowledge-store · workzone

Жизненный цикл данных Knowledge Store вращается вокруг Curation Pass — периодического фонового прогона по уже собранному графу, не по источнику. Это оркестратор поверх набора lifecycle-операций: материализация рёбер, нечёткое сведение, занижение устаревшего идут одной цепочкой по расписанию, а retention v2 примыкает к ней следующим шагом; переэмбеддинг стоит отдельно — по событию смены модели. Видимость прогонов держит control-plane таблица curation_runs; взаимное исключение конкурентных прогонов с доставкой Harvester держит координация полос, а резервное копирование и согласованность одной базы — backup и consistency.

Curation Pass — оркестратор доводки графа

Harvester доставляет данные и чинит каждый источник по отдельности; Curation Pass доводит граф поверх всех источников разом. Это зеркало Harvester · Reconciliation: Reconciliation читает API источника и приводит снимок per-source в соответствие с ним; Curation Pass читает саму базу Knowledge Store и сводит то, что видно только при взгляде на все источники сразу. Граница ровно та же, что на границе с производителем: «точно внутри источника → Harvester, догадка между источниками → Knowledge Store».

Расписание — одно платформенное, не per-source: прогон идёт по всему графу целиком, дробить его по источникам незачем. Частоту задаёт → Admin · Knowledge Store. Под одним оркестратором — три шага по расписанию в v1 (retention v2 — четвёртым) плюс один по событию:

Оркестратор, не отдельный модуль. Curation Pass не новый сервис, а фоновый прогон Knowledge Store, который запускает набор lifecycle-операций над уже персистнутыми тремя проекциями. Каждый шаг самостоятелен и идемпотентен: повторный прогон не вредит, частичный сбой не рушит остальное.
Материализация рёбер: из заявок в граф

Детерминированный шаг, не догадка. Harvester при захвате оставляет явные ссылки заявками в entity_ref, когда целевой узел ещё не в базе (другой источник или forward внутри своего). Прогон проходит нерешённые заявки, ищет цель по натуральному идентификатору и, найдя, пишет ребро в entity_edge (origin=curation) и гасит заявку; цель не нашлась — заявка ждёт следующего прогона. Так граф eventually complete: связь появляется, как только оба её конца оказались в базе. Это отделяет детерминированную сборку явных связей от нечёткого сведения ниже.

Entity resolution: нечёткое cross-source сведение

Это deep entity resolution — то, что не поймал exact-match Harvester на загрузке. Внутри одного источника дубли схлопывает полный скан Reconciliation; между источниками точного ключа нет, и сведение становится догадкой — задача Curation Pass. Jira «Иван Петров» и Slack «ipetrov» — один человек; две страницы об одном релизе в разных пространствах — одна тема.

Сведение личностей
Несведённые source_principal и личности под разными email прогон сопоставляет нечётко и проставляет identity_idv2, шаг Curation Pass. В v1 мост identity ↔ users досвязывает только по совпавшему email; разошлись адреса — личность уходит на ручной разбор (ниже). Модель и точный exact-свод — на ACL и личности.
Мёрж нод в графе
Две сущности, признанные одним объектом, схлопываются в один узел; их рёбра переносятся, дубль помечается duplicate_of. Узел графа — это сама запись entities, поэтому мёрж — операция над реляционным телом, а не над отдельной node-таблицей.

Несведённое прогон не выбрасывает: что нечётко связать не удалось, остаётся на ручной разбор в → Admin · Identity Mapping — так автоматика и ручной разбор работают по одной очереди.

Staleness & trust decay

Данные устаревают и не равны по достоверности: год назад закрытый тикет и вчерашний релиз, официальный регламент и реплика в чате — разный вес. Curation Pass ведёт ранжирующий entities.trust_score — без удаления, только понижение приоритета в выдаче. Базу задаёт авторитет источника, давность и спрос её модулируют: trust = вес источника, сниженный устареванием и невостребованностью.

Входы trust
  • авторитет источника — базовый вес из уровня (low · normal · high): регламент-база и вики выше, тикеты средне, чат и боты ниже. Уровень задаёт админ на карточке источника (Harvester.sources.authority_tier, читается JOIN по source_id); decay разворачивает уровень в числовой множитель — соответствие уровень → вес живёт в коде одним местом
  • давность — насколько давно source_updated_at правился в источнике; старое опускается decay-функцией
  • частота обращений — что не запрашивают, опускается; востребованное держит вес. Сигнал ведёт Query Engine (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.

Retention policyv2

Что хранить вечно, что архивировать, что удалять физически — завершающий шаг цепочки, отложенный в v2: в первой версии данные просто накапливаются (источник вычищает только то, что сам пометил удалённым, — через Reconciliation), а собственную политику хранения вводим позже. Политика зависит от типа сущности и требований клиента (нормативные сроки, объём, чувствительность), поэтому задаётся не в коде, а в → Admin · Knowledge Store рядом с расписанием прогона.

Хранить
Активное и востребованное — остаётся в горячей выдаче без ограничения срока.
Архивировать
Старое, но ценное для аудита — уходит из горячей выдачи, физически сохраняется. Сродни soft-delete, но по политике, не по пропаже из источника.
Удалять
Истёкшее по сроку или политике — вычищается физически вместе с дочерними chunks по CASCADE.
Embedding refresh — переэмбеддинг при смене модели

Этот шаг стоит вне цепочки расписания: его триггерит не таймер, а событие — смена модели эмбеддингов. Векторы, посчитанные старой моделью, несовместимы с новой: их близость в общем пространстве теряет смысл, и поиск ломается, пока база смешана. Поэтому при смене модели Curation Pass перегенерирует chunks.embedding и обновляет chunks.embedding_model — массовый прогон, который сам по себе — инференс AI.

Тонкость размерности: при равной размерности старые и новые эмбеддинги сосуществуют построчно, пока идёт прогон (различает embedding_model). Смена самой размерности N — не построчный refresh, а schema-операция: chunks.embedding типизирована размерностью (halfvec(N)) и двух N разом не держит, поэтому новый размер требует реиндекса колонки.

Refresh пересобирает индекс, не правит по месту. Массовый UPDATE колонки embedding у живого HNSW — тотальный churn графа (каждая строка переэмбеддинга = удаление и вставка узла под локами), худший режим поддержания индекса. Поэтому прогон идёт пересборкой без эксклюзивного лока на запись: новая колонка → CREATE INDEX CONCURRENTLY → атомарный своп, либо REINDEX INDEX CONCURRENTLY — ценой времени прогона и места под два индекса разом. Тем же приёмом строится индекс на первичном bulk-импорте источника: сперва заливка фрагментов, HNSW — одним проходом после, а не инкрементом во время заливки.

Отмена безопасна — своп единственная точка фиксации. Раз новое поколение собирается в отдельной колонке, а старый индекс авторитетен до свопа, прерывание прогона просто отбрасывает недостроенную колонку: смешанного построчного состояния, видимого поиску, не возникает, и платформа остаётся на прежней модели. Цена не в целостности, а в потраченном инференсе — поэтому в Admin отмена переэмбеддинга идёт через подтверждение, а перезапуск — повторной сменой модели.

Control-plane: curation_runs → Cache & Workers

Зеркало журнала прогонов Harvester (sync_runs) на стороне Knowledge Store: одна строка на прогон, видимость хода и итога. UI ничего не вычисляет — читает curation_runs и рисует прогресс, статус и статистику по шагам.

Прогон доводки Журнал прогонов Curation Pass.
curation_runs журнал прогонов доводки
платформенный · не per-source один активный
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, отдельная отметка правки избыточна
Один активный платформенный прогон ↓ Координация полос
Замок зеркалит sync_runs, но платформенный, не per-source: partial unique index на константном дискриминаторе UNIQUE ((true)) WHERE state IN ('queued','running') — на всю платформу максимум один незавершённый прогон доводки. Второй запрос (таймер совпал с running, ручной триггер поверх планового) встаёт в queued либо отклоняется, а не плодит параллельную доводку. Воркер бьёт heartbeat_at ~раз в 30с; молчание дольше ~90с (три пропуска, как у sync_runs и agent_runs) — сторож отпускает замок зомби, не закрывшего прогон; cancelled терминализует — следующий стартует свежим. Координацию с доставкой Harvester (что идёт параллельно, что взаимоисключается) держит отдельная секция.
Статистика по шагам — в steps, не в столбцах
Шаги прогона разнородны (материализация рёбер, сведение, decay, retention, переэмбеддинг), и набор может меняться, поэтому их итоги живут гибким JSONB, а не фиксированными колонками: какой шаг отработал и с какой статистикой. trigger отвечает почему подняли прогон, stepsчто внутри сделали: событийный переэмбеддинг — это строка с trigger = model_change и единственным шагом переэмбеддинга в steps. Само наполнение steps — ориентировочное, устаканивается при реализации.
Координация полос: доставка ∥ доводка → Harvester · Sync

В один граф пишут две фоновые полосы: доставка — Harvester тянет источники, и доводка — Curation Pass наводит порядок в уже собранном графе. Правило одно: обычно они идут рядом, не мешая друг другу, и лишь на короткое разрушительное окно доводки синхронизация источника уступает ей дорогу.

Доставка Harvester · per-source
Доводка Curation · платформенно мёрж · retention
пересекаются только в замкé — пока он держится, sync источника ждёт в очереди и стартует следом
Идёт параллельно — почти всё
Материализация рёбер, decay и переэмбеддинг только добавляют и пересчитывают, ничего не ломая. Они спокойно переживают одновременную запись Harvester — замок им не нужен: поиск работает, приём не встаёт.
Уступает дорогу — только разрушительное
Мёрж дублей и retention v2 переносят рёбра и удаляют записи. Перепиши Harvester ту же сущность в этот миг — выйдет каша. Поэтому на них встаёт замок, а доставка затронутых источников ждёт и продолжает сразу следом (мёрж по природе cross-source — окно перекрывает не один источник).
Замок самоочищается, прогон один на платформу. Пока разрушительный шаг держит замок, новый прогон источника встаёт в queued и стартует следом — не падает. Упади воркер доводки, не закрыв прогон, — замок отпускается сам, зависших блокировок не остаётся. Прогонов доводки на платформе один (curation_runs), второй — в очередь. Переэмбеддинг сюда не входит: он только добавляет векторы и идёт без замка.

UI. Кнопки просто показывают это состояние, ничего не вычисляя. Источник под прогоном — «Синхронизировать» неактивна; идёт разрушительное окно доводки — запуск помечен «в очереди · идёт доводка графа». Общий индикатор активности — на → Admin · Knowledge Store, когда экран выйдет из заглушки.

Backup & restore

Резервное копирование идёт автоматически по расписанию, отдельно от доводки графа. Назначение (внешнее хранилище / S3), периодичность и retention самих бэкапов задаются в → Admin · Backups; оттуда же запускается восстановление. Одна база Postgres под всем модулем означает, что снимок целостен по построению: тело, векторы, граф и права попадают в бэкап одной согласованной точкой, без сборки из нескольких СУБД.

Конфигурация и журнал бэкапов Что куда копировать и что уже снято.
backup_settings настройки резервного копирования
singleton · CHECK (id = 1)
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 · правится по месту
backup_snapshots журнал снимков
append-only · зеркало sync_runs
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 отпускает замок
Снятие дампа (Postgres + вектора) — длинная I/O-операция, и под может умереть на середине, оставив снимок в running навсегда. Тот же паттерн, что у curation_runs: замок single-flight — partial unique index UNIQUE ((true)) WHERE state = 'running', один активный бэкап на платформу; следующий по расписанию, совпав с незакрытым, отклоняется, а не плодит параллельный дамп. Воркер бьёт heartbeat_at ~раз в 30с; молчание дольше ~90с (три пропуска) — сторож жнёт зомби в failed и отпускает замок, расписание идёт дальше.
Restore — обслуживание, ротация — по счётчику
Восстановление перезаписывает базу снимком целиком, поэтому идёт под maintenance: приём и поиск приостановлены, на платформе — режим обслуживания. Снимки ротируются по retention_count: при наборе сверх порога самый старый удаляется и из журнала, и из хранилища. Креды хранилища write-only — задаются на экране бэкапов, обратно не показываются.
Согласованность одной базы

Реляционное тело, pgvector и граф живут в одном Postgres, поэтому проекции и ACL меняются транзакционно — без кросс-базовой синхронизации, без окна, где права и контент разошлись между СУБД. Source of truth — реляционное тело; векторы и рёбра суть его проекции.

Синхронную загрузку (одна транзакция, фиксированный порядок проекций) держит модель данных. Доводка же асинхронна: Curation Pass досводит cross-source рёбра и нечёткие связи отдельным прогоном, поверх уже консистентного тела. Поэтому граф eventually complete — ребро видно сразу, когда оба узла уже в базе; ссылка на ещё не пришедший узел (как и cross-source) досводится прогоном, — а реляционное тело верно всегда.