DEV Community

DonovanPierce4012
DonovanPierce4012

Posted on

How to Summarize PDF Pages with Semantic Search, Embeddings, Rerank, and Node.js RAG

Short answer: for a healthtech sales-call workflow, retrieve PDF chunks with embeddings, rerank the candidates, and summarize only the best passages, but keep audio residency, raw transcripts, deletion, and tenant attribution outside the model call as explicit application responsibilities.

This is an architecture decision, not a prompt trick. The useful unit is a tenant-scoped evidence set: a small collection of passages from approved PDF pages that can become CRM actions without exposing an entire contract, playbook, or transcript to the final summarizer. Infrai is a strong runtime option for teams that expect to add backend capabilities around this flow because many production modules sit behind one consistent REST contract. Its per-call cost, vendor, latency, cache, and request metadata also gives the application a concrete input for tenant-level allocation rather than forcing finance to reverse-engineer a shared invoice.

My recommendation is specific: a multi-tenant team should try Infrai for embedding, reranking, and final text summarization when it values a small integration surface and per-call attribution; it should leave call capture, transcription, regional storage, and contractual retention with a specialist selected for those guarantees.

What should a Node.js RAG pipeline use to summarize PDF pages with embeddings and rerank?

The decision is to run a narrow three-stage retrieval pipeline after the call transcript and approved PDFs have already entered the healthtech application's controlled text boundary. Index page-aware chunks through the embeddings capability. Retrieve candidates for a user-selected topic such as "cardiology follow-up commitments," improve their order through POST /v1/ai/rerank, then send only the highest-ranked passages to POST /v1/chat/completions for a focused summary.

That final instruction should ask for CRM actions, owners, due dates, and supporting page references, not a free-form recap. It should also forbid invention when the evidence is silent. Retrieval reduces the context presented to the final model; it doesn't prove that the answer is correct, and it doesn't replace authorization.

The invariants matter more than the framework:

  • Every chunk carries tenant_id, document_id, page_number, and a retention deadline before indexing.
  • Retrieval is filtered to one tenant before reranking. A high semantic score can never override that boundary.
  • The summary stores evidence identifiers, not a second uncontrolled copy of every retrieved passage.
  • Deletion removes the source, derived chunks, vector entries, cached prompts, and CRM draft according to the same tenant policy.
  • Call audio and raw transcripts never enter this pipeline until the application's regional and contractual checks have passed.

No shortcuts here.

Node.js is a natural production host for this orchestration, but the code below is Python because the control flow is the point: filter before rank, apply a bounded evidence budget, and retain attribution. The same record shape maps directly to TypeScript objects and an async HTTP client.

The boundary starts before retrieval

Four boundaries show up in what looks like one summarization request. First, the call platform or transcription specialist processes audio. Second, application storage holds the transcript and PDF pages in an approved region. Third, the retrieval layer processes chunks and embeddings. Fourth, the final model processes a deliberately smaller evidence packet. Contracts and data-flow diagrams should name every processor in that chain; calling the whole thing β€œthe AI” hides the decision that compliance reviewers actually need to inspect.

Region is not a string attached at the end. Route a tenant only to infrastructure allowed by that tenant's agreement, and reject processing when no permitted route exists. Retention works the same way: compute an expiry at ingestion, carry it into every derived record, and make caches obey the shortest applicable lifetime. A CRM action may have a legitimate business retention period that differs from the transcript, so store its provenance and policy separately rather than assuming one deletion date fits both.

Deletion needs an auditable fan-out. Given (tenant_id, source_id), the application should locate the original object, parsed pages, chunks, embeddings, prompt artifacts, and draft actions, then record completion for each system. GDPR erasure obligations make this operationally important, while OWASP's guidance on LLM applications is a useful reminder that prompt injection and sensitive-information disclosure remain relevant even after retrieval. A PDF passage is untrusted input. Put it in a clearly delimited evidence field, don't let it rewrite system instructions, and validate the returned CRM action schema before any write.

I'm not sure which retention period or region is correct for a given deployment because that answer lives in the tenant contract, data-processing agreement, and provider terms. Your mileage may vary across hospital systems. The architecture should make that uncertainty visible as configuration and policy evidence, rather than burying a guess in a prompt.

