pgvector: ACL pre-filter, обход
графа и upsert проверяются на живой СУБД, а не на подменённом
хранилище. Моками закрываем только чистую логику без БД
(chunking, fusion, decay).
pytest -m p0).
pgvector (вектора + recursive SQL в одной СУБД);
миграции Alembic накатываются на старте контейнера
Entity / chunks /
entity_edge и пятёрки ACL — сборка графа и прав сцены
без прохода через Harvester
@pytest.mark.unit / @pytest.mark.integration /
@pytest.mark.api — по типу;
@pytest.mark.p0 / p1 — по приоритету,
ортогонально типу
body режется на куски; короткий — один фрагмент; пустой — ни одногоUNIQUE (entity_id, ordinal) держитсяentity_id, лучший фрагмент остаётся при сущности; одна сущность из нескольких списков дедуплицируетсяchunks ни строки → is_empty истинно; источник истины — наличие фрагментов для поиска, не число источников или сущностейchunk → is_empty ложно; свойство меняется само, без отдельной команды или флагаharvester_embedding при нулевых chunks базу непустой не делает — пустота определяется фрагментами, не готовностью приёмаis_empty вычисляется из наличия chunks (реализация вправе кэшировать), вручную не выставляется — как is_available() у SMTPauthority_tier (high > normal > low) даёт больший trust_score; уровень читается JOIN по source_idsource_updated_at даёт меньший trust_score, свежий — выше (time-machine)access_counter (hits · last_accessed_at), давно не запрашиваемое затухает к нейтралиtrust_score, не трогает status / is_deleted — устаревшее остаётся видимымsrc→dst с фильтром по виду связи строится корректноentity_acl на посещённых id — права лягут в тот же запросchunks возвращает только фрагменты разрешённых сущностей — отбор тем же SQL, до ранжированияtext_tsv + GIN) фильтруется тем же ACL JOIN — точное слово из неразрешённой сущности не утекаетentity_id → entity_aclgroup_membership) ИЛИ прямой грант (entity_acl.source_principal_id); цепочка резолва от usersscope='public' (оба principal/group NULL) виден всем — и пользователю без членств, и анониму по правилам источника; в фильтр уходит как scope='public', без синтетической группыscope IN (group/principal/public) плюс согласованность: group → заполнен source_group_id, principal → source_principal_id, public → оба NULL; кривая комбинация отвергается БДentity_acl — узлы без доступа не попадают в результат, без отдельной проекции правweight отбрасываются из обхода — слабые связи не тянутся в контекст; фильтр применяется до ACL, наравне с fanout-cap сдерживая раздувание с хаб-узлаUNIQUE (source_id, source_type, source_entity_id) повторный upsert обновляет, не плодит дублиchunks → entity_edge в одной транзакции; FK держатсяbody → переэмбеддинг затронутых фрагментов; неизменные не переэмбеддятсяchunks удалены, осиротевших нет (CASCADE по entity_id)source_principal обнуляет entities.author_principal_id (ON DELETE SET NULL) — сущность жива; удаление source уносит всё содержимое по CASCADEstatus вне (draft/final/archived) отвергается CHECK; NULL допустим — до шага classifyis_deleted = true → запись и её фрагменты выпадают из всех четырёх примитивов, строка физически живаchunks.is_deleted ставится в той же транзакции; частичные HNSW/GIN (WHERE NOT is_deleted) исключают удалённое из обхода индексаstatus (draft/final/archived) — оси независимыusers → KS ставит identity.user_id по точному lower(email); Harvester апсёртит identity → KS досвязывает по users; обе точки держит KSALICE@CORP ↔ alice@corp связываются — свод по UNIQUE (lower(email)), коллизий регистра нет(user_id) WHERE user_id IS NOT NULL — одна identity на users; повторная связь идемпотентна, второй привязки не плодитuser_id = NULL, личность остаётся unmatched под ручную привязку в Admin, не ошибкаuser_identity_edge с origin = curation, когда оба узла уже в графе; заявка entity_ref до того, после материализации строка удаляетсяentity_ref; прогон материализует ребро (origin = curation) после того, как цель приходит следующим инкрементом — заявка снимаетсяtarget_kind = user (напр. author → личность) гасится записью author_principal_id в тело сущности; entity_edge при этом не создаётся — не всякая заявка становится ребромduplicate_ofsource_principal под разными email сопоставляются, identity_id проставляется; мост к users досвязывается по разным emailentities.trust_score в БДchunks по CASCADEidentity_id = NULL) для ручного разбора, не выбрасываетсяchunks.embedding_model с текущей моделью → массовый прогон пересчитывает chunks.embeddingembedding_model у фрагментов = текущая модель платформыcontent_hashhalfvec(N)) — миграция (новая колонка → CREATE INDEX CONCURRENTLY → атомарный своп), не in-place refresh, и сюда не входитrunning + started_at; финал — succeeded / failed + finished_atJSONB: рёбер материализовано, дублей сведено, сущностей заниженоstate = failed + краткая причина в error; уже прогнанные шаги отражены в stepsqueued/running/succeeded/failed/cancelled) отвергается БДqueued/runningcancelled + finished_at, освобождает singleton-замок; следующий прогон стартует свежимschedule/model_change/manual) отвергается БД; событийный переэмбеддинг пишется строкой trigger = model_changequeued, не падает, и стартует по завершении шагаON CONFLICT DO NOTHING)duplicate_of консистентенheartbeat_at замок отбирается, новый прогон стартует; fencing отсекает запоздалую записьchunks пишутся текущей моделью; смешанные поколения embedding_model сходятся, приём не встаётis_deleted снимает частичный индекс, ACL-JOIN — на ручной замерchunks.is_deleted + частичный HNSW (WHERE NOT is_deleted) держит удалённое вне индекса — этот предикат недобора не даёт, он не post-фильтрentities → top-K приближённого HNSW частью отсекается уже после скана: разрешённых кандидатов меньше запрошенного, без ошибкиhnsw.iterative_scan (pgvector 0.8.0+) добирает только по условиям на самой chunks; предикат через JOIN на entities им не компенсируется — потому ACL-recall не закрывается автоматическиvector_cosine_ops), не по L2-дефолту pgvector — метрика зафиксирована как контракт, а не унаследована случайно от индексаchunks.embedding и HNSW-индекс восстанавливаются — близость считается как до бэкапаbackup_snapshots: running + started_at → succeeded + finished_at + size_bytes + location; CHECK на state отвергает чужое значениеbackup_settings.retention_count → самый старый удаляется и из журнала, и из хранилищаrunning → по протуханию heartbeat_at (~90с) сторож жнёт его в failed, замок single-flight отпускается, следующий по расписанию стартуетWHERE state = 'running': второй бэкап поверх незакрытого отклоняется, параллельного дампа нетdestination_creds_enc хранится зашифрованным, в API-ответе и data export не возвращается (как api_key_enc)backup_settings CHECK (id = 1): вторая строка отвергается; приложение читает/обновляет, не создаёт422, не 500K попаданий; K ограничен серверным потолком — запрос сверх потолка усечён до него, не безлимитscore и id chunk/entity, порядок по убыванию релевантности; пустой результат → 200 с пустым списком, не 404401 (параметризовано по всем retrieval-роутам)202 + id прогона; строка curation_runs / backup_snapshots в queued, задача в SAQbackup_snapshots → 202; неизвестный снапшот → 404409, не порча данныхPOST reindex / re-embed / backup, пока прогон уже queued/running → 409 CONFLICT (single-flight по curation_runs / backup_snapshots) — не второй прогон и не 500422, не 500200/202; Member → 403401 (параметризовано по всем admin-роутам KS)
Абсолютный recall приближённого индекса HNSW под жёстким
ACL-фильтром
зависит от распределения реальных данных и формы прав — общим оценкам из
бенчмарков доверять нельзя, а синтетика его не воспроизводит. Авто-тест
ловит лишь факт недобора и границу лечения:
hnsw.iterative_scan добирает по условиям на самой
chunks, но ACL приходит JOIN-ом на entities и
итеративным сканом не компенсируется — потому само значение, что
ACL-фильтр не роняет полноту ниже приемлемого, снимается вручную на
представительном корпусе. Назначение — поднять recall под фактическую
нагрузку параметрами индекса (m, ef_search),
раз iterative_scan JOIN-предикат не закрывает, — не в авто-прогоне.
Разделение по типу: unit/ — чистая логика без СУБД, идёт
на каждом PR. integration/ и api/ — против
тестового Postgres с pgvector в Docker, отдельным, более
редким шагом. Приоритет (P0–P1) ортогонален каталогам и задаётся
маркерами (pytest -m p0). Живой замер recall остаётся
ручным инструментом и в авто-прогон не попадает.
tests/knowledge_store/каталог модуля
conftest.pytestcontainers Postgres+pgvector · фабрики Entity/chunks/edge + пятёрки ACL · фейковая модель эмбеддингов · шаги Curation Pass напрямуюunit/чистая логика без СУБД
test_chunking · test_fusion · test_staleness_decay · test_traversal_builderрезка, слияние, decay, построитель обходаintegration/против Postgres+pgvector
test_acl_prefilter · test_identity_bridge · test_graph_traversal · test_entity_upsert · test_soft_deleteP0 — несущие инварианты: ACL, мост личностей, обход, upsert, soft-deletetest_curation_pass · test_embedding_refresh · test_curation_runs · test_coordination · test_hnsw_recall · test_backup_restoreP1 — доводка, refresh, журнал, координация полос, recall, backupapi/HTTP-контракт — httpx + ASGI + Postgres+pgvector
test_api_retrieval.pyпримитивы как tools + собранный hybridtest_api_access.pyвыдача всегда ACL-фильтрованаmanual/вне CI — живой замер recall на реальных данных