Short answer: for semantic search over messy B2B catalog PDFs, I would spend the latency budget during ingestion, preserve page-level evidence, and keep the query path to one embedding plus one vector search; if a catalog must become searchable immediately after every upload, I would choose simpler deterministic chunks and defer enrichment.
The decisive constraint is not the PDF parser or the language of the upload service. It is the quality-versus-latency boundary: descriptions often separate a product name, dimensions, compatibility notes, and exclusions across headings or pages, while a buyer expects one coherent result. A fast pipeline that loses those relationships produces plausible but unauditable answers. A sophisticated pipeline that blocks publication for too long fails a different operational requirement.
For a Node.js RAG service, I treat the upload worker, embedding adapter, and Postgres repository as replaceable components. The durable contract is the evidence record. Each record needs a stable document version, a stable chunk identity, normalized text, page bounds, catalog identifiers, and the embedding configuration that produced its vector. That is the smallest design I trust for retries, reconciliation, and citations.
How should Node.js RAG handle PDF upload chunking metadata and citations?
The Node.js boundary should accept an upload, hash the original bytes, write an immutable document version, and enqueue ingestion under an idempotency key derived from the tenant, catalog, and file hash. Parsing and embedding can happen asynchronously. Search should read only a version whose ingestion status was committed as complete; otherwise a retry can expose half a catalog, which is especially awkward when two chunks describe the same SKU differently.
Chunking comes after extraction, not during transport. Keep page boundaries from the parser, normalize repeated headers and whitespace without rewriting the source, then group adjacent blocks around product structure. A heading followed by a short specification table usually belongs together. An unrelated product heading is a hard boundary. If a product description exceeds the embedding input limit, split it into deterministic windows and retain the same product identifier and precise page span on every child. The embedding guide is the authority for the selected model's input constraints; don't freeze an assumed limit into the domain model.
I use metadata for two distinct jobs. Filter metadata narrows the candidate set before ranking: tenant, catalog version, locale, availability state, or an exact SKU. Evidence metadata explains the result afterward: source filename, page range, chunk identifier, content hash, and extraction version. Mixing these jobs into one unversioned JSON object makes migrations deceptively easy and audits painfully vague.
The write path should be boring. Really boring.
The following Go contracts illustrate the storage boundary even when the public upload handler is written in Node.js. They avoid binding the evidence model to a PDF library or embedding provider, and they make idempotency visible rather than relying on queue delivery semantics.
package catalog
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
)
type Page struct {
Number int
Text string
}
type Chunk struct {
ID string
TenantID string
CatalogID string
DocumentVersion string
ProductID string
PageStart int
PageEnd int
Text string
ContentHash string
EmbeddingConfig string
Vector []float32
}
type Extractor interface {
Pages(ctx context.Context, pdf []byte) ([]Page, error)
}
type Chunker interface {
CatalogChunks(pages []Page) ([]Chunk, error)
}
type Embedder interface {
Embed(ctx context.Context, texts []string) ([][]float32, error)
}
type Repository interface {
CommitVersion(ctx context.Context, idempotencyKey string, chunks []Chunk) error
}
func StableKey(parts ...string) string {
h := sha256.New()
for _, part := range parts {
fmt.Fprintf(h, "%d:%s|", len(part), part)
}
return hex.EncodeToString(h.Sum(nil))
}
CommitVersion should run in one database transaction. A unique constraint on (tenant_id, document_version, chunk_id) turns a repeated queue delivery into the same committed state, while the content hash lets a reconciliation job distinguish a legitimate new version from a retry. This is not magical exactly-once execution; it is an exactly-once effect constructed from immutable inputs, uniqueness, and a transactional visibility switch.
Deriving a quality and latency budget from the catalog
There are three useful chunking regimes, and none wins universally.
| Regime | Search quality tendency | Ingestion latency tendency | Appropriate condition |
|---|---|---|---|
| Fixed deterministic windows | Lower when product facts cross boundaries | Lowest | Rapid publication matters more than complete product context |
| Structure-aware product chunks | Higher when headings and tables extract cleanly | Moderate | Catalog layout carries useful product boundaries |
| Structure-aware chunks plus enrichment | Potentially highest for noisy descriptions | Highest | Offline publication permits validation before activation |
That comparison is deliberately qualitative. I'm not sure which regime wins for a particular catalog until it is tested against judged queries, because extraction quality and description structure determine far more than a generic benchmark can reveal. The resolving evidence is a corpus sampled from the actual uploads, including scans, repeated headers, tables, superseded SKUs, and descriptions that negate compatibility.
Build the evaluation set before tuning overlap. For each query, record acceptable products, required evidence pages, and disqualifying facts. Score retrieval separately from answer generation. Recall at a chosen candidate count tells whether the needed chunk was retrieved; citation accuracy tells whether the returned page actually supports the answer; contradiction tests catch a model that combines an accessory's compatibility with the parent product. Latency should be split into upload acknowledgement, ingestion-to-activation, query embedding, vector search, and answer generation. One aggregate percentile conceals the component that needs work.
The decision rule follows from those measurements. If structure-aware chunking materially improves judged retrieval while meeting the ingestion service-level objective, keep it. If it misses that objective, don't immediately weaken citation data. Publish a deterministic first pass, then activate a richer immutable version after background validation. Readers query one complete version at a time, so the system never blends old vectors with new metadata.
Compliance adds a limit to experimentation: raw uploads, extracted text, vectors, prompts, and query logs can fall under different retention and access policies. The applicable classification depends on the organization, contracts, data, and jurisdiction, so it cannot be inferred from the retrieval architecture alone. Make those decisions explicit with the responsible legal and security teams, and design deletion around document versions rather than trying to reconstruct which vectors came from a file months later.
Keeping pgvector retrieval reproducible
pgvector provides exact and approximate nearest-neighbor search in Postgres and supports cosine distance with the <=> operator. Start with exact search while validating semantics. Approximate indexing is an operational optimization to evaluate against the judged query set, since its settings add another recall-and-latency trade-off.
Store the vector beside the evidence identity, but keep model configuration as first-class versioned data. A vector generated under one configuration must not silently share a search space with vectors from another. The schema can enforce that separation and preserve the original text used for embedding.
package catalog
const Schema = `
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE catalog_chunks (
tenant_id text NOT NULL,
catalog_id text NOT NULL,
document_version text NOT NULL,
chunk_id text NOT NULL,
product_id text,
page_start integer NOT NULL CHECK (page_start > 0),
page_end integer NOT NULL CHECK (page_end >= page_start),
content text NOT NULL,
content_hash text NOT NULL,
embedding_config text NOT NULL,
embedding vector NOT NULL,
active boolean NOT NULL DEFAULT false,
PRIMARY KEY (tenant_id, document_version, chunk_id)
);
`
const Search = `
SELECT chunk_id, product_id, page_start, page_end, content,
embedding <=> $1::vector AS cosine_distance
FROM catalog_chunks
WHERE tenant_id = $2
AND catalog_id = $3
AND document_version = $4
AND embedding_config = $5
AND active = true
ORDER BY embedding <=> $1::vector
LIMIT $6;
`
The application should construct $1 using a pgvector-aware database adapter, not by concatenating user input into SQL. Filters for tenant and active version are correctness boundaries. Product or locale filters are query semantics and should be applied only when the request supplies them; an inferred filter can quietly erase the right answer.
For citations, return structured evidence from retrieval and keep it separate from generated prose. A result can carry chunk_id, document_version, page_start, and page_end, while the presentation layer renders something like [Catalog A, pages 12-13]. Validate every emitted citation against the retrieved set. No match means no citation, and no supporting chunk means the answer should abstain rather than manufacture provenance.
This also creates an audit trail with a useful replay boundary: the normalized query, embedding configuration, active document version, retrieved chunk IDs and distances, and final cited IDs. Don't log raw content by default merely because it is convenient. Retention and access controls still apply.
The catch and a compact rollout
The quality-first design is not suitable when uploads must become searchable synchronously, when PDFs are mostly images but no approved extraction path exists, or when the team cannot operate versioned ingestion and reconciliation. In the first case, stick with deterministic windows and explicit page metadata. In the second, stop at upload and establish an approved extraction process rather than pretending empty text is searchable. In the third, a plain lexical search over normalized catalog fields may be easier to reason about than an embedding pipeline with weak ownership.
Roll out with one shadow catalog version. Ingest it idempotently, compare retrieval against the judged queries, verify that every citation resolves to an immutable page range, then switch the active-version pointer in a transaction. Keep the previous version available for rollback until its retention deadline, and reconcile counts and hashes after activation.
Only then tune latency.
This order preserves the central trade-off: improve catalog answer quality during asynchronous ingestion, but keep online semantic search small, measurable, and supported by evidence that can be replayed. The architecture is useful because its claims are inspectable, not because a particular parser, runtime, or embedding service is fashionable.
Top comments (0)