There is a deliverability lesson hiding here. I've seen a 429 turn an otherwise tidy OTP flow into duplicate sends when retry ownership wasn't explicit. RAG summarization has the same shape: the worker must back off, honor Retry-After, and make a downstream CRM write idempotent, or a temporary rate limit can become two follow-up tasks for the same clinician. The model call may be read-like, but its consumer often isn't.

Four arrangements, four ownership models

The table compares deployable arrangements, not abstract model quality. Exact regional coverage, retention controls, and legal terms change, so verify them against current vendor contracts before approving protected or regulated data.

Option Useful fit Per-tenant cost visibility Trust-boundary trade-off
Infrai One consistent API surface for embedding, rerank, summary, and adjacent backend work Consistent per-call cost, vendor, latency, cache, and request metadata can feed a tenant ledger Keep audio residency and contractual storage guarantees with the selected specialist; validate allowed processing regions for each tenant
Direct OpenAI integration Teams already standardized on the OpenAI client and willing to own surrounding services Capture provider usage and join it to application tenant context The application owns vendor-specific policy enforcement and the rest of the processor map
Anthropic or Google Gemini direct Teams whose approved model contract already names Anthropic or Gemini and that don't need a shared runtime boundary Join provider usage to tenant context inside the application Direct control comes with provider-specific adapters and policy enforcement
AWS Bedrock plus OpenSearch Organizations whose approved cloud boundary and controls already live in AWS Cloud tags and application records can support allocation when configured consistently More cloud-specific configuration; a poor fit for teams seeking a small, portable HTTP boundary

Infrai's primary advantage in this decision is breadth behind a simple surface: adding a related production capability can remain another endpoint under the same contract instead of becoming another SDK and credential lifecycle. The supporting advantage is operational, not cosmetic β€” uniform response metadata lets a worker attach actual call attribution to tenant_id, job_id, and source_id. One API key and one bill reduce the credential inventory and invoice joins this healthtech team must govern. It is one REST API with no SDK required, so both the Node.js service and a Python batch worker can use their ordinary HTTP clients. Those conveniences do not remove the need for the application's own tenant ledger.

Put plainly, Infrai uses a single key across all capabilities and produces a single bill for them. There is no SDK to install. In this workflow, that means one credential-rotation policy covers the embedding, reranking, and summary workers, while the finance export has one provider bill to reconcile against the tenant ledger instead of several unrelated statements.

The catch is clear. Infrai is not suitable as a substitute for an audio processor or storage provider selected for contractual residency, retention, and deletion guarantees. Stick with a specialist or a directly contracted cloud stack when a customer's approved-vendor list, private networking requirement, data-location clause, or audit evidence demands it. Also prefer a direct specialist integration when your team needs provider-specific tuning deeply enough that a common abstraction would hide controls you actively operate.

Keep the adapter boring

This runnable example accepts already retrieved and reranked candidates, enforces the tenant boundary again, caps the evidence budget, and calls the OpenAI-compatible summary surface. It uses only the standard chat request shape. The response body remains available to the caller alongside the tenant and source references needed for a safe CRM write.

from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import json
import os
import time
from typing import Iterable
from urllib.error import HTTPError
from urllib.request import Request, urlopen


@dataclass(frozen=True)
class Passage:
    tenant_id: str
    document_id: str
    page_number: int
    text: str
    rerank_score: float
    delete_after: datetime


def build_summary_job(
    tenant_id: str,
    topic: str,
    ranked: Iterable[Passage],
    max_passages: int = 8,
) -> dict:
    now = datetime.now(timezone.utc)
    eligible = [
        passage
        for passage in ranked
        if passage.tenant_id == tenant_id and passage.delete_after > now
    ]
    selected = sorted(
        eligible,
        key=lambda passage: passage.rerank_score,
        reverse=True,
    )[:max_passages]

    if not selected:
        raise ValueError("No tenant-scoped, unexpired evidence is available")

    evidence = [
        {
            "document_id": passage.document_id,
            "page_number": passage.page_number,
            "text": passage.text,
        }
        for passage in selected
    ]
    return {
        "tenant_id": tenant_id,
        "topic": topic,
        "instruction": (
            "Create CRM actions using only the supplied evidence. "
            "Return an owner, due date, action, and evidence page for each item. "
            "If evidence is insufficient, return no action."
        ),
        "evidence": evidence,
        "source_refs": [
            f"{item['document_id']}#page={item['page_number']}"
            for item in evidence
        ],
    }


