All work
Case study 01NodusPersonal project · 2026

Twenty papers, one rate limit

Building a research assistant that reads in parallel without getting itself banned, and still shows its working.

PythonLangGraphFastAPI asyncPostgres + pgvectorGeminiSemantic ScholararXivWebSocketsCloud RunVercel
A run
1 question → 20 papersclaims, clusters, then a three-axis report
Concurrency
up to 10 in flightevery outside call paced
Cost of a run
~60–70 LLM callsabout 5 minutes on Gemini's free tier
Failure model
one paper, never the runa dead PDF degrades that unit only
Role
Solodesign, build and deploy

The question behind it

A summary of twenty papers takes a minute. Defending it takes a week. The reviewer always asks the same three things: where did this claim come from, who disagrees, and how strong is the evidence? Nodus is built to answer those three questions, which it calls lineage, disagreement and quality, for every claim in its report.

To do that it can't just summarise abstracts. It has to actually read each paper: fetch it, split it into sections, pull out the individual claims, embed them, and only then compare claims across papers.

FETCHSPLITEXTRACT CLAIMSEMBEDCOMPAREREPORTlineage · disagreement · qualityPER PAPERACROSS PAPERS →
Fig. 1 · What reading a paper meansEvery claim keeps its source

The slow, correct version

The first pipeline did exactly that, one paper at a time. It was correct, and slow by construction: every paper waited for the one before it, and almost all of the waiting was network time. Fetching a PDF, calling the model and calling the embedding API were each fast to ask for and slow to answer.

Reading twenty papers is an embarrassingly parallel job. So: read them all at once.

Going parallel broke three things at once

asyncio.gather over twenty papers worked for about one run.

01 · The database

One session, twenty tasks

Twenty coroutines shared one SQLAlchemy AsyncSession, which is not safe across concurrent tasks. The fix was structural: every paper task, and every pipeline stage, opens its own session.

02 · The APIs

Dozens of requests in one second

Semantic Scholar allows one request per second, even with a key. Gemini's free tier has a per-minute ceiling, metered separately for embeddings. The fan-out failed together, and retried together.

03 · The PDFs

The long tail, every run

At twenty papers per run the long tail showed up every time: paywalled, missing, scanned. Some papers simply had nothing to read.

Bounded, then paced

Two different limits, two different mechanisms. This distinction is the heart of the fix.

CAPPEDasyncio.Semaphore(10) · how many at oncePACEDone start per interval · how often429retry after the server's delay + jitter
Fig. 2 · Two limits, two mechanismsHow many at once ≠ how often
  • Capped: an asyncio.Semaphore limits how many papers are in flight (default 10). That protects memory, connections and the database.
  • Paced: a separate limiter controls how often a call may start. A cap alone doesn't enforce a rate: four calls that each take two seconds is still 120 requests a minute. Chat and embeddings each get their own pacer, because Google meters them separately.
  • Refusals are respected: a 429 is retried after the delay the API itself attaches, plus jitter, so twenty refused calls don't all come back at the same instant.
  • Semantic Scholar is throttled in-process to one call every 1.1 seconds. If relevance search is refused, retrieval falls back to bulk search and remembers that choice for the rest of the process.

Sending less

The cheapest call is the one you don't make.

  • Papers are global, not per-query. They're de-duplicated on their Semantic Scholar ID, and normalisation and extraction are reused across questions. A paper read once is never read again.
  • Embeddings are cached per claim, keyed by model, so switching provider throws away old vectors rather than comparing across incompatible spaces.
  • The extractor gets the results and conclusions, not the whole paper. Study design, population and sample size arrive as a compact context block.
  • A paper with no abstract, TLDR or full text is skipped, not sent to the model.
Net resulta twenty-paper run ≈ 60–70 model calls, paced to roughly five minutes on the free tier

The long tail, handled

A missing PDF shouldn't sink a run. Nodus walks down a short ladder, and says honestly where it stopped.

PUBLISHER PDFwhere it really livesARXIVpaced at 3 s a callABSTRACTand say soSKIPnothing to readeach step only if the one before came back empty
Fig. 3 · The long tail, handledA failure costs one paper

A failure only costs one paper. A dead PDF, an unparseable paper or one failed cluster analysis degrades that unit only. The run finishes, and the report states what it couldn't read.

Five minutes that feel alive

Five minutes is long to stare at a spinner. So progress streams over a WebSocket: each paper as it is read, each cluster as it forms. You watch the evidence arrive, and the wait becomes part of the product.

From many claims to one judgement

With the reading solved, the hard problems became judgement.

DEFAULT THRESHOLDTUNED FOR THIS MODEL75% in one clusterdistinct clusters
Fig. 5 · One threshold doesn't fit every model169 claims · 75% in one cluster at default
  • Clustering without knowing how many clusters. Greedy leader clustering with running centroids. The similarity threshold has to be tuned per embedding model.
  • The chat is grounded or it refuses. It answers from the report only, and says covered: false when the answer isn't there.
  • Human edits win. A cluster you edit is pinned, and re-running the analysis never overwrites it.
40%design20%sample size20%corroboration20%confidence− contradiction penalty ≤ 0.15a person can override the tier
Fig. 4 · Quality is a formula, not a vibeEvery input shown · overridable

Honest limits

Lineage

Approximate, and says so

Semantic Scholar's search endpoints don't return citation links, so lineage is rebuilt from publication dates and stance. The output says so.

Progress

In-process, by design

Correct for a single API worker. Scaling out would mean Redis pub/sub behind the same interface.

What it taught me

Four things I kept.

01

Concurrency has two limits.

"How many at once" and "how often" are different, and you need both.

02

Respect the refusal.

Retry when the server says to, with jitter, and remember which endpoints are closed.

03

The fastest request is the one you cache.

Global papers and model-keyed embeddings did more than any amount of parallelism.

04

Partial results, stated honestly,

beat a failed run.

Got a hard problem?

Let's talk about yours.