Skip to content

Commit a5f200a

Browse files
authored
Merge branch 'main' into frontend
2 parents 144f2c0 + 3cc8dc1 commit a5f200a

4 files changed

Lines changed: 185 additions & 11 deletions

File tree

.github/workflows/backend-lint.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ jobs:
1515
with:
1616
python-version: "3.11"
1717

18+
- name: Set PYTHONPATH
19+
run: echo "PYTHONPATH=$(pwd)/backend" >> $GITHUB_ENV
20+
1821
- name: Install dependencies
1922
working-directory: backend
2023
run: |
@@ -39,6 +42,9 @@ jobs:
3942
with:
4043
python-version: "3.11"
4144

45+
- name: Set PYTHONPATH
46+
run: echo "PYTHONPATH=$(pwd)/backend" >> $GITHUB_ENV
47+
4248
- name: Install MyPy and dependencies
4349
working-directory: backend
4450
run: |

backend/__init__.py

Whitespace-only changes.

backend/app/models/paper.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@
44
from typing import TYPE_CHECKING, List, Optional
55

66
from pgvector.sqlalchemy import Vector
7-
from sqlalchemy import Enum as SqlEnum
87
from sqlalchemy import (
98
JSON,
109
BigInteger,
1110
Date,
1211
DateTime,
12+
Index,
1313
String,
1414
Text,
15-
Index,
1615
)
16+
from sqlalchemy import Enum as SqlEnum
1717
from sqlalchemy.orm import Mapped, mapped_column, relationship
1818
from sqlalchemy.sql import func
1919

@@ -45,17 +45,13 @@ class Paper(Base):
4545
authors: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
4646
abstract: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
4747
published_at: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
48-
pdf_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
49-
url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
50-
48+
paper_id_external: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
5149
fetched_at: Mapped[datetime] = mapped_column(
5250
DateTime(timezone=True), nullable=False, server_default=func.now()
5351
)
5452

5553
# Vector Embedding
56-
embedding: Mapped[Optional[List[float]]] = mapped_column(
57-
Vector(768), nullable=True
58-
)
54+
embedding: Mapped[Optional[List[float]]] = mapped_column(Vector(768), nullable=True)
5955

6056
# Relationships
6157
project_links: Mapped[List["ProjectPaper"]] = relationship(
@@ -72,9 +68,7 @@ class Paper(Base):
7268
"ix_paper_embedding_hnsw",
7369
"embedding",
7470
postgresql_using="hnsw",
75-
postgresql_ops={
76-
"embedding": "vector_cosine_ops"
77-
},
71+
postgresql_ops={"embedding": "vector_cosine_ops"},
7872
postgresql_with={"m": 16, "ef_construction": 64},
7973
),
8074
)

