fix(admin): покрытие L3 мерить по индексу, а не по отметкам в базе

Проверка на живых данных вскрыла, что панель отладки врала в мою же пользу:
показывала 154 046 «документов с эмбеддингом» (87%), тогда как в FAISS реально
93 053 вектора (52.5%). Колонка documents.faiss_id для этого непригодна —
отметка остаётся после пересоздания индекса (смена модели: 768 → 1024) и после
сбоев worker-gpu. Выборочная проверка: у 8 из 20 «отмеченных» вектора нет.

- gpu.index_stats — новая задача, отдаёт реальное содержимое активного
  векторного бэкенда; API спрашивает её для панели отладки;
- панель показывает «векторов в индексе» и отдельно предупреждает о ложных
  отметках (их 60 993), потому что такие документы молча выпадают из L3:
  в индексе их нет, а на пересчёт они не попадут — reembed ищет faiss_id IS NULL;
- scripts/ops/faiss_reconcile.py — сверяет отметки с индексом и обнуляет ложные,
  после чего reembed_missing.py отправляет их на пересчёт. Проверен вживую
  (dry-run на проде: 93 053 в индексе, 60 993 ложных отметок).

Документация: зафиксирована реальная глубина корпуса — полный текст только у
27.5% документов, у 79% меньше 500 отпечатков (уровень аннотации). Система
ловит списывание из того, что есть целиком; это граница, а не поломка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-08-28 17:17:41 +05:00
parent ea62f10829
commit d8630ca0f9
7 changed files with 226 additions and 15 deletions

View File

@@ -784,6 +784,20 @@ async def upload_documents(files: list[UploadFile]) -> dict:
# ═══════════════════════════════════════════════════════════════════════════════
# ОТЛАДКА
# ═══════════════════════════════════════════════════════════════════════════════
def _vector_index_stats() -> dict:
"""Реальное наполнение векторного индекса — спросить у worker-gpu.
Колонка `documents.faiss_id` для этого не годится: пометка остаётся и когда
вектор в индекс не попал, и когда индекс пересоздали после смены модели
эмбеддингов. Отладка, показывающая покрытие L3 по ней, завышает его в разы.
"""
try:
result = celery_app.send_task("gpu.index_stats", queue="queue.gpu")
return result.get(timeout=10) or {}
except Exception as e:
return {"error": str(e)[:200] or type(e).__name__}
def _celery_snapshot() -> dict:
"""Живое состояние воркеров через Celery inspect (блокирующий вызов)."""
try:
@@ -827,8 +841,10 @@ async def debug_snapshot(db: AsyncSession = Depends(get_db)) -> dict:
именно сейчас крутит, что копится в очередях, как наполняется корпус,
какие прогоны идут и на чём падали последние.
"""
# Воркеры — блокирующий Celery inspect, в пул потоков, чтобы не вешать луп
# Воркеры и статистика индекса — блокирующие вызовы Celery, в пул потоков,
# чтобы не вешать событийный цикл
celery_state = await run_in_threadpool(_celery_snapshot)
vector_stats = await run_in_threadpool(_vector_index_stats)
rmq = await _rabbitmq_queues()
queues = (
@@ -915,8 +931,10 @@ async def debug_snapshot(db: AsyncSession = Depends(get_db)) -> dict:
"queues": queues,
"corpus": {
"documents": docs_total,
"documents_embedded": docs_embedded,
"documents_without_embedding": max(docs_total - docs_embedded, 0),
# Пометка в БД и реальное содержимое индекса расходятся — показываем
# обе величины, иначе покрытие L3 выглядит лучше, чем оно есть
"documents_marked_embedded": docs_embedded,
"vector_index": vector_stats,
"fingerprints_estimate": int(fingerprints_est),
"elasticsearch_documents": es_docs,
},