feat(admin): честная длительность прогона + добор эмбеддингов
Разбор итогов массовой заливки показал в отладке «среднюю длительность прогона» в 2.8 часа там, где парсинг занимал 50 секунд: started_at пишется в момент постановки в очередь, а очередь из 173 источников разбирается часами. Теперь момент реального старта пишется отдельно (run_started_at, миграция 006), а схема отдаёт обе величины — сколько ждал очереди и сколько работал. Плюс scripts/ops/reembed_missing.py: документ попадает в корпус сразу, а вектор для L3 считает отдельная задача; когда worker-gpu или Ollama недоступны, эти задачи теряются и документ остаётся невидимым для семантического поиска. Скрипт находит faiss_id IS NULL и переотправляет пачками (dry-run по умолчанию) — сейчас таких 23 101 из 177 147. Документация: актуальные цифры корпуса, дубли при повторном прогоне, лимит OpenAlex, и главное — гипервизор .254 зафиксирован в DR-HA как самая широкая единая точка отказа (брокер, эмбеддинги, секреты и прокси на одном железе; подтверждено аварией 28.08). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
140
scripts/ops/reembed_missing.py
Executable file
140
scripts/ops/reembed_missing.py
Executable file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Догнать эмбеддинги для документов, у которых их нет (faiss_id IS NULL).
|
||||
|
||||
Зачем: документ попадает в корпус сразу (метаданные, fingerprints L1, MinHash L2),
|
||||
а вектор для L3 считает отдельная задача `gpu.embed_documents`. Если worker-gpu
|
||||
или Ollama были недоступны в момент заливки, эти задачи теряются — документ
|
||||
остаётся в базе, но семантический поиск его не видит. Скрипт находит такие
|
||||
документы и переотправляет задачи пачками.
|
||||
|
||||
Идемпотентно: повторный запуск возьмёт только оставшиеся без faiss_id.
|
||||
|
||||
ВАЖНО, чего скрипт НЕ делает: документы с непустым `faiss_id`, которого нет в
|
||||
самом FAISS-индексе (наследие смены модели эмбеддингов — размерность сменилась,
|
||||
индекс пересобран с нуля, а ссылки в БД остались), сюда не попадут. Их сначала
|
||||
надо выявить сверкой с индексом и обнулить faiss_id — это отдельная операция.
|
||||
|
||||
Запуск (нужны psycopg2 + celery — они есть в образе воркера; сам репозиторий
|
||||
внутрь контейнера не смонтирован, поэтому передаём скрипт через stdin):
|
||||
cd /home/user/anti-plagiarism
|
||||
C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -"
|
||||
$C < scripts/ops/reembed_missing.py # dry-run, только считает
|
||||
$C --apply < scripts/ops/reembed_missing.py # отправить задачи
|
||||
$C --apply --limit 1000 < scripts/ops/reembed_missing.py # пробная порция
|
||||
|
||||
Креды берутся из окружения (в контейнере они уже есть из env_file), а при
|
||||
запуске файлом — из .env репозитория: POSTGRES_*, RABBITMQ_URL.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_BATCH = 64 # столько же, сколько EMBED_BATCH_SIZE у индексера
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
"""Подтянуть переменные из .env репозитория, если файл есть.
|
||||
|
||||
При запуске через stdin (`docker exec ... python -`) __file__ не определён —
|
||||
это штатный способ запуска здесь, и переменные в контейнере уже есть.
|
||||
"""
|
||||
try:
|
||||
env = Path(__file__).resolve().parent.parent.parent / ".env"
|
||||
except NameError:
|
||||
return
|
||||
if not env.exists():
|
||||
return
|
||||
for line in env.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), val.strip())
|
||||
|
||||
|
||||
def _pg_dsn() -> dict:
|
||||
return {
|
||||
"host": os.environ.get("POSTGRES_HOST", "localhost"),
|
||||
"port": int(os.environ.get("POSTGRES_PORT", "5432")),
|
||||
"dbname": os.environ.get("POSTGRES_DB", "antiplagiator"),
|
||||
"user": os.environ.get("POSTGRES_USER", "antiplagiator"),
|
||||
"password": os.environ.get("POSTGRES_PASSWORD", ""),
|
||||
}
|
||||
|
||||
|
||||
def chunked(items: list[int], size: int) -> list[list[int]]:
|
||||
"""Разбить список id на пачки по size элементов."""
|
||||
return [items[i : i + size] for i in range(0, len(items), size)]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument("--apply", action="store_true", help="реально отправить задачи (иначе dry-run)")
|
||||
ap.add_argument("--batch", type=int, default=DEFAULT_BATCH, help=f"документов в пачке (по умолчанию {DEFAULT_BATCH})")
|
||||
ap.add_argument("--limit", type=int, default=0, help="взять не больше N документов (0 = все)")
|
||||
args = ap.parse_args()
|
||||
|
||||
_load_env()
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
sys.exit("Нужен psycopg2 (есть в образах воркеров). Запускайте в контейнере.")
|
||||
|
||||
dsn = _pg_dsn()
|
||||
print(f"PostgreSQL {dsn['host']}:{dsn['port']}/{dsn['dbname']}")
|
||||
conn = psycopg2.connect(**dsn)
|
||||
with conn.cursor() as cur:
|
||||
sql = "SELECT id FROM documents WHERE faiss_id IS NULL ORDER BY id"
|
||||
if args.limit:
|
||||
sql += f" LIMIT {int(args.limit)}"
|
||||
cur.execute(sql)
|
||||
doc_ids = [r[0] for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT count(*) FROM documents")
|
||||
total = cur.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
batches = chunked(doc_ids, args.batch)
|
||||
print(f"Документов всего: {total}")
|
||||
print(f"Без эмбеддинга: {len(doc_ids)} → пачек по {args.batch}: {len(batches)}")
|
||||
if not doc_ids:
|
||||
print("Нечего досчитывать.")
|
||||
return
|
||||
|
||||
if not args.apply:
|
||||
head = ", ".join(str(i) for i in doc_ids[:10])
|
||||
print(f"\n[dry-run] первые id: {head}{' …' if len(doc_ids) > 10 else ''}")
|
||||
print("Запустите с --apply, чтобы отправить gpu.embed_documents в queue.gpu.")
|
||||
return
|
||||
|
||||
try:
|
||||
from celery import Celery
|
||||
except ImportError:
|
||||
sys.exit("Нужен celery (есть в образах воркеров). Запускайте в контейнере.")
|
||||
|
||||
broker = os.environ.get("RABBITMQ_URL")
|
||||
if not broker:
|
||||
sys.exit("Не задан RABBITMQ_URL — нечем отправлять задачи.")
|
||||
|
||||
app = Celery("reembed", broker=broker)
|
||||
sent = 0
|
||||
try:
|
||||
for batch in batches:
|
||||
app.send_task("gpu.embed_documents", args=[batch], queue="queue.gpu")
|
||||
sent += 1
|
||||
if sent % 50 == 0:
|
||||
print(f" отправлено пачек: {sent}/{len(batches)}")
|
||||
except Exception as e:
|
||||
sys.exit(f"\nБрокер недоступен после {sent} пачек: {e}")
|
||||
|
||||
print(f"\nОтправлено пачек: {sent} ({len(doc_ids)} документов).")
|
||||
print("Ход выполнения — админка → «Отладка» (очередь queue.gpu и покрытие эмбеддингами).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user