refactor(ops): массовая заливка — в общий конвейер вместо отдельных скриптов
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Deploy / test (push) Successful in 2m49s

Заливку Википедии и PMC я сделал отдельными скриптами мимо существующей
инфраструктуры: запуск руками через ssh, состояние в файле в /tmp, никакой
видимости. Результат предсказуем — за неделю обе умерли молча (обрыв базы на
20 008 статьях из 2 млн и таймаут сети на 85 тыс. из 100 тыс.), прогресс
потерялся, а узнали мы об этом через неделю. При том что рядом лежит готовый
механизм: parse_sources, прогоны со шкалой, журнал, кнопки, ретраи Celery.

Теперь это обычные типы источника — wikipedia_ru и pmc_bulk:

- заводятся и запускаются из админки, как OpenAlex или КиберЛенинка;
- показывают ту же шкалу, журнал и кнопку остановки;
- падение воркера больше не теряет прогресс: позиция продолжения хранится в
  parse_sources.resume_token (номер статьи в дампе / токен страницы бакета),
  повторный запуск берёт следующую порцию;
- укладываются в бюджет времени таска — заливка идёт порциями, а не одним
  многосуточным процессом.

Чего не хватало конвейеру для миллионов и что добавлено:
- парсеры отдают генератор, а не список: 2 млн статей в память не влезают;
- app/bulk_writer.py — запись пачками через COPY (21 тыс. строк/с против
  6.7 тыс. построчно) с переподключением к базе при обрыве;
- эмбеддинги при массовой заливке не диспатчатся: они на порядок медленнее и
  стали бы узким местом, вектора досчитываются отдельно (reembed_missing.py).

scripts/ops/bulk_ingest_*.py удалены — их работу делает конвейер.

Проверено на проде: оба парсера отдают документы, прогон через run_parser
завершается штатно, позиция продолжения сдвигается (300 → 600), повторный
запуск продолжает с неё.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-09-05 20:32:53 +05:00
parent 1b1ca3b7e3
commit 26de1b9de1
15 changed files with 732 additions and 584 deletions

View File

