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:
@@ -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.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.gost` | `gost.format_bibliography` | worker-gost |
|
||||||
| `queue.notify` | `notify.send_task_done`, `notify.send_verification` | worker-notifier |
|
| `queue.notify` | `notify.send_task_done`, `notify.send_verification` | worker-notifier |
|
||||||
|
|
||||||
@@ -232,6 +232,12 @@ Identity (Universal Auth) и генерирует `.env` заново (`infisica
|
|||||||
`VECTOR_BACKEND`).
|
`VECTOR_BACKEND`).
|
||||||
- **Бюджет времени прогона** (`PARSER_TIME_BUDGET_S`, по умолчанию 1500с) —
|
- **Бюджет времени прогона** (`PARSER_TIME_BUDGET_S`, по умолчанию 1500с) —
|
||||||
защита от краш-лупа по `consumer_timeout` RabbitMQ, см. [DR-HA.md](DR-HA.md) §6.
|
защита от краш-лупа по `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):
|
- **Добор эмбеддингов** — [`scripts/ops/reembed_missing.py`](../scripts/ops/reembed_missing.py):
|
||||||
документ попадает в корпус сразу, а вектор для L3 считает отдельная задача
|
документ попадает в корпус сразу, а вектор для L3 считает отдельная задача
|
||||||
`gpu.embed_documents`; если worker-gpu или Ollama были недоступны, эти задачи
|
`gpu.embed_documents`; если worker-gpu или Ollama были недоступны, эти задачи
|
||||||
|
|||||||
@@ -11,6 +11,22 @@
|
|||||||
- Повторный прогон по уже залитым источникам даёт почти одни дубли (типично
|
- Повторный прогон по уже залитым источникам даёт почти одни дубли (типично
|
||||||
1490 из 1500 на источник): прирост дают только новые публикации. Реальный
|
1490 из 1500 на источник): прирост дают только новые публикации. Реальный
|
||||||
рост корпуса — поднятый `limit` или новые темы, а не повторный запуск.
|
рост корпуса — поднятый `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
|
- OpenAlex при массовом запуске упирается в лимит вежливого пула (10 req/s
|
||||||
на mailto, общий для всех воркеров) — пауза между страницами поднята до 1с.
|
на mailto, общий для всех воркеров) — пауза между страницами поднята до 1с.
|
||||||
- Добавлен 4-й парсер — **PMC** (PubMed Central, `scripts/parsers/pmc.py`),
|
- Добавлен 4-й парсер — **PMC** (PubMed Central, `scripts/parsers/pmc.py`),
|
||||||
@@ -66,10 +82,17 @@ python scripts/seed_ru_sources.py --apply --limit 500
|
|||||||
- **Страница «Отладка»** — очереди, воркеры, покрытие эмбеддингами, зависшие и
|
- **Страница «Отладка»** — очереди, воркеры, покрытие эмбеддингами, зависшие и
|
||||||
упавшие прогоны (см. ARCHITECTURE.md §12).
|
упавшие прогоны (см. ARCHITECTURE.md §12).
|
||||||
|
|
||||||
После большой заливки стоит свериться с покрытием L3 (панель отладки, строка
|
После большой заливки — свериться с покрытием L3 (панель отладки, «Векторов в
|
||||||
«без вектора»): эмбеддинги считаются отдельной задачей на worker-gpu, и если он
|
индексе»). Порядок именно такой, из двух шагов:
|
||||||
или Ollama были недоступны, документы останутся без вектора. Догнать —
|
|
||||||
`scripts/ops/reembed_missing.py` (dry-run по умолчанию, `--apply` отправляет).
|
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` — это не ошибка: сработал бюджет времени
|
Прогон со статусом `partial` — это не ошибка: сработал бюджет времени
|
||||||
(`PARSER_TIME_BUDGET_S`, 1500с), заливка остановилась раньше `consumer_timeout`
|
(`PARSER_TIME_BUDGET_S`, 1500с), заливка остановилась раньше `consumer_timeout`
|
||||||
|
|||||||
101
scripts/ops/faiss_reconcile.py
Executable file
101
scripts/ops/faiss_reconcile.py
Executable file
@@ -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()
|
||||||
@@ -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:
|
def _celery_snapshot() -> dict:
|
||||||
"""Живое состояние воркеров через Celery inspect (блокирующий вызов)."""
|
"""Живое состояние воркеров через Celery inspect (блокирующий вызов)."""
|
||||||
try:
|
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)
|
celery_state = await run_in_threadpool(_celery_snapshot)
|
||||||
|
vector_stats = await run_in_threadpool(_vector_index_stats)
|
||||||
|
|
||||||
rmq = await _rabbitmq_queues()
|
rmq = await _rabbitmq_queues()
|
||||||
queues = (
|
queues = (
|
||||||
@@ -915,8 +931,10 @@ async def debug_snapshot(db: AsyncSession = Depends(get_db)) -> dict:
|
|||||||
"queues": queues,
|
"queues": queues,
|
||||||
"corpus": {
|
"corpus": {
|
||||||
"documents": docs_total,
|
"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),
|
"fingerprints_estimate": int(fingerprints_est),
|
||||||
"elasticsearch_documents": es_docs,
|
"elasticsearch_documents": es_docs,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ interface DebugData {
|
|||||||
celery: { workers: Worker[]; error?: string };
|
celery: { workers: Worker[]; error?: string };
|
||||||
queues: Record<string, { messages: number; unacked: number; consumers: number }> | { error: string };
|
queues: Record<string, { messages: number; unacked: number; consumers: number }> | { error: string };
|
||||||
corpus: {
|
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;
|
fingerprints_estimate: number; elasticsearch_documents: number | null;
|
||||||
};
|
};
|
||||||
sources: {
|
sources: {
|
||||||
@@ -79,8 +80,14 @@ export function Debug() {
|
|||||||
|
|
||||||
const queuesErr = 'error' in data.queues ? (data.queues as { error: string }).error : null;
|
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 queues = queuesErr ? {} : (data.queues as Record<string, { messages: number; unacked: number; consumers: number }>);
|
||||||
const embedPercent = data.corpus.documents
|
// Покрытие L3 считаем по РЕАЛЬНОМУ содержимому индекса: пометка faiss_id в БД
|
||||||
? (data.corpus.documents_embedded / data.corpus.documents) * 100
|
// остаётся и когда вектор туда не попал, и завышает картину в разы
|
||||||
|
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;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -191,15 +198,32 @@ export function Debug() {
|
|||||||
<Card icon={Database} title="Корпус сравнения">
|
<Card icon={Database} title="Корпус сравнения">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
<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)} />
|
||||||
<Metric label="С эмбеддингом" value={fmtNum(data.corpus.documents_embedded)} />
|
<Metric label="Векторов в индексе" value={fmtNum(vectors)} />
|
||||||
<Metric label="Отпечатков (оценка)" value={fmtNum(data.corpus.fingerprints_estimate)} />
|
<Metric label="Отпечатков (оценка)" value={fmtNum(data.corpus.fingerprints_estimate)} />
|
||||||
<Metric label="В Elasticsearch" value={fmtNum(data.corpus.elasticsearch_documents)} />
|
<Metric label="В Elasticsearch" value={fmtNum(data.corpus.elasticsearch_documents)} />
|
||||||
</div>
|
</div>
|
||||||
|
{data.corpus.vector_index?.error && (
|
||||||
|
<p className="text-xs text-red-600 mb-2">
|
||||||
|
индекс недоступен: {data.corpus.vector_index.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
percent={embedPercent}
|
percent={embedPercent}
|
||||||
status={data.corpus.documents_without_embedding > 0 ? 'partial' : 'done'}
|
status={embedPercent >= 99 ? 'done' : 'partial'}
|
||||||
label={`покрытие эмбеддингами (L3): без вектора ${fmtNum(data.corpus.documents_without_embedding)} документов`}
|
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>
|
</Card>
|
||||||
|
|
||||||
{/* Проблемные прогоны */}
|
{/* Проблемные прогоны */}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ celery_app = Celery(
|
|||||||
"worker_gpu",
|
"worker_gpu",
|
||||||
broker=settings.RABBITMQ_URL,
|
broker=settings.RABBITMQ_URL,
|
||||||
backend=settings.REDIS_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(
|
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