arxplorer / utils /backfill_catalog.py
Subhadeep Mandal
Fresh deploy
54eb2ce
Raw
History Blame Contribute Delete
6.07 kB
"""
Backfill ArXiv catalog: harvest CS papers via OAI-PMH → PostgreSQL → Qdrant.
Usage (from backend/):
python -m utils.backfill_catalog # last 5 years, all CS
python -m utils.backfill_catalog --years 2 # last 2 years
python -m utils.backfill_catalog --from 2024-01-01 --until 2024-06-30
python -m utils.backfill_catalog --set-spec cs.AI # subcategory (auto-converted to cs:cs:AI)
python -m utils.backfill_catalog --qdrant-only # skip harvest, just index pending rows
python -m utils.backfill_catalog --chunk-days 14 # smaller time slices (helps with timeouts)
Requires: DATABASE_URL, QDRANT_URI, QDRANT_API_KEY in .env (loaded automatically).
"""
import argparse
import asyncio
import math
import sys
import time
from datetime import date, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn, MofNCompleteColumn
from src.database.db import session_pool
from src.core.catalog_engine.ingest import load_catalog_from_oai, upsert_catalog_to_qdrant
from src.lib.qdrant import ensure_collections_exist
console = Console()
# ArXiv OAI-PMH uses colon-separated set specs (e.g. cs:cs:AI), not dots.
SUBCATEGORY_MAP = {
"cs": "cs", "math": "math", "stat": "stat", "eess": "eess",
"econ": "econ", "q-bio": "q-bio", "q-fin": "q-fin",
}
def _normalize_set_spec(spec: str) -> str:
"""Convert dotted notation (cs.AI) to OAI-PMH format (cs:cs:AI)."""
if ":" in spec:
return spec
parts = spec.split(".", 1)
top = parts[0]
if len(parts) == 1:
return top
return f"{top}:{top}:{parts[1]}"
def _retry(fn, description: str, max_retries: int = 5, base_delay: float = 3.0):
"""Call a sync function with retries on transient network errors."""
for attempt in range(1, max_retries + 1):
try:
return fn()
except Exception as e:
err_msg = str(e).lower()
is_transient = any(s in err_msg for s in ["getaddrinfo", "connecterror", "timed out", "connection reset"])
if not is_transient or attempt == max_retries:
raise
delay = base_delay * attempt
console.print(f"[yellow] {description} failed (attempt {attempt}/{max_retries}): {e}[/yellow]")
console.print(f"[yellow] Retrying in {delay:.0f}s...[/yellow]")
time.sleep(delay)
async def backfill(
set_spec: str,
start: date,
end: date,
chunk_days: int,
qdrant_only: bool,
):
console.print("[bold]Ensuring Qdrant collections exist...[/bold]")
await asyncio.to_thread(_retry, ensure_collections_exist, "Qdrant connection")
total_harvested = 0
total_indexed = 0
if not qdrant_only:
chunk = timedelta(days=chunk_days)
total_chunks = math.ceil((end - start).days / chunk_days)
with Progress(
SpinnerColumn(),
TextColumn("[bold blue]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TimeElapsedColumn(),
TimeRemainingColumn(),
console=console,
) as progress:
harvest_task = progress.add_task("Harvesting", total=total_chunks)
cursor = start
while cursor < end:
until = min(cursor + chunk, end)
from_str = cursor.isoformat()
until_str = until.isoformat()
progress.update(harvest_task, description=f"Harvesting {from_str}{until_str}")
try:
async with session_pool() as session:
count = await load_catalog_from_oai(
session,
set_spec=set_spec,
from_date=from_str,
until_date=until_str,
)
total_harvested += count
except Exception as e:
console.print(f"[red] ERROR {from_str}{until_str}: {e}[/red]")
progress.advance(harvest_task)
cursor = until
console.print(f"[green]Harvest complete: {total_harvested} records upserted to PostgreSQL[/green]")
console.print("\n[bold]Indexing pending rows into Qdrant...[/bold]")
async with session_pool() as session:
total_indexed = await upsert_catalog_to_qdrant(session, batch_size=200, show_progress=True)
console.print(f"\n[bold green]Done. Harvested: {total_harvested} | Indexed to Qdrant: {total_indexed}[/bold green]")
def main():
parser = argparse.ArgumentParser(description="Backfill ArXiv catalog from OAI-PMH")
parser.add_argument("--set-spec", default="cs", help="OAI-PMH set (default: cs)")
parser.add_argument("--years", type=int, default=5, help="How many years back (default: 5)")
parser.add_argument("--from", dest="from_date", help="Override start date (YYYY-MM-DD)")
parser.add_argument("--until", dest="until_date", help="Override end date (YYYY-MM-DD)")
parser.add_argument("--chunk-days", type=int, default=30, help="Days per OAI-PMH request (default: 30)")
parser.add_argument("--qdrant-only", action="store_true", help="Skip harvest, only index pending rows to Qdrant")
args = parser.parse_args()
if args.from_date:
start = date.fromisoformat(args.from_date)
else:
start = date.today() - timedelta(days=365 * args.years)
end = date.fromisoformat(args.until_date) if args.until_date else date.today()
set_spec = _normalize_set_spec(args.set_spec)
print(f"Backfill: {set_spec} | {start}{end} | chunks={args.chunk_days}d")
asyncio.run(backfill(set_spec, start, end, args.chunk_days, args.qdrant_only))
if __name__ == "__main__":
main()