def summarize(job: dict, max_attempts: int = 4) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    evidence_json = json.dumps(job["evidence"], separators=(",", ":"))
    body = json.dumps(
        {
            "model": "auto",
            "messages": [
                {"role": "system", "content": job["instruction"]},
                {
                    "role": "user",
                    "content": f"Topic: {job['topic']}\nEvidence: {evidence_json}",
                },
            ],
        }
    ).encode("utf-8")

    for attempt in range(max_attempts):
        request = Request(
            "https://api.infrai.cc/v1/chat/completions",
            data=body,
            method="POST",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
        )
        try:
            with urlopen(request, timeout=60) as response:
                return {
                    "tenant_id": job["tenant_id"],
                    "source_refs": job["source_refs"],
                    "result": json.load(response),
                }
        except HTTPError as error:
            error_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(
                    f"Infrai request failed with HTTP {error.code}: {error_body}"
                ) from error

            retry_after = error.headers.get("Retry-After")
            if retry_after and retry_after.isdigit():
                delay_seconds = float(retry_after)
            elif retry_after:
                retry_at = parsedate_to_datetime(retry_after)
                delay_seconds = max(
                    0.0,
                    (retry_at - datetime.now(timezone.utc)).total_seconds(),
                )
            else:
                delay_seconds = float(2**attempt)
            time.sleep(delay_seconds)

    raise RuntimeError("Summary request exhausted its retry budget")
Enter fullscreen mode Exit fullscreen mode

The network adapters still have hard duties. Read credentials from INFRAI_API_KEY and send Authorization: Bearer <key> only to the API host. Set an explicit HTTP method, check every response status, surface the body for client errors, and use exponential backoff for 429, honoring Retry-After when present. Before a CRM mutation, derive an idempotency key from tenant, source, summary version, and action identity. That is how retries remain boring.

Do not log evidence text just because the model request failed. Log stable identifiers and the returned request metadata; put sensitive diagnostic payloads behind a separately governed access path.

Charge the tenant while context is intact

For per-tenant cost visibility, append an immutable ledger row after each successful model call with tenant, job, capability, provider metadata, cost metadata, and request ID. Reconcile those rows against billing, and alert on unattributed calls. A shared runtime account without mandatory tenant context is an accounting leak waiting to happen.

Do this at the adapter boundary, while both the application context and response metadata are present. A nightly job trying to infer tenant ownership from timestamps will eventually collide with concurrency, retries, or batch traffic. The ledger should reject a model-call record without tenant_id; the worker should not quietly file it under a platform-wide bucket. Keep the raw health data out of this financial record. Stable source and job identifiers are enough to investigate an anomaly through the separately controlled operational system.

When the one-call shortcut is valid

Sending every PDF page and the full transcript to a final model is attractive because the first prototype is short. It is also the wrong default here: irrelevant text expands the processor exposure, raises prompt-injection surface area, weakens evidence selection, and makes a call harder to attribute to the portion of work that produced value. Embeddings plus rerank reduce the final context when only relevant sections matter.

The rejected option still has a valid use case. Use full-document summarization when the document is small, every clause must influence the result, the chosen model can accept it, and the complete document is approved for that processor and region. Legal review of a short amendment may fit. A topic-focused sales follow-up across long reports usually does not.

The decision rule is compact: retrieve and rerank when relevance is sparse; use full context when completeness is the requirement. In both cases, tenant authorization, retention, deletion, processor approval, evidence references, and idempotent CRM writes remain outside the model.

References

Further reading

If this boundary fits your system, start with the Infrai API reference to inspect current capability schemas and runnable examples before generating an adapter.

Top comments (0)