Files
anti-plagiarism/scripts/ops/reembed_missing.py
jze9 99bf14fe6a 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>
2026-08-28 16:51:25 +05:00

141 lines
6.6 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()