feat(ops): углубление индексации КиберЛенинки + общий store_full_text

Проверка глубины вскрыла главную слабость корпуса: 99 883 документа
КиберЛенинки (56% базы) имеют в среднем 30 отпечатков — заголовок с
аннотацией. Списывание из тела русской статьи L1 не находит, хотя сервис
рассчитан именно на русских студентов. У PMC и arXiv глубина 97% и 94%.

Отдельно: мерить глубину по minio_key оказалось неверно — PMC кладёт тело
статьи в отпечатки при заливке, не сохраняя файл, поэтому «27.5% с полным
текстом» занижало картину для одних источников и скрывало провал у другой.
Честный показатель — число отпечатков на документ, по нему всё и пересчитано.

- backfill_cyberleninka_pdf.py: PDF берётся прямым адресом {url}/pdf (докачка
  по обычному url бесполезна — там HTML, замер 0 из 8). Проверено на 50:
  углублено 44, в среднем 23 тыс. символов, глубина 30 → 1453 отпечатка;
- backfill_pmc_fulltext.py: сохранение тела статьи из API в MinIO (отпечатки
  там уже глубокие — скрипт нужен для отчётов и подсветки, не для детекции);
- store_full_text вынесен из index.enrich_full_text: один путь «текст получен →
  MinIO + пересчёт L1 + обновление L2» для задачи докачки и для бэкфиллов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-08-28 17:45:48 +05:00
parent d8630ca0f9
commit d7c004af76
4 changed files with 309 additions and 49 deletions

View File