@@ -1,247 +0,0 @@
#!/usr/bin/env python3
"""Массовая заливка полнотекстовых статей из открытого бакета PMC (миллионы).
Почему отдельный путь, а не парсеры и Celery: постраничный API даёт ~1-2 статьи
в секунду и упирается в rate limit — миллионы так не залить. У AWS Open Data
бакета `pmc-oa-opendata` (открыт, ключ не нужен) для каждой статьи лежит уже
извлечённый текст `PMC*.txt` (33-130 КБ) и метаданные `PMC*.json`. Замер:
12 статей/с в 10 потоков, то есть миллион — примерно за сутки.
Второе узкое место — запись отпечатков. Построчный INSERT даёт ~6.7 тыс. строк/с
(миллион статей = 2.5 млрд строк = четверо суток только на вставку), поэтому
здесь используется COPY: он на порядки быстрее и не гоняет данные через ORM.
Масштаб для планирования: 1 млн статей ≈ 2.5 млрд отпечатков ≈ 400 ГБ в БД
с индексами. Плотность отпечатков ограничена --fp-per-doc (по умолчанию 2000
вместо 20 000 из настроек воркера) — иначе объём растёт быстрее пользы.
Запуск (в контейнере worker-indexer, где есть psycopg2 и winnowing):
cd /home/user/anti-plagiarism
C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -"
$C --limit 200 < scripts/ops/bulk_ingest_pmc.py # пробная порция
$C --limit 100000 --workers 20 < scripts/ops/bulk_ingest_pmc.py
Идемпотентно: документы вставляются с ON CONFLICT DO NOTHING по ext_id, уже
залитые пропускаются. Прогресс листинга сохраняется в --state-file, поэтому
прерванная заливка продолжается с той же страницы бакета.
Проверено на проде: 1831 отпечаток на статью — реальная глубина, а не аннотация.
Темп ~2 статьи/с в один поток (0.3 ст/с из первого замера были получены, когда
база одновременно считала эмбеддинги). Скачивание не узкое место (12 статей/с в
12 потоков), упирается в последовательную обработку документа — для миллионов
её нужно распараллелить по ядрам воркера.
"""
import argparse
import contextlib
import io
import json
import os
import re
import time
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import quote
BUCKET = "https://pmc-oa-opendata.s3.amazonaws.com"
STATE_FILE = "/tmp/bulk_pmc_state.json"
KEY_RE = re.compile(r"<Key>(PMC\d+\.\d+)/\1\.txt</Key>")
TOKEN_RE = re.compile(r"<NextContinuationToken>([^<]+)</NextContinuationToken>")
def list_articles(client, token: str | None, want: int) -> tuple[list[str], str | None]:
"""Собрать id статей из листинга бакета, продолжая с сохранённой страницы."""
ids: list[str] = []
while len(ids) < want:
url = f"{BUCKET}/?list-type=2&max-keys=1000"
if token:
# В токене бывают + и / — без экранирования S3 отвечает 400
url += f"&continuation-token={quote(token, safe='')}"
resp = client.get(url)
resp.raise_for_status()
ids.extend(KEY_RE.findall(resp.text))
m = TOKEN_RE.search(resp.text)
token = m.group(1) if m else None
if token is None:
break
return ids[:want], token
def fetch_one(client, art_id: str) -> dict | None:
"""Текст статьи и её метаданные. None — если статья недоступна или пустая."""
try:
txt = client.get(f"{BUCKET}/{art_id}/{art_id}.txt")
if txt.status_code != 200 or len(txt.text) < 1500:
return None
meta_resp = client.get(f"{BUCKET}/{art_id}/{art_id}.json")
meta = meta_resp.json() if meta_resp.status_code == 200 else {}
except Exception:
return None
citation = meta.get("citation") or ""
year = None
m = re.search(r"\b(19|20)\d{2}\b", citation)
if m:
year = int(m.group(0))
return {
"ext_id": f"pmc:{meta.get('pmcid') or art_id.split('.')[0]}",
"title": (meta.get("title") or art_id)[:1000],
"doi": meta.get("doi"),
"year": year,
"journal": citation[:300] or None,
"text": txt.text,
}
def write_batch(conn, connect, docs: list[dict], fp_limit: int) -> tuple:
"""Записать пачку статей, пережив обрыв соединения с базой.
Прошлые прогоны умирали целиком от `server closed the connection`, теряя и
позицию в бакете. Здесь обрыв стоит переподключения и повтора.
Returns:
(соединение, добавлено, пропущено, отпечатков) — соединение может быть
новым, если пришлось переподключаться
"""
import psycopg2
from app.algorithms.winnowing import sample_evenly, winnow_ordered
for attempt in range(1, 6):
try:
added = skipped = fp_count = 0
with conn.cursor() as cur:
# ON CONFLICT — повторный прогон не плодит дубли
cur.executemany(
"""INSERT INTO documents (ext_id, source, title, doi, year, lang, journal,
abstract, authors, indexed_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'[]'::json, now())
ON CONFLICT (ext_id) DO NOTHING""",
[(d["ext_id"], "pmc", d["title"], d["doi"], d["year"], "en",
d["journal"], d["text"][:2000]) for d in docs],
)
cur.execute("SELECT id, ext_id FROM documents WHERE ext_id = ANY(%s)",
([d["ext_id"] for d in docs],))
id_by_ext = {e: i for i, e in cur.fetchall()}
# COPY вместо построчных INSERT — единственный способ писать
# миллиарды строк за разумное время
buf = io.StringIO()
for d in docs:
doc_id = id_by_ext.get(d["ext_id"])
if doc_id is None:
continue
cur.execute("SELECT 1 FROM fingerprints WHERE doc_id=%s LIMIT 1", (doc_id,))
if cur.fetchone():
skipped += 1
continue
# Равномерно по тексту, а не первые N: иначе хвост статьи
# остаётся без отпечатков
hashes = sample_evenly(winnow_ordered(d["text"]), fp_limit)
for pos, h in enumerate(hashes):
buf.write(f"{doc_id}\t{h}\t{pos}\n")
fp_count += len(hashes)
added += 1
buf.seek(0)
if added:
cur.copy_from(buf, "fingerprints",
columns=("doc_id", "hash_value", "position"))
conn.commit()
return conn, added, skipped, fp_count
except psycopg2.OperationalError as e:
wait = min(60, 5 * attempt)
print(f" база недоступна ({str(e).strip()[:60]}), повтор через {wait}с "
f"[{attempt}/5]", flush=True)
time.sleep(wait)
with contextlib.suppress(Exception):
conn.close()
with contextlib.suppress(Exception):
conn = connect()
raise RuntimeError("не удалось записать пачку после 5 попыток")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--limit", type=int, default=200, help="сколько статей залить за прогон")
ap.add_argument("--workers", type=int, default=10, help="потоков закачки (вежливо: 10-30)")
ap.add_argument("--batch", type=int, default=200, help="статей в одной транзакции")
ap.add_argument("--fp-per-doc", type=int, default=2000, help="максимум отпечатков на документ")
ap.add_argument("--state-file", default=STATE_FILE, help="файл с позицией листинга")
args = ap.parse_args()
import httpx
import psycopg2
from app.config import settings
token = None
if os.path.exists(args.state_file):
with open(args.state_file) as fh:
token = json.load(fh).get("token")
print("продолжаю листинг с сохранённой позиции")
client = httpx.Client(timeout=60,
headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"})
def connect():
return psycopg2.connect(
host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT,
dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER,
password=settings.POSTGRES_PASSWORD,
)
conn = connect()
added = skipped = failed = fp_total = done = 0
t0 = time.time()
while done < args.limit:
want = min(args.batch, args.limit - done)
# Сеть у бакета иногда отваливается (прошлый прогон умер на таймауте
# SSL-рукопожатия) — это стоит паузы, а не всей заливки
try:
ids, token = list_articles(client, token, want)
except Exception as e:
print(f" листинг не удался ({type(e).__name__}), пауза 30с", flush=True)
time.sleep(30)
continue
if not ids:
print("бакет закончился")
break
done += len(ids)
with ThreadPoolExecutor(max_workers=args.workers) as pool:
docs = [d for d in pool.map(lambda i: fetch_one(client, i), ids) if d]
failed += len(ids) - len(docs)
if not docs:
continue
conn, n_added, n_skipped, n_fp = write_batch(conn, connect, docs, args.fp_per_doc)
added += n_added
skipped += n_skipped
fp_total += n_fp
with open(args.state_file, "w") as fh:
json.dump({"token": token}, fh)
el = time.time() - t0
print(f" {done}/{args.limit} · залито {added} · пропущено {skipped} · "
f"недоступно {failed} · отпечатков {fp_total:,} · {added / max(el, 1):.1f} ст/с",
flush=True)
if token is None:
break
conn.close()
el = time.time() - t0
print(f"\nГотово за {el / 60:.1f} мин: залито {added}, пропущено {skipped}, "
f"недоступно {failed}, отпечатков {fp_total:,}")
if added:
print(f"темп: {added / el:.1f} статей/с → "
f"миллион за ~{1e6 / max(added / el, 0.01) / 3600:.0f} ч")
if __name__ == "__main__":
main()

View File

@@ -1,269 +0,0 @@
#!/usr/bin/env python3
"""Массовая заливка русской Википедии в корпус сравнения.
Зачем именно она: студенты копируют из Википедии чаще, чем из научных статей, а
в корпусе её нет вовсе. При этом она единственный русскоязычный источник такого
объёма, который отдаётся без блокировок — КиберЛенинка режет выкачку после ~130
запросов, eLIBRARY требует договора. Дамп `ruwiki-latest-pages-articles.xml.bz2`
(5.6 ГБ, ~2 млн статей) скачивается свободно.
Дамп не сохраняется на диск: читается потоком и распаковывается на лету —
на app-хосте всего 22 ГБ свободно.
Что отбрасывается: перенаправления, служебные пространства имён (оставляем
только статьи) и короткие тексты — для детекции нужен объём, а не заготовки.
Запуск (в контейнере worker-indexer):
cd /home/user/anti-plagiarism
C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -"
$C --limit 100 < scripts/ops/bulk_ingest_wikipedia_ru.py # проба
$C --limit 500000 < scripts/ops/bulk_ingest_wikipedia_ru.py # объём
Идемпотентно: ext_id вида `wikipedia_ru:<pageid>`, повторная заливка пропускает
уже существующие статьи (ON CONFLICT DO NOTHING).
"""
import argparse
import bz2
import contextlib
import io
import json
import os
import re
import time
DUMP_URL = "https://dumps.wikimedia.org/ruwiki/latest/ruwiki-latest-pages-articles.xml.bz2"
PAGE_RE = re.compile(r"<page>(.*?)</page>", re.DOTALL)
TITLE_RE = re.compile(r"<title>(.*?)</title>", re.DOTALL)
ID_RE = re.compile(r"<id>(\d+)</id>")
NS_RE = re.compile(r"<ns>(\d+)</ns>")
TEXT_RE = re.compile(r'<text[^>]*>(.*?)</text>', re.DOTALL)
REDIRECT_RE = re.compile(r"<redirect ")
# Разметку чистим регулярками: для отпечатков нужен связный текст, а не точное
# восстановление вёрстки — полноценный парсер вики-разметки тут неоправдан
CLEAN_RULES = [
(re.compile(r"\{\{[^{}]*\}\}"), " "), # шаблоны (в несколько проходов)
(re.compile(r"\[\[[^\]|]*\|"), ""), # [[ссылка|текст → текст
(re.compile(r"\[\[|\]\]"), ""), # остатки скобок
(re.compile(r"<ref[^>]*>.*?</ref>", re.DOTALL), " "),
(re.compile(r"<[^>]+>"), " "), # html-теги
(re.compile(r"^[*#:;|!].*$", re.MULTILINE), " "), # списки и таблицы
(re.compile(r"^=+.*?=+$", re.MULTILINE), " "), # == заголовки разделов ==
(re.compile(r"'{2,}"), ""), # ''курсив''
(re.compile(r"&[a-z]+;"), " "),
(re.compile(r"[ \t]+"), " "),
(re.compile(r"\n{2,}"), "\n"),
]
def clean_wikitext(raw: str) -> str:
text = raw
for _ in range(3): # шаблоны бывают вложенными
text = CLEAN_RULES[0][0].sub(CLEAN_RULES[0][1], text)
for rx, repl in CLEAN_RULES[1:]:
text = rx.sub(repl, text)
return text.strip()
def _pages_from_chunks(chunks, min_chars: int):
"""Разобрать поток распакованного XML на статьи основного пространства имён."""
buf = ""
for raw in chunks:
buf += raw.decode("utf-8", errors="replace")
while True:
m = PAGE_RE.search(buf)
if not m:
break
page, buf = m.group(1), buf[m.end():]
if REDIRECT_RE.search(page):
continue
ns = NS_RE.search(page)
if not ns or ns.group(1) != "0": # только статьи
continue
tm, im, xm = TITLE_RE.search(page), ID_RE.search(page), TEXT_RE.search(page)
if not (tm and im and xm):
continue
text = clean_wikitext(xm.group(1))
if len(text) < min_chars:
continue
yield im.group(1), tm.group(1), text
if len(buf) > 20 * 1024 * 1024: # страховка от разбухания
buf = buf[-1024 * 1024:]
def iter_pages(min_chars: int, dump_file: str | None):
"""Статьи из локального дампа либо, если файла нет, прямо из сети.
Локальный файл предпочтителен: Wikimedia рвёт долгие потоковые соединения
(проверено — обрыв на 32 МБ из 5.9 ГБ), а скачать файл можно с докачкой
(`curl -C -`) и потом читать сколько угодно.
"""
decomp = bz2.BZ2Decompressor()
if dump_file:
def chunks():
with open(dump_file, "rb") as fh:
while True:
part = fh.read(4 * 1024 * 1024)
if not part:
return
try:
yield decomp.decompress(part)
except EOFError:
return
yield from _pages_from_chunks(chunks(), min_chars)
return
import httpx
# Wikimedia отдаёт 403 без осмысленного User-Agent — по их правилам он должен
# называть приложение и давать контакт
headers = {"User-Agent": "AcademicHelper/1.0 (https://academic.jze9.ru; noreply@jze9.ru)"}
def net_chunks():
with httpx.stream("GET", DUMP_URL, timeout=120, follow_redirects=True,
headers=headers) as resp:
resp.raise_for_status()
for chunk in resp.iter_bytes(4 * 1024 * 1024):
try:
yield decomp.decompress(chunk)
except EOFError:
return
yield from _pages_from_chunks(net_chunks(), min_chars)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--limit", type=int, default=100, help="сколько статей залить")
ap.add_argument("--batch", type=int, default=150, help="статей в транзакции")
ap.add_argument("--min-chars", type=int, default=2000, help="минимальная длина текста")
ap.add_argument("--fp-per-doc", type=int, default=2000, help="максимум отпечатков на статью")
ap.add_argument("--dump-file", default=None,
help="путь к заранее скачанному дампу (надёжнее, чем читать из сети)")
ap.add_argument("--state-file", default="/tmp/bulk_wiki_state.json",
help="файл с числом уже обработанных статей — для продолжения после сбоя")
args = ap.parse_args()
import psycopg2
from app.algorithms.winnowing import sample_evenly, winnow_ordered
from app.config import settings
def connect():
return psycopg2.connect(
host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT,
dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER,
password=settings.POSTGRES_PASSWORD,
)
conn = connect()
added = skipped = 0
fp_total = 0
seen = 0 # сколько статей прошло через парсер (позиция в дампе)
t0 = time.time()
batch: list[tuple[str, str, str]] = []
# Позиция в дампе: прошлый прогон обрывался на 20 008 статье и начинал всё
# сначала — дедуп по ext_id спасал от дублей, но время тратилось впустую
skip_until = 0
if os.path.exists(args.state_file):
with open(args.state_file) as fh:
skip_until = json.load(fh).get("processed", 0)
if skip_until:
print(f"продолжаю с {skip_until}-й статьи дампа")
def _flush_once(batch):
"""Одна попытка записать пачку; счётчики трогает только при успехе."""
nonlocal added, skipped, fp_total
with conn.cursor() as cur:
cur.executemany(
"""INSERT INTO documents (ext_id, source, title, lang, abstract, url,
authors, indexed_at)
VALUES (%s, 'wikipedia_ru', %s, 'ru', %s, %s, '[]'::json, now())
ON CONFLICT (ext_id) DO NOTHING""",
[(f"wikipedia_ru:{pid}", title[:1000], text[:2000],
f"https://ru.wikipedia.org/?curid={pid}") for pid, title, text in batch],
)
cur.execute("SELECT id, ext_id FROM documents WHERE ext_id = ANY(%s)",
([f"wikipedia_ru:{pid}" for pid, _, _ in batch],))
id_by_ext = {e: i for i, e in cur.fetchall()}
buf = io.StringIO()
fresh = 0
for pid, _, text in batch:
doc_id = id_by_ext.get(f"wikipedia_ru:{pid}")
if doc_id is None:
continue
cur.execute("SELECT 1 FROM fingerprints WHERE doc_id=%s LIMIT 1", (doc_id,))
if cur.fetchone():
skipped += 1
continue
# Равномерно по тексту, а не первые N: обрезка «с начала»
# оставляла бы хвост статьи без отпечатков
hashes = sample_evenly(winnow_ordered(text), args.fp_per_doc)
for pos, h in enumerate(hashes):
buf.write(f"{doc_id}\t{h}\t{pos}\n")
fp_total += len(hashes)
fresh += 1
buf.seek(0)
if fresh:
cur.copy_from(buf, "fingerprints", columns=("doc_id", "hash_value", "position"))
added += fresh
conn.commit()
def flush(batch):
"""Записать пачку, пережив обрыв соединения с базой.
Прошлый прогон умер целиком на `server closed the connection` — потеряв
и позицию в дампе. Здесь обрыв стоит переподключения и повтора.
"""
nonlocal conn
if not batch:
return
for attempt in range(1, 6):
try:
_flush_once(batch)
return
except psycopg2.OperationalError as e:
wait = min(60, 5 * attempt)
print(f" база недоступна ({str(e).strip()[:60]}), повтор через {wait}с "
f"[{attempt}/5]", flush=True)
time.sleep(wait)
with contextlib.suppress(Exception):
conn.close()
try:
conn = connect()
except Exception:
continue
raise RuntimeError("не удалось записать пачку после 5 попыток")
for pid, title, text in iter_pages(args.min_chars, args.dump_file):
seen += 1
if seen <= skip_until: # быстро проматываем уже обработанное
continue
batch.append((pid, title, text))
if len(batch) >= args.batch:
flush(batch)
batch = []
with open(args.state_file, "w") as fh:
json.dump({"processed": seen}, fh)
el = time.time() - t0
print(f" залито {added} · пропущено {skipped} · отпечатков {fp_total:,} · "
f"{added / max(el, 1):.1f} ст/с", flush=True)
if added >= args.limit:
break
flush(batch)
conn.close()
el = time.time() - t0
print(f"\nГотово за {el / 60:.1f} мин: залито {added}, пропущено {skipped}, "
f"отпечатков {fp_total:,}")
if __name__ == "__main__":
main()