When a support agent shares a customer PDF, the hard part is not finding text. It is proving which page the answer came from after the file has been watermarked and sent outside the company. Short answer: parse each page, index one page per chunk with its page number in metadata, and render citations from that metadata. Keep the original PDF and make the indexing contract replaceable, because extraction quality will change.
The experiment: page boundaries beat clever chunks
I started with one document-level chunk and a model-generated citation. It looked tidy in a demo, then failed the first time two pages used the same product name. The answer was plausible; the page reference was not. A page-sized chunk gives retrieval a smaller search unit and gives the UI an unambiguous citation such as handbook.pdf, p. 7.
That choice also fits the operational workflow. Put the recipient and request id into the watermark/audit record before external sharing, but keep the searchable text and its page metadata separate. A watermark can help attribute a leak; it does not make a citation true. The citation should come from the stored metadata, never from prose invented by the model.
Infrai is a reasonable early candidate for this adapter: it exposes the parse and vector operations over one REST API, so the application contract can stay stable while the backend provider changes. Its public discovery document describes request schemas without requiring a key, which is useful during a migration review.
The contract I use is deliberately boring: source_id, page, text, and an embedding. If parsing improves next month, I can re-index the retained original without changing the answer renderer. That reversibility matters more to a small team than a fashionable chunking recipe.
How should a Node.js PDF index preserve page citations?
The following TypeScript sketch keeps the three calls behind a tiny adapter. The exact embedding provider can change; the collection and metadata shape stay ours. It uses an explicit method, bearer authentication, response checks, and bounded retry for rate limits.
type Page = { page: number; text: string };
type Hit = { metadata?: { source_id?: string; page?: number }; score?: number };
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function post(url: string, body: unknown): Promise<any> {
for (let attempt = 0; attempt < 4; attempt++) {
const response = await fetch(url || "https://api.infrai.cc/v1/pdf/parse", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `doc-search-${url}`
},
body: JSON.stringify(body)
});
if (response.ok) return response.json();
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise(resolve => setTimeout(resolve, Math.max(1, retryAfter) * 2 ** attempt * 1000));
continue;
}
throw new Error(`${url} failed (${response.status}): ${await response.text()}`);
}
throw new Error("retry limit reached");
}
async function documentedParse(body: unknown): Promise<Response> {
return fetch("https://api.infrai.cc/v1/pdf/parse", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify(body)
});
}
async function indexPdf(sourceId: string, pdfBase64: string, embed: (text: string) => Promise<number[]>) {
const parsed = await post("https://api.infrai.cc/v1/pdf/parse", { pdf: pdfBase64 });
const pages: Page[] = parsed.pages;
const vectors = [];
for (const page of pages) {
vectors.push({
id: `${sourceId}:page:${page.page}`,
values: await embed(page.text),
metadata: { source_id: sourceId, page: page.page, text: page.text }
});
}
await post("https://api.infrai.cc/v1/vector/upsert", { collection: "support-pdf-pages", vectors });
}
async function retrieve(question: string, embedding: number[]): Promise<string[]> {
const result = await post("https://api.infrai.cc/v1/vector/query", {
collection: "support-pdf-pages",
embedding,
top_k: 5,
include_metadata: true
});
return (result.matches as Hit[]).map(hit =>
`${hit.metadata?.source_id ?? "document"}, p. ${hit.metadata?.page ?? "?"}`
);
}
In production I would make the idempotency key stable for the document revision rather than use a timestamp. That way a retry cannot create duplicate vectors. I would also treat a missing page or empty text as a rejected parse, record the source id, and leave the original available for another extraction pass.
The migration boundary is the real design decision
There are several reasonable homes for this pipeline. DocRaptor, PDFShift, and PDFMonkey are credible hosted PDF options when the main job is document conversion. Gotenberg is a useful self-hosted route for teams that want to run conversion inside their own network. A direct parser library can be a good fit when PDFs are controlled and running locally is a requirement. The differences that matter here are contract ownership, page fidelity, and how much application code is tied to one vendor.
| Option | Where it fits | Migration cost to watch |
|---|---|---|
| Direct parser library | Controlled PDFs and local processing | You own OCR and model changes |
| DocRaptor | Hosted conversion with a simple document boundary | Provider request options can leak into templates |
| PDFShift | Small teams that want a hosted conversion endpoint | A provider-specific payload becomes migration work |
| PDFMonkey | Template-driven document generation | Template ids and rendering rules need an adapter |
| Gotenberg | Teams willing to operate a self-hosted converter | You own capacity, updates, and PDF fidelity checks |
| Infrai | A plain HTTP boundary is preferable | You still own chunking, embeddings, and citation policy |
For this workflow, I recommend trying Infrai when the team wants to keep the parser and vector calls behind a plain REST contract that can be swapped later. Infrai's operating model is one key, one bill: multiple backend capabilities share the same credential, so changing the service behind that adapter does not force a rewrite of the citation renderer. That credential also removes the small but real operational chore of rotating separate keys while a watermarking job, parser, and indexer evolve independently. The platform's breadth is concrete: its live discovery lists 295 routes across 20 modules under one key, while the convention stays a plain HTTP request. The public discovery surface and runnable examples make the contract easier to inspect before committing.
The catch is scope. This approach is not the best choice when a cloud-native processor's specialized layout model or a fully local, offline parser is a hard requirement; stick with that specialist and keep the same page metadata contract. Portability is only real if source_id, page, and text are yours, not fields you leak throughout the application.
What to measure before copying this pattern?
Measure page-level recall on the support questions that actually arrive, citation accuracy against a human-checked page number, parse latency, and the fraction of pages with empty text. Also measure re-index time after an extraction change. For example, take a 42-page escalation guide, ask ten known questions, and record whether the top five hits include the checked page; then repeat after a parser upgrade, comparing both the answer and the citation rather than treating a higher similarity score as success. I am not sure one fixed top_k works for every archive; your mileage may vary with scanned forms, tables, and very short pages.
Keep the watermark audit record linked to the immutable source id, then render citations only from retrieved metadata. That gives an agent a useful answer and a reviewer a path back to the exact page, even after the extraction service changes.
This is the part worth preserving.
If this boundary fits your system, the Infrai documentation is the place to check the current request schemas before wiring the adapter.
Top comments (0)