backend/ingestion/arxiv_to_db.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import argparse
2+
import json
3+
import logging
4+
import os
5+
from datetime import date, datetime
6+
from pathlib import Path
7+
from typing import Any, Iterable, List, Optional, Tuple
8+
9+
import numpy as np
10+
import pandas as pd
11+
import psycopg2
12+
from psycopg2.extensions import connection
13+
from psycopg2.extras import execute_values
14+
15+
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
16+
logger = logging.getLogger(__name__)
17+
18+
DB_CONFIG: dict[str, Any] = {
19+
"host": os.getenv("POSTGRES_HOST"),
20+
"port": int(os.getenv("POSTGRES_PORT") or 5432),
21+
"user": os.getenv("POSTGRES_USER"),
22+
"password": os.getenv("POSTGRES_PASSWORD"),
23+
"dbname": os.getenv("POSTGRES_DB"),
24+
"sslmode": "require",
25+
}
26+
27+
TABLE = "paper"
28+
29+
30+
def iter_shards(data_dir: Path) -> Iterable[Path]:
31+
"""Return all parquet shard paths in sorted order."""
32+
return sorted(data_dir.glob("shard_*.parquet"))
33+
34+
35+
def parse_date(d: Optional[str]) -> Optional[date]:
36+
"""Parse YYYY-MM-DD into a date object or return None."""
37+
if not d:
38+
return None
39+
try:
40+
return datetime.strptime(d, "%Y-%m-%d").date()
41+
except ValueError:
42+
return None
43+
44+
45+
def to_plain(x: Any) -> Any:
46+
"""Convert numpy objects recursively to plain Python types."""
47+
if isinstance(x, np.ndarray):
48+
return [to_plain(v) for v in x.tolist()]
49+
if isinstance(x, (list, tuple)):
50+
return [to_plain(v) for v in x]
51+
if isinstance(x, dict):
52+
return {k: to_plain(v) for k, v in x.items()}
53+
if isinstance(x, np.generic):
54+
return x.item()
55+
return x
56+
57+
58+
def authors_to_json(x: Any) -> str:
59+
"""Convert author list structure into JSON string."""
60+
try:
61+
return json.dumps(to_plain(x))
62+
except (TypeError, ValueError):
63+
return "[]"
64+
65+
66+
def ensure_doi_index(conn: connection) -> None:
67+
"""Ensure unique index on paper.doi exists."""
68+
with conn.cursor() as cur:
69+
cur.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_paper_doi ON public.paper(doi);")
70+
conn.commit()
71+
72+
73+
def embedding_to_literal(embedding: Any) -> str:
74+
"""Convert embedding array into PostgreSQL vector literal."""
75+
if hasattr(embedding, "tolist"):
76+
embedding = embedding.tolist()
77+
return "[" + ",".join(f"{float(v):.6f}" for v in embedding) + "]"
78+
79+
80+
def insert_rows(conn: connection, rows: List[Tuple[Any, ...]]) -> None:
81+
"""Insert a batch of rows into the paper table."""
82+
if not rows:
83+
return
84+
85+
query = f"""
86+
INSERT INTO public.{TABLE} (
87+
doi,
88+
source,
89+
paper_type,
90+
title,
91+
authors,
92+
abstract,
93+
published_at,
94+
paper_id_external,
95+
embedding
96+
)
97+
VALUES %s
98+
ON CONFLICT (doi) DO NOTHING
99+
"""
100+
101+
template = "(" + ",".join(["%s"] * 8) + ", %s::vector)"
102+
103+
with conn.cursor() as cur:
104+
execute_values(cur, query, rows, template=template)
105+
106+
conn.commit()
107+
108+
109+
def build_row(row: Any) -> Optional[Tuple[Any, ...]]:
110+
"""Build a single row tuple for DB insertion."""
111+
doi = getattr(row, "doi", None)
112+
title: str = (getattr(row, "title", "") or "").strip()
113+
abstract: str = (getattr(row, "abstract", "") or "").strip()
114+
published_at = parse_date(getattr(row, "update_date", None))
115+
raw_authors = getattr(row, "authors_parsed", None)
116+
authors_json = authors_to_json(raw_authors)
117+
arxiv_id = getattr(row, "id", None)
118+
paper_id_external = arxiv_id or None
119+
embedding = getattr(row, "embedding", None)
120+
if embedding is None:
121+
return None
122+
embedding_literal = embedding_to_literal(embedding)
123+
124+
return (
125+
doi,
126+
"ARXIV",
127+
"PREPRINT",
128+
title,
129+
authors_json,
130+
abstract,
131+
published_at,
132+
paper_id_external,
133+
embedding_literal,
134+
)
135+
136+
137+
def ingest_shard(conn: connection, shard_path: Path, batch_size: int) -> None:
138+
"""Ingest a single parquet shard into the database."""
139+
logger.info("Ingesting %s", shard_path.name)
140+
141+
df: pd.DataFrame = pd.read_parquet(shard_path)
142+
rows: List[Tuple[Any, ...]] = []
143+
144+
for row in df.itertuples(index=False):
145+
row_tuple = build_row(row)
146+
if row_tuple is None:
147+
continue
148+
rows.append(row_tuple)
149+
if len(rows) >= batch_size:
150+
insert_rows(conn, rows)
151+
rows.clear()
152+
153+
154+
def main(data_dir: Path, batch_size: int) -> None:
155+
"""Main entrypoint for bulk ingestion."""
156+
conn = psycopg2.connect(**DB_CONFIG)
157+
try:
158+
ensure_doi_index(conn)
159+
for shard in iter_shards(data_dir):
160+
ingest_shard(conn, shard, batch_size=batch_size)
161+
finally:
162+
conn.close()
163+
164+
165+
if __name__ == "__main__":
166+
script_dir = os.path.dirname(os.path.abspath(__file__))
167+
default_data_path = os.path.join(script_dir, "arxiv-vector-embeddings")
168+
169+
parser = argparse.ArgumentParser(description="Bulk ingest arXiv parquet shards.")
170+
parser.add_argument("--data-dir", type=str, default=default_data_path)
171+
parser.add_argument("--batch-size", type=int, default=1000)
172+
args = parser.parse_args()
173+
174+
main(Path(args.data_dir), args.batch_size)

0 commit comments

Comments
 (0)