diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bdc98c4..6550745 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,7 +95,7 @@ | Очередь | Задачи | Воркер | |---------|--------|--------| | `queue.index` | `index.extract_and_check`, `index.add_document`, `index.run_parser`, `index.enrich_full_text`, `index.ingest_upload` | worker-indexer | -| `queue.gpu` | `gpu.check_plagiarism`, `gpu.embed_documents`, `gpu.search_semantic` | worker-gpu | +| `queue.gpu` | `gpu.check_plagiarism`, `gpu.embed_documents`, `gpu.search_semantic`, `gpu.index_stats` | worker-gpu | | `queue.gost` | `gost.format_bibliography` | worker-gost | | `queue.notify` | `notify.send_task_done`, `notify.send_verification` | worker-notifier | @@ -232,6 +232,12 @@ Identity (Universal Auth) и генерирует `.env` заново (`infisica `VECTOR_BACKEND`). - **Бюджет времени прогона** (`PARSER_TIME_BUDGET_S`, по умолчанию 1500с) — защита от краш-лупа по `consumer_timeout` RabbitMQ, см. [DR-HA.md](DR-HA.md) §6. +- **Покрытие L3 меряется по индексу, а не по БД.** Колонка `documents.faiss_id` + для этого непригодна: отметка остаётся после пересоздания индекса (смена + модели/размерности) и после сбоев worker-gpu. Панель отладки спрашивает + реальное число векторов задачей `gpu.index_stats` и отдельно предупреждает о + ложных отметках; чинит их + [`scripts/ops/faiss_reconcile.py`](../scripts/ops/faiss_reconcile.py). - **Добор эмбеддингов** — [`scripts/ops/reembed_missing.py`](../scripts/ops/reembed_missing.py): документ попадает в корпус сразу, а вектор для L3 считает отдельная задача `gpu.embed_documents`; если worker-gpu или Ollama были недоступны, эти задачи diff --git a/docs/INGESTION.md b/docs/INGESTION.md index a241321..7c06951 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -11,6 +11,22 @@ - Повторный прогон по уже залитым источникам даёт почти одни дубли (типично 1490 из 1500 на источник): прирост дают только новые публикации. Реальный рост корпуса — поднятый `limit` или новые темы, а не повторный запуск. + +### Глубина корпуса — чем реально располагает детекция (замер 2026-08-28) + +| Что | Значение | Следствие | +|-----|----------|-----------| +| Документов | 177 147 | — | +| С полным текстом в MinIO | 48 848 (27.5%) | arXiv 93.7%, КиберЛенинка 27.8%, PMC 17.0%, OpenAlex 12.7% | +| Отпечатков < 500 на документ | 128 246 (79%) | по этим документам L1 сравнивает фактически аннотацию, а не тело статьи | +| Векторов в индексе (L3) | 93 053 (52.5%) | остальные документы семантический поиск не видит | +| Ложных отметок `faiss_id` | 60 993 | вектора нет, но документ считается векторизованным — см. `faiss_reconcile.py` | + +Это не поломка, а честная граница возможностей: **система ловит списывание из +того, что у неё есть целиком.** Скопированное из тела статьи, от которой в базе +только аннотация, L1 не найдёт. Рычаги углубления: `FETCH_FULL_TEXT=true` +(докачка OA-PDF, уже включена), приоритет источников с полным текстом (PMC, +arXiv), и добор эмбеддингов для L3. - OpenAlex при массовом запуске упирается в лимит вежливого пула (10 req/s на mailto, общий для всех воркеров) — пауза между страницами поднята до 1с. - Добавлен 4-й парсер — **PMC** (PubMed Central, `scripts/parsers/pmc.py`), @@ -66,10 +82,17 @@ python scripts/seed_ru_sources.py --apply --limit 500 - **Страница «Отладка»** — очереди, воркеры, покрытие эмбеддингами, зависшие и упавшие прогоны (см. ARCHITECTURE.md §12). -После большой заливки стоит свериться с покрытием L3 (панель отладки, строка -«без вектора»): эмбеддинги считаются отдельной задачей на worker-gpu, и если он -или Ollama были недоступны, документы останутся без вектора. Догнать — -`scripts/ops/reembed_missing.py` (dry-run по умолчанию, `--apply` отправляет). +После большой заливки — свериться с покрытием L3 (панель отладки, «Векторов в +индексе»). Порядок именно такой, из двух шагов: + +1. `scripts/ops/faiss_reconcile.py` (в контейнере worker-gpu) — сверяет отметки + `faiss_id` с реальным содержимым индекса и обнуляет ложные. Без этого шага + документы, потерявшие вектор при пересоздании индекса, не попадут на + пересчёт: они всё ещё «отмечены». +2. `scripts/ops/reembed_missing.py` (в контейнере worker-indexer) — отправляет + `gpu.embed_documents` для всех `faiss_id IS NULL`. + +Оба по умолчанию dry-run, отправляют/меняют только с `--apply`. Прогон со статусом `partial` — это не ошибка: сработал бюджет времени (`PARSER_TIME_BUDGET_S`, 1500с), заливка остановилась раньше `consumer_timeout` diff --git a/scripts/ops/faiss_reconcile.py b/scripts/ops/faiss_reconcile.py new file mode 100755 index 0000000..95ec46f --- /dev/null +++ b/scripts/ops/faiss_reconcile.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Сверить отметки `documents.faiss_id` с реальным содержимым векторного индекса. + +Проблема, которую он решает: отметка в базе НЕ означает, что вектор есть в +индексе. Она остаётся после пересоздания индекса (например, при смене модели +эмбеддингов и размерности) и после сбоев worker-gpu. Из-за этого документ +навсегда выпадает из L3: в индексе его нет, а на пересчёт он не попадёт — +`reembed_missing.py` ищет только `faiss_id IS NULL`. + +Замер на проде 2026-08-28: в индексе 93 053 вектора, отмечено в базе 154 046 — +60 993 документа считались векторизованными, не будучи ими. + +Скрипт обнуляет отметки у документов, которых в индексе нет. После него +`reembed_missing.py` отправит их на пересчёт. + +Запуск (нужен доступ к самому индексу → контейнер worker-gpu): + cd /home/user/anti-plagiarism + C="docker compose -f docker-compose.prod.yml exec -T worker-gpu python -" + $C < scripts/ops/faiss_reconcile.py # dry-run, только показывает + $C --apply < scripts/ops/faiss_reconcile.py # обнулить ложные отметки + +Работает только с FAISS (`VECTOR_BACKEND=faiss`): у Qdrant идентификаторы +хранит сам сервис, и сверка там делается иначе. +""" + +import argparse +import sys + + +def stale_marks(marked_ids: set[int], indexed_ids: set[int]) -> set[int]: + """Документы, отмеченные как векторизованные, но отсутствующие в индексе.""" + return marked_ids - indexed_ids + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--apply", action="store_true", help="реально обнулить faiss_id (иначе dry-run)") + args = ap.parse_args() + + from app.config import settings + + if settings.VECTOR_BACKEND != "faiss": + sys.exit(f"VECTOR_BACKEND={settings.VECTOR_BACKEND}: скрипт рассчитан на faiss") + + import faiss # noqa: F401 (нужен для vector_to_array) + from app.faiss_manager import FAISSManager + + FAISSManager.load_or_create() + index = FAISSManager._index + if index is None or not hasattr(index, "id_map"): + sys.exit("индекс не загрузился или не хранит id (нет id_map)") + + indexed = {int(i) for i in faiss.vector_to_array(index.id_map)} + print(f"векторов в индексе: {index.ntotal} (уникальных id: {len(indexed)})") + + import psycopg2 + + conn = psycopg2.connect( + host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT, + dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER, + password=settings.POSTGRES_PASSWORD, + ) + with conn.cursor() as cur: + cur.execute("SELECT id FROM documents WHERE faiss_id IS NOT NULL") + marked = {r[0] for r in cur.fetchall()} + cur.execute("SELECT count(*) FROM documents") + total = cur.fetchone()[0] + + stale = stale_marks(marked, indexed) + print(f"документов всего: {total}") + print(f"отмечено как векторизованные: {len(marked)}") + print(f"ложных отметок (нет в индексе): {len(stale)}") + print(f"реальное покрытие L3: {len(indexed & marked) * 100.0 / total:.1f}%") + + if not stale: + print("Сверка чистая, делать нечего.") + conn.close() + return + + if not args.apply: + print(f"\n[dry-run] первые id: {sorted(stale)[:10]}") + print("Запустите с --apply, чтобы обнулить faiss_id у этих документов,") + print("затем reembed_missing.py отправит их на пересчёт эмбеддингов.") + conn.close() + return + + ids = list(stale) + with conn.cursor() as cur: + # Пачками: один UPDATE с десятками тысяч id упирается в лимиты параметров + for i in range(0, len(ids), 5000): + chunk = ids[i : i + 5000] + cur.execute("UPDATE documents SET faiss_id = NULL WHERE id = ANY(%s)", (chunk,)) + conn.commit() + conn.close() + print(f"\nОбнулено отметок: {len(ids)}") + print("Дальше: reembed_missing.py --apply (отправит их на пересчёт).") + + +if __name__ == "__main__": + main() diff --git a/services/api/app/api/admin.py b/services/api/app/api/admin.py index 0ac53eb..4897c3c 100644 --- a/services/api/app/api/admin.py +++ b/services/api/app/api/admin.py @@ -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, }, diff --git a/services/frontend/src/pages/admin/Debug.tsx b/services/frontend/src/pages/admin/Debug.tsx index 2a4789c..5a7fb6d 100644 --- a/services/frontend/src/pages/admin/Debug.tsx +++ b/services/frontend/src/pages/admin/Debug.tsx @@ -23,7 +23,8 @@ interface DebugData { celery: { workers: Worker[]; error?: string }; queues: Record | { error: string }; corpus: { - documents: number; documents_embedded: number; documents_without_embedding: number; + documents: number; documents_marked_embedded: number; + vector_index: { backend?: string; vectors?: number; dim?: number; embed_model?: string; error?: string }; fingerprints_estimate: number; elasticsearch_documents: number | null; }; sources: { @@ -79,8 +80,14 @@ export function Debug() { const queuesErr = 'error' in data.queues ? (data.queues as { error: string }).error : null; const queues = queuesErr ? {} : (data.queues as Record); - const embedPercent = data.corpus.documents - ? (data.corpus.documents_embedded / data.corpus.documents) * 100 + // Покрытие L3 считаем по РЕАЛЬНОМУ содержимому индекса: пометка faiss_id в БД + // остаётся и когда вектор туда не попал, и завышает картину в разы + const vectors = data.corpus.vector_index?.vectors ?? null; + const embedPercent = data.corpus.documents && vectors != null + ? (vectors / data.corpus.documents) * 100 + : 0; + const staleMarks = vectors != null + ? Math.max(0, data.corpus.documents_marked_embedded - vectors) : 0; return ( @@ -191,15 +198,32 @@ export function Debug() {
- +
+ {data.corpus.vector_index?.error && ( +

+ индекс недоступен: {data.corpus.vector_index.error} +

+ )} 0 ? 'partial' : 'done'} - label={`покрытие эмбеддингами (L3): без вектора ${fmtNum(data.corpus.documents_without_embedding)} документов`} + status={embedPercent >= 99 ? 'done' : 'partial'} + label={`покрытие L3 (реально в индексе ${data.corpus.vector_index?.backend || '—'}): ` + + `без вектора ${fmtNum(Math.max(0, data.corpus.documents - (vectors ?? 0)))} документов`} /> + {staleMarks > 0 && ( +
+ + + У {fmtNum(staleMarks)} документов в базе стоит отметка faiss_id, но вектора в индексе нет — + обычно это след пересоздания индекса после смены модели эмбеддингов. + Такие документы не участвуют в семантическом поиске и не будут пересчитаны, + пока отметку не сбросить: scripts/ops/faiss_reconcile.py. + +
+ )}
{/* Проблемные прогоны */} diff --git a/services/worker-gpu/app/celery_app.py b/services/worker-gpu/app/celery_app.py index 3630c43..aa975b5 100644 --- a/services/worker-gpu/app/celery_app.py +++ b/services/worker-gpu/app/celery_app.py @@ -9,7 +9,7 @@ celery_app = Celery( "worker_gpu", broker=settings.RABBITMQ_URL, backend=settings.REDIS_URL, - include=["app.tasks.search", "app.tasks.plagiarism"], + include=["app.tasks.search", "app.tasks.plagiarism", "app.tasks.stats"], ) celery_app.conf.update( diff --git a/services/worker-gpu/app/tasks/stats.py b/services/worker-gpu/app/tasks/stats.py new file mode 100644 index 0000000..58523e3 --- /dev/null +++ b/services/worker-gpu/app/tasks/stats.py @@ -0,0 +1,39 @@ +"""Служебная задача: состояние векторного индекса для панели отладки. + +Индекс живёт в памяти и на диске worker-gpu, у API к нему доступа нет. Без этой +задачи админка показывала покрытие L3 по колонке `documents.faiss_id` — а она +врёт: пометка остаётся и тогда, когда вектор в индекс не попал (или индекс был +пересоздан после смены модели эмбеддингов). Здесь возвращается то, что в индексе +есть на самом деле. +""" + +from typing import Any + +from celery.utils.log import get_task_logger + +from app.celery_app import celery_app +from app.config import settings +from app.vector_store import get_backend + +logger = get_task_logger(__name__) + + +@celery_app.task(name="gpu.index_stats") +def index_stats() -> dict[str, Any]: + """Реальное число векторов в активном бэкенде (FAISS или Qdrant).""" + backend = get_backend() + try: + # У FAISS индекс ленивый: без обращения ntotal вернёт 0 на холодном воркере + if hasattr(backend, "_ensure"): + backend._ensure() + total = backend.total_vectors() + except Exception as e: + logger.warning(f"index_stats: не удалось прочитать индекс: {e}") + return {"backend": settings.VECTOR_BACKEND, "error": str(e)[:200]} + + return { + "backend": settings.VECTOR_BACKEND, + "vectors": total, + "dim": settings.EMBED_DIM, + "embed_model": settings.EMBED_MODEL, + }