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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,8 @@ interface DebugData {
|
||||
celery: { workers: Worker[]; error?: string };
|
||||
queues: Record<string, { messages: number; unacked: number; consumers: number }> | { 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<string, { messages: number; unacked: number; consumers: number }>);
|
||||
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() {
|
||||
<Card icon={Database} title="Корпус сравнения">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<Metric label="Документов" value={fmtNum(data.corpus.documents)} />
|
||||
<Metric label="С эмбеддингом" value={fmtNum(data.corpus.documents_embedded)} />
|
||||
<Metric label="Векторов в индексе" value={fmtNum(vectors)} />
|
||||
<Metric label="Отпечатков (оценка)" value={fmtNum(data.corpus.fingerprints_estimate)} />
|
||||
<Metric label="В Elasticsearch" value={fmtNum(data.corpus.elasticsearch_documents)} />
|
||||
</div>
|
||||
{data.corpus.vector_index?.error && (
|
||||
<p className="text-xs text-red-600 mb-2">
|
||||
индекс недоступен: {data.corpus.vector_index.error}
|
||||
</p>
|
||||
)}
|
||||
<ProgressBar
|
||||
percent={embedPercent}
|
||||
status={data.corpus.documents_without_embedding > 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 && (
|
||||
<div className="mt-3 flex items-start gap-2 text-sm text-amber-700 bg-amber-50 border border-amber-100 rounded-lg p-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
У {fmtNum(staleMarks)} документов в базе стоит отметка faiss_id, но вектора в индексе нет —
|
||||
обычно это след пересоздания индекса после смены модели эмбеддингов.
|
||||
Такие документы не участвуют в семантическом поиске и не будут пересчитаны,
|
||||
пока отметку не сбросить: <code>scripts/ops/faiss_reconcile.py</code>.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Проблемные прогоны */}
|
||||
|
||||
@@ -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(
|
||||
|
||||
39
services/worker-gpu/app/tasks/stats.py
Normal file
39
services/worker-gpu/app/tasks/stats.py
Normal file
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user