Spaces:
Running
Running
File size: 7,733 Bytes
b861f87 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | #!/usr/bin/env python3
"""
Build the local metadata sidecar from Turso.
Why this exists
---------------
Turso's `papers` table has exactly one index (`arxiv_id`), so it supports point
lookups and nothing else. Measured against the live DB:
SELECT 1 -> 187 ms
SELECT COUNT(*) -> 834 ms
SELECT MIN(update_date), MAX(update_date) -> TIMES OUT at 100 s
trending (LIKE + ORDER BY) -> SCAN papers + TEMP B-TREE
and a 50-id metadata fetch on the search hot path costs ~4.7 s round trip.
This script mirrors the table into a local SQLite file that ships inside the
Docker image, with the indexes Turso lacks. That buys two things:
1. Hot-path metadata lookups become local reads (microseconds, no network).
2. Query patterns that are currently impossible become cheap — trending by
category, recency filters, "most cited in cs.CL since 2024".
Usage
-----
export TURSO_URL=... TURSO_DB_TOKEN=...
python scripts/build_metadata_sidecar.py --out data/metadata.sqlite
# smaller/faster build without abstracts (~250 MB instead of ~1.1 GB)
python scripts/build_metadata_sidecar.py --out data/meta.sqlite --no-abstracts
Resumable: re-running continues from the highest rowid already imported.
"""
from __future__ import annotations
import argparse
import json
import os
import sqlite3
import sys
import time
import urllib.error
import urllib.request
TOTAL_ESTIMATE = 1_600_000
SCHEMA = """
CREATE TABLE IF NOT EXISTS papers (
arxiv_id TEXT PRIMARY KEY,
title TEXT,
authors TEXT,
abstract_preview TEXT,
categories TEXT,
primary_topic TEXT,
update_date TEXT,
citation_count INTEGER,
influential_citations INTEGER
);
-- One row per (paper, arXiv code). Turso can only do LIKE '%cs.CL%' against a
-- space-separated string, which forces a full scan; this makes category
-- filtering index-driven.
CREATE TABLE IF NOT EXISTS paper_categories (
code TEXT NOT NULL,
arxiv_id TEXT NOT NULL,
citation_count INTEGER,
update_date TEXT
);
CREATE TABLE IF NOT EXISTS build_state (
key TEXT PRIMARY KEY,
value TEXT
);
"""
INDEXES = """
CREATE INDEX IF NOT EXISTS idx_papers_update_date ON papers(update_date);
CREATE INDEX IF NOT EXISTS idx_papers_citations ON papers(citation_count DESC);
-- Covers the trending query: filter by code, already ordered by citations.
CREATE INDEX IF NOT EXISTS idx_pc_code_cit ON paper_categories(code, citation_count DESC);
CREATE INDEX IF NOT EXISTS idx_pc_code_date ON paper_categories(code, update_date DESC);
CREATE INDEX IF NOT EXISTS idx_pc_arxiv ON paper_categories(arxiv_id);
"""
def turso_query(url: str, token: str, sql: str, timeout: int = 120):
payload = json.dumps({"requests": [
{"type": "execute", "stmt": {"sql": sql}},
{"type": "close"},
]}).encode()
req = urllib.request.Request(
f"{url}/v2/pipeline", data=payload,
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
data = json.loads(r.read())
res = data["results"][0]
if res.get("type") == "error":
raise RuntimeError(str(res.get("error"))[:300])
rd = res["response"]["result"]
return [[c.get("value") for c in row] for row in rd["rows"]]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True, help="output sqlite path")
ap.add_argument("--chunk", type=int, default=2000, help="rows per request")
ap.add_argument("--no-abstracts", action="store_true",
help="omit abstract_preview (much smaller file)")
ap.add_argument("--max-rowid", type=int, default=1_700_000)
args = ap.parse_args()
url = os.environ.get("TURSO_URL", "").rstrip("/")
token = os.environ.get("TURSO_DB_TOKEN", "")
if not url or not token:
print("TURSO_URL and TURSO_DB_TOKEN must be set", file=sys.stderr)
return 2
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
con = sqlite3.connect(args.out)
con.executescript(SCHEMA)
# Bulk-load pragmas. Safe: if the build dies we just re-run it.
con.execute("PRAGMA journal_mode=OFF")
con.execute("PRAGMA synchronous=OFF")
con.commit()
cur = con.execute("SELECT value FROM build_state WHERE key='max_rowid_done'")
row = cur.fetchone()
start = int(row[0]) if row else 0
if start:
print(f"resuming from rowid {start:,}")
abstract_col = "''" if args.no_abstracts else "abstract_preview"
chunk = args.chunk
lo = start + 1
imported = 0
t_start = time.time()
while lo <= args.max_rowid:
hi = lo + chunk - 1
sql = (
"SELECT rowid, arxiv_id, title, authors, "
f"{abstract_col}, categories, primary_topic, update_date, "
"citation_count, influential_citations "
f"FROM papers WHERE rowid BETWEEN {lo} AND {hi}"
)
try:
rows = turso_query(url, token, sql)
except (urllib.error.URLError, RuntimeError, TimeoutError) as e:
if chunk > 250:
chunk = max(250, chunk // 2)
print(f"\n chunk too big/slow ({str(e)[:60]}); retrying at {chunk}")
continue
print(f"\n chunk {lo}-{hi} failed permanently: {str(e)[:120]}")
lo = hi + 1
continue
if rows:
papers = []
cats = []
for r in rows:
(_rid, aid, title, authors, abstract, categories,
topic, udate, cit, infl) = r
if not aid:
continue
try:
cit_i = int(cit) if cit is not None else 0
except (TypeError, ValueError):
cit_i = 0
try:
infl_i = int(infl) if infl is not None else 0
except (TypeError, ValueError):
infl_i = 0
papers.append((aid, title, authors, abstract, categories,
topic, udate, cit_i, infl_i))
for code in str(categories or "").split():
cats.append((code, aid, cit_i, udate))
con.executemany(
"INSERT OR REPLACE INTO papers VALUES (?,?,?,?,?,?,?,?,?)", papers)
con.executemany(
"INSERT INTO paper_categories VALUES (?,?,?,?)", cats)
imported += len(papers)
con.execute(
"INSERT OR REPLACE INTO build_state VALUES ('max_rowid_done', ?)",
(str(hi),))
con.commit()
elapsed = time.time() - t_start
rate = imported / elapsed if elapsed > 0 else 0
pct = 100 * min(hi, TOTAL_ESTIMATE) / TOTAL_ESTIMATE
eta = (TOTAL_ESTIMATE - imported) / rate / 60 if rate > 0 else 0
print(f"\r rowid {hi:,} imported {imported:,} "
f"({pct:5.1f}%) {rate:,.0f} rows/s ETA {eta:5.1f} min",
end="", flush=True)
if not rows and hi > TOTAL_ESTIMATE:
break
lo = hi + 1
print("\nbuilding indexes...")
con.executescript(INDEXES)
con.commit()
print("running ANALYZE...")
con.execute("ANALYZE")
con.commit()
con.execute("PRAGMA journal_mode=DELETE")
con.close()
size_mb = os.path.getsize(args.out) / 1e6
print(f"done: {imported:,} papers -> {args.out} ({size_mb:,.0f} MB)")
return 0
if __name__ == "__main__":
sys.exit(main())
|