The instinct is to hand the whole reference list to a model and ask for an array of citation objects. On a list of eighty entries that produces seventy-three, with two merged and five hallucinated into tidiness. The fix is to make segmentation a separate, deterministic step.
Two stages, and why the first one is harder
Parsing one reference string into author, year, title and venue is a task current models do well. Deciding where one reference ends and the next begins is a task they do badly, because the boundary is typographic rather than semantic: a hanging indent, a numeric label, a line break that is either a wrap or a separator depending on the column width.
Splitting the work also gives you a count to assert against. If the list is numbered 1 to 84 and you segmented 81 entries, you know the parse is wrong before you have looked at a single field. A single-call extraction gives you no such handle — a merged pair looks identical to a list that was three shorter.
Step 1: segment the list
Three reference-list styles cover almost everything, and each has a different boundary signal:
- Numbered (Vancouver, IEEE). Each entry begins with
1.or[1]. Boundary detection is a regex, the sequence is monotonic, and you get the assertion for free. - Author-date (APA, Harvard, Chicago author-date). No labels. Entries are separated by a hanging indent — the first line starts at the margin and continuations are indented — which is invisible in a flat text stream and obvious in the layout.
- Note-bibliography (Chicago notes). Also unlabelled, also hanging-indented, and additionally uses a three-em dash for a repeated first author, which is the case discussed below.
For the unlabelled styles, segment on the indent rather than on the text. If you have coordinates from the PDF, an entry starts at every line whose left edge is at the block minimum and continues through every line indented further. If you do not have coordinates, a reasonable proxy is a line that begins with a capital letter followed by a comma-and-initial pattern, but it is a proxy and it will miss entries beginning with an institutional author or a title.
function segmentNumbered(text) {
// Split before a line-initial "1." / "[1]" / "1)".
const parts = text.split(/\n(?=\s*(?:\[\d+\]|\d+[.)])\s)/);
return parts.map((p) => p.trim()).filter(Boolean);
}
function segmentByIndent(lines) {
// lines: [{ text, x }] from the PDF text layer, one entry per visual line.
const margin = Math.min(...lines.map((l) => l.x));
const out = [];
for (const line of lines) {
if (Math.abs(line.x - margin) < 1.5) out.push(line.text);
else out[out.length - 1] += " " + line.text;
}
return out;
}
The tolerance of 1.5 points matters. Text-layer x-coordinates are not exact — kerning and the width of an opening quotation mark shift the reported origin — and a strict equality test puts every reference beginning with a quotation mark into the previous entry.
The conventions that destroy authorship
Two style rules silently delete the field you most want, and neither is recoverable from the entry in isolation.
The repeated-author dash. In Chicago-style bibliographies, consecutive works by the same author replace the name with a three-em dash: ———. 2019. A parser that reads each entry independently records the author as a dash, or as empty, for every entry after the first. The entry is only parseable in the context of its predecessor, which means segmentation order is load-bearing and you cannot parallelise the parse across a shuffled list. Carry the previous entry’s author forward, and note that the dash can appear in several widths — em dash repeated three times, a single three-em dash character, or a run of hyphens in a plain-text rendering.
Et al. truncation. Most styles abbreviate long author lists after a threshold. The information is gone from the page; no amount of prompting recovers it. The right response is to record what is there and mark the list as truncated, so that a downstream match on “same author list” does not fail against the full record from a lookup:
{ "authors": [{ "family": "Rivera", "given": "A." }], "authors_truncated": true }
Two further traps are worth pre-empting. Page ranges use an en dash and often an elided upper bound — 1123–31 means 1123 to 1131, not 1123 to 31 — so expansion is a rule, not a parse. And a trailing period is part of the sentence, not part of the DOI, which is the single most common way a greedy identifier regex captures a character that makes the DOI unresolvable.
Step 2: parse each entry
With entries isolated, per-entry parsing is a constrained structured output task. Use a strict schema so the model cannot invent a field, and give it an explicit entry_type enum — a book chapter, a conference paper and a preprint have genuinely different fields, and forcing all three into a journal-article shape loses the container title. Designing that enum so an unfamiliar entry type has somewhere to go is the subject of schema design for unseen variants.
const CITATION_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["entry_type", "authors", "title", "raw"],
properties: {
entry_type: {
enum: ["journal_article", "book", "book_chapter", "conference_paper",
"preprint", "thesis", "report", "webpage", "other"],
},
authors: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["family"],
properties: { family: { type: "string" }, given: { type: "string" } },
},
},
authors_truncated: { type: "boolean" },
title: { type: "string" },
container_title: { type: "string" }, // journal, book or proceedings
year: { type: "integer" },
volume: { type: "string" },
issue: { type: "string" },
pages: { type: "string" },
doi: { type: "string" },
raw: { type: "string" }, // the entry exactly as segmented
},
};
Keeping raw is not optional. It is what makes every later disagreement resolvable without going back to the PDF, and it is what you diff against when you change the prompt. Whether your provider enforces this schema or merely encourages it differs by provider and by mode — see structured output support and JSON mode versus structured outputs, because with additionalProperties: false the difference between the two is the difference between a guarantee and a suggestion.
The whole thing
- Extract the reference-list region with its line coordinates. The section heading is usually “References”, “Bibliography” or “Works Cited”; take everything from it to the next heading or the end of the document.
- Detect the style. If more than 80% of lines at the block margin start with a numeric label, treat the list as numbered; otherwise segment by indent.
- Segment. Assert the count against the highest numeric label if there is one, and stop the run if they disagree by more than one.
- Walk the segmented entries in order and expand any repeated-author dash from the previous entry before parsing.
- Parse each entry against the schema, in parallel, one call per entry. Set
rawfrom the segmenter rather than letting the model echo it back. - Post-validate: check the DOI syntactically, recompute any ISBN check digit, and reject a year outside a plausible range with a date field validation rule. Then resolve the DOIs you found and prefer the registry’s metadata over your parse wherever the two disagree.
Step 6 is where the accuracy actually comes from. Your parse of an entry is a guess at what the citing author typed; the registry record is what the cited work is. Treat the parse as a lookup key and the lookup as the answer, and the whole pipeline becomes far more robust than any amount of prompt tuning would make it.
A reference-list backlog is one call per entry, which is tens of thousands of small requests with a hard cost ceiling you want to know before you start rather than after. Running them through a gateway gives you per-request cost attribution and a spend cap on the job, and lets a rate-limit rejection from one provider fall through to another without the batch driver knowing which provider it is talking to.
Top comments (0)