@@ -12,21 +12,32 @@
1490 из 1500 на источник): прирост дают только новые публикации. Реальный 1490 из 1500 на источник): прирост дают только новые публикации. Реальный
рост корпуса — поднятый `limit` или новые темы, а не повторный запуск. рост корпуса — поднятый `limit` или новые темы, а не повторный запуск.
### Глубина корпуса — чем реально располагает детекция (замер 2026-08-28) ### Глубина индексации — чем реально располагает детекция (замер 2026-08-28)
| Что | Значение | Следствие | Мерить глубину по `minio_key` (сохранён ли текст в MinIO) **нельзя**: PMC,
|-----|----------|-----------| например, кладёт тело статьи в отпечатки прямо при заливке, не сохраняя файл.
| Документов | 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` |
Это не поломка, а честная граница возможностей: **система ловит списывание из | Источник | Документов | Глубоко (≥500 отпечатков) | Среднее отпечатков |
того, что у неё есть целиком.** Скопированное из тела статьи, от которой в базе |----------|-----------:|--------------------------:|-------------------:|
только аннотация, L1 не найдёт. Рычаги углубления: `FETCH_FULL_TEXT=true` | КиберЛенинка | 99 883 | 0 (0.0%) | **30** |
(докачка OA-PDF, уже включена), приоритет источников с полным текстом (PMC, | OpenAlex | 49 276 | 6 240 (12.7%) | 907 |
arXiv), и добор эмбеддингов для L3. | PMC | 14 910 | 14 519 (97.4%) | 2 208 |
| arXiv | 13 077 | 12 236 (93.6%) | 4 035 |
**Главная слабость — русская часть корпуса.** 56% базы (КиберЛенинка) в индексе
представлено заголовком и аннотацией: списывание из тела русской статьи L1 не
найдёт, хотя сервис рассчитан именно на русских студентов. Причина не в
алгоритме: search API отдаёт только аннотацию и OCR-фрагмент (~700 символов), а
`url` ведёт на HTML-страницу — докачка по нему бесполезна (замер: 0 из 8).
Лечится `scripts/ops/backfill_cyberleninka_pdf.py`: PDF доступен прямым адресом
`{url}/pdf` (проверено: 44 из 50, в среднем 23 тыс. символов, глубина 30 → ~1450
отпечатков). Полный прогон — около 48 часов при вежливом 1 req/s и порядка
+220 млн строк в `fingerprints` (~22 ГБ; место на сервере БД проверять заранее).
Покрытие L3 на ту же дату — 93 053 вектора (52.5% корпуса); ещё 60 993 документа
числились векторизованными ошибочно, отметки сброшены `faiss_reconcile.py`.
- 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`),

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Углубить индексацию КиберЛенинки: скачать PDF статей и переиндексировать по ним.
Самая большая слабость корпуса (замер 2026-08-28): 99 883 документа
КиберЛенинки — 56% всей базы — имеют в среднем 30 отпечатков. Это заголовок с
аннотацией, тела статьи в индексе нет. Списывание из русской статьи система
не найдёт, хотя сервис рассчитан именно на русских студентов.
Причина: search API отдаёт только аннотацию и короткий OCR-фрагмент (~700
символов), а `url` ведёт на HTML-страницу — докачка по нему бесполезна
(замер: 0 из 8). Зато PDF доступен прямым адресом `{url}/pdf` и извлекается:
проверка на 4 статьях дала 13-40 тыс. символов у трёх, у одной 0 (скан без
текстового слоя — такие пропускаем).
Дальше — общий путь `store_full_text`: MinIO + пересчёт отпечатков L1 по
полному тексту + обновление MinHash LSH (L2).
Масштаб полного прогона: ~28 часов при вежливом 1 req/s, порядка +300 млн строк
в fingerprints (~30 ГБ). Запускать в фоне (nohup) и следить за местом на БД.
Запуск (в контейнере worker-indexer):
cd /home/user/anti-plagiarism
C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -"
$C < scripts/ops/backfill_cyberleninka_pdf.py # dry-run
$C --apply --limit 50 < scripts/ops/backfill_cyberleninka_pdf.py # пробная порция
$C --apply < scripts/ops/backfill_cyberleninka_pdf.py # всё (сутки)
Идемпотентно: документы с уже проставленным minio_key пропускаются, так что
прерванный прогон продолжается с того же места.
"""
import argparse
import time
RATE_LIMIT_DELAY = 1.0 # КиберЛенинка не любит частых запросов
MIN_USEFUL_CHARS = 1500 # меньше — скан без текстового слоя либо обрывок
MAX_PDF_BYTES = 30 * 1024 * 1024
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apply", action="store_true", help="реально качать и сохранять")
ap.add_argument("--limit", type=int, default=0, help="взять не больше N документов (0 = все)")
args = ap.parse_args()
import httpx
from app.db import db_session
from app.extractors.pdf import extract_text_from_pdf
from app.tasks.index import store_full_text
from sqlalchemy import text
with db_session() as s:
sql = """
SELECT id, url FROM documents
WHERE source = 'cyberleninka' AND minio_key IS NULL AND url IS NOT NULL
ORDER BY id
"""
if args.limit:
sql += f" LIMIT {int(args.limit)}"
rows = s.execute(text(sql)).all()
print(f"документов КиберЛенинки без полного текста: {len(rows)}")
if not rows:
return
if not args.apply:
print(f"[dry-run] пример: {rows[0][1]}/pdf")
print(f"оценка полного прогона: ~{len(rows) * RATE_LIMIT_DELAY / 3600:.1f} ч, "
f"~{len(rows) * 3000 / 1e6:.0f} млн строк отпечатков")
print("Запустите с --apply (сначала --limit 50).")
return
client = httpx.Client(
timeout=30, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 (compatible; AcademicHelper/1.0; +noreply@jze9.ru)"},
)
done = no_text = failed = 0
chars_total = 0
t0 = time.time()
for n, (doc_id, url) in enumerate(rows, 1):
try:
resp = client.get(url.rstrip("/") + "/pdf")
if resp.status_code != 200 or "pdf" not in resp.headers.get("content-type", "").lower() or len(resp.content) > MAX_PDF_BYTES:
no_text += 1
else:
body = extract_text_from_pdf(resp.content)
if len(body) < MIN_USEFUL_CHARS:
no_text += 1 # скан без текстового слоя
else:
store_full_text(doc_id, body)
done += 1
chars_total += len(body)
except Exception as e:
failed += 1
if failed <= 5:
print(f" doc {doc_id}: {type(e).__name__}: {str(e)[:80]}")
if n % 25 == 0:
el = time.time() - t0
print(f" {n}/{len(rows)} · углублено {done} · без текста {no_text} · "
f"ошибок {failed} · {n / el:.2f} док/с · осталось ~{(len(rows) - n) / max(n / el, 0.01) / 3600:.1f} ч",
flush=True)
time.sleep(RATE_LIMIT_DELAY)
avg = chars_total // done if done else 0
print(f"\nГотово за {(time.time() - t0) / 60:.1f} мин: углублено {done}, "
f"без текстового слоя {no_text}, ошибок {failed}, средний объём {avg} симв.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Бэкфилл полного текста для уже залитых документов PMC.
Зачем: PMC отдаёт тело статьи прямо в ответе efetch (40-50 тыс. символов), но
документы, залитые до включения сохранения full_text, остались в базе с одной
аннотацией — L1 сравнивает по ней и не видит тело статьи. Докачка PDF по url
здесь не работает: ссылка ведёт на HTML-страницу NCBI (замер: 0 из 8). Поэтому
текст берём тем же путём, что и парсер, — через API.
Замер на проде 2026-08-28: 12 368 документов PMC без полного текста.
Что делает для каждого документа: efetch по PMC id → извлечение текста →
`store_full_text` (та же функция, что у задачи докачки: MinIO + пересчёт
fingerprints по полному тексту + обновление MinHash LSH).
Идемпотентно: документы с уже проставленным minio_key не берутся.
Запуск (в контейнере worker-indexer, репозиторий внутрь не смонтирован):
cd /home/user/anti-plagiarism
C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -"
$C < scripts/ops/backfill_pmc_fulltext.py # dry-run
$C --apply --limit 40 < scripts/ops/backfill_pmc_fulltext.py # пробная порция
$C --apply < scripts/ops/backfill_pmc_fulltext.py # всё
Внимание: каждый документ добавляет ~3.5 тыс. строк в fingerprints (~4.5 ГБ
на все 12 тыс.). Перед полным прогоном стоит убедиться в свободном месте на
сервере БД.
"""
import argparse
import sys
import time
BATCH = 20 # столько id за один efetch — как в самом парсере
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("--limit", type=int, default=0, help="взять не больше N документов (0 = все)")
args = ap.parse_args()
if "/parsers" not in sys.path:
sys.path.insert(0, "/parsers")
from app.db import db_session
from app.tasks.index import store_full_text
from sqlalchemy import text
with db_session() as s:
sql = """
SELECT id, ext_id FROM documents
WHERE source = 'pmc' AND minio_key IS NULL AND ext_id IS NOT NULL
ORDER BY id
"""
if args.limit:
sql += f" LIMIT {int(args.limit)}"
rows = s.execute(text(sql)).all()
# ext_id формата "pmc:13521573" → числовой id для efetch
todo = {ext.split(":", 1)[-1]: doc_id for doc_id, ext in rows if ":" in ext}
print(f"документов PMC без полного текста: {len(rows)} (пригодных ext_id: {len(todo)})")
if not todo:
return
if not args.apply:
print(f"[dry-run] первые id: {list(todo.items())[:5]}")
print("Запустите с --apply (рекомендуется сначала --limit 40).")
return
from pmc import PMCParser
parser = PMCParser()
ids = list(todo)
done = failed = empty = 0
chars_total = 0
t0 = time.time()
for i in range(0, len(ids), BATCH):
batch = ids[i : i + BATCH]
try:
resp = parser.client.get(
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi",
params=parser._params(db="pmc", id=",".join(batch), rettype="full", retmode="xml"),
)
resp.raise_for_status()
articles = parser._parse_articles(resp.text)
except Exception as e:
failed += len(batch)
print(f" батч {i // BATCH}: ошибка запроса: {e}")
continue
for raw in articles:
doc = parser.transform(raw)
ext = (doc.get("ext_id") or "").split(":", 1)[-1]
doc_id = todo.get(ext)
full = doc.get("full_text") or ""
if not doc_id:
continue
if len(full) < 1000: # аннотация вместо тела — сохранять нечего
empty += 1
continue
try:
store_full_text(doc_id, full)
done += 1
chars_total += len(full)
except Exception as e:
failed += 1
print(f" doc {doc_id}: не сохранён: {e}")
if (i // BATCH) % 10 == 0:
speed = done / max(time.time() - t0, 1)
print(f" обработано {i + len(batch)}/{len(ids)} · сохранено {done} · "
f"{speed:.1f} док/с")
time.sleep(0.35) # лимит NCBI без ключа — 3 запроса/с
avg = chars_total // done if done else 0
print(f"\nГотово за {time.time() - t0:.0f}с: сохранено {done}, "
f"без тела статьи {empty}, ошибок {failed}, средний объём {avg} симв.")
if __name__ == "__main__":
main()

View File

@@ -347,6 +347,53 @@ def add_document(doc_data: dict[str, Any], dispatch_embed: bool = True) -> dict[
return {"status": "indexed", "doc_id": doc_id} return {"status": "indexed", "doc_id": doc_id}
def store_full_text(doc_id: int, text: str) -> dict[str, Any]:
"""Сохранить полный текст документа и переиндексировать его по нему.
Общая часть для всех путей получения полного текста: скачанный PDF
(enrich_full_text) и текст, пришедший прямо из API источника (бэкфилл PMC —
scripts/ops/). Провизорные fingerprints, посчитанные по аннотации,
заменяются на посчитанные по полному тексту — ради этого всё и делается:
L1 начинает видеть тело статьи, а не только её краткое описание.
Args:
doc_id: документ в PostgreSQL
text: полный текст статьи
Returns:
dict со статусом, объёмом текста и числом отпечатков
"""
from sqlalchemy import delete
from app.models import Document, Fingerprint
minio = get_minio()
key = f"corpus/{doc_id}.txt"
data = text.encode("utf-8")
minio.put_object(
settings.MINIO_BUCKET_DOCS, key, io.BytesIO(data), length=len(data),
content_type="text/plain; charset=utf-8",
)
hashes = list(winnow(text))[: settings.MAX_FINGERPRINTS_PER_DOC]
with db_session() as session:
doc = session.get(Document, doc_id)
if doc is None:
return {"status": "doc_gone", "doc_id": doc_id}
doc.minio_key = key
session.execute(delete(Fingerprint).where(Fingerprint.doc_id == doc_id))
for i, hash_val in enumerate(hashes):
session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i))
session.commit()
# MinHash LSH (L2) тоже должен считаться по полному тексту
add_to_lsh(f"doc:{doc_id}", text)
logger.info(f"store_full_text: doc={doc_id} {len(text)} симв., fingerprints={len(hashes)}")
return {"status": "ok", "doc_id": doc_id, "chars": len(text), "fingerprints": len(hashes)}
@celery_app.task( @celery_app.task(
name="index.enrich_full_text", name="index.enrich_full_text",
bind=True, bind=True,
@@ -366,51 +413,17 @@ def enrich_full_text(self, doc_id: int, url: str) -> dict[str, Any]:
Недоступный/не-PDF источник — не ошибка: возвращаем no_fulltext. Недоступный/не-PDF источник — не ошибка: возвращаем no_fulltext.
""" """
from app.fulltext import fetch_full_text from app.fulltext import fetch_full_text
from app.models import Document, Fingerprint
text = fetch_full_text(url) text = fetch_full_text(url)
if not text: if not text:
return {"status": "no_fulltext", "doc_id": doc_id} return {"status": "no_fulltext", "doc_id": doc_id}
# Сохранить полный текст в MinIO
try: try:
minio = get_minio() return store_full_text(doc_id, text)
key = f"corpus/{doc_id}.txt"
data = text.encode("utf-8")
minio.put_object(
settings.MINIO_BUCKET_DOCS, key, io.BytesIO(data), length=len(data),
content_type="text/plain; charset=utf-8",
)
except Exception as exc: except Exception as exc:
logger.error(f"enrich_full_text: не удалось сохранить текст в MinIO для {doc_id}: {exc}") logger.error(f"enrich_full_text: не удалось сохранить текст для {doc_id}: {exc}")
raise self.retry(exc=exc, countdown=120) from exc raise self.retry(exc=exc, countdown=120) from exc
# Пересчитать fingerprints по полному тексту
doc_fp = winnow(text)
hashes = list(doc_fp)[: settings.MAX_FINGERPRINTS_PER_DOC]
from sqlalchemy import delete
with db_session() as session:
doc = session.get(Document, doc_id)
if doc is None:
return {"status": "doc_gone", "doc_id": doc_id}
doc.minio_key = key
# Удалить провизорные fingerprints и записать новые
session.execute(delete(Fingerprint).where(Fingerprint.doc_id == doc_id))
for i, hash_val in enumerate(hashes):
session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i))
session.commit()
# Обновить MinHash LSH по полному тексту
add_to_lsh(f"doc:{doc_id}", text)
logger.info(
f"enrich_full_text: doc={doc_id} полный текст {len(text)} симв., "
f"fingerprints={len(hashes)}"
)
return {"status": "ok", "doc_id": doc_id, "chars": len(text), "fingerprints": len(hashes)}
@celery_app.task( @celery_app.task(
name="index.auto_approve_submission", name="index.auto_approve_submission",