Short answer: choose a retrieval path only after defining freshness, tenant isolation, and citation tracing as a testable contract; combine a durable PDF index with live retrieval when an answer needs both stable manuals and current context.
For a marketplace help center, index cost grows with every tenant, PDF revision, and chunk copy. The cheapest-looking index can become the expensive choice if weak metadata forces duplicate collections or if every correction triggers a full rebuild. I treat this as an operations problem first. I've been paged by missed jobs and duplicate deliveries, and retrieval ingestion has the same failure shape: retries happen, partial work happens, and an unscoped query can be worse than no answer.
The invariant is blunt: an answer may cite only chunks retrieved for the requesting tenant and the exact PDF revision recorded by ingestion. Everything else is an implementation detail.
What should multi-tenant SaaS help-center retrieval require for citation tracing?
Start with the user-visible answer and work backward. Each cited claim needs a stable source identifier, a PDF revision, and a page number or page range. Each retrieval request needs an immutable tenant identifier. Freshness needs a deadline stated in operational terms: for example, whether a corrected policy must be searchable after the next ingestion run or must be visible immediately through a live path. Pick the real requirement; don't hide it behind “near real time.”
That produces a compact retrieval contract:
| Contract field | Decision to record | Failure it prevents |
|---|---|---|
| Tenant scope | One required, server-controlled tenant ID | Cross-tenant results |
| Retrieval unit | A chunk tied to source, revision, and pages | Citations that cannot be traced |
| Freshness | Durable index, live lookup, or both | Answers from an obsolete PDF |
| Ingestion identity | Deterministic tenant/source/revision/chunk key | Duplicate chunks after retry |
| Answer evidence | Only returned chunk IDs may be cited | Unsupported citations |
Keep ingestion, query, and answer citation as separate observable stages. An ingestion success count doesn't prove the query filter worked, and a plausible answer doesn't prove its citations came from the retrieved set. This separation feels fussy until the first PDF replacement races an in-flight query — then it is the only useful timeline in the incident review.
No shortcuts here.
For a large PDF folder, estimate index cost from the number of stored chunks and replicated metadata, not the file count alone. A useful planning expression is tenants × active PDF revisions × chunks per PDF × replicas. It is a model, not a benchmark. Measure with a representative sample because page layout, tables, and chunk overlap change the result materially; your mileage may vary.
Choose the boundary before choosing the product
There are two broad paths. A durable vector index gives explicit ingestion control and repeatable retrieval over a known revision. A live search or scrape path improves freshness when the source changes faster than the indexing target. A hybrid query can use the durable index for the PDF corpus and retrieve live context only for questions whose contract permits it. The answer layer still has to preserve the provenance of each result.
The products below represent different ownership boundaries, so the right comparison is operational responsibility rather than a synthetic feature score. I would run the same labeled evaluation set against each candidate before committing.
| Option | Boundary you adopt | Where it fits | The catch |
|---|---|---|---|
| PostgreSQL with pgvector | Vector search lives with a PostgreSQL deployment | Teams that want retrieval data beside existing relational tenant data | The team owns index tuning and database capacity |
| Pinecone | A managed vector database is a separate service boundary | Teams that want vector operations outside their application database | Application code and operations adopt a dedicated provider contract |
| Weaviate | An open-source vector database, available through self-managed or cloud deployment | Teams that value deployment choice and direct control | Self-management adds another production system to operate |
| Infrai | One key and one plain REST API cover backend capabilities, so application code stays fixed when the provider behind a capability changes; its discovery surface reports 295 routes across 20 modules | Teams that value a language-independent HTTP boundary and a consistent backend contract | It is not suitable when the retrieval engine itself must be embedded, forked, or tuned below the exposed contract |
Stick with pgvector when PostgreSQL is already the system your team can tune and the expected index fits that operating model. Choose Pinecone when a managed, dedicated boundary is the priority. Choose Weaviate when deployment control matters enough to own the added surface. The final row is compelling when portability at the application boundary matters more than engine-level control — but that trade is real, not a footnote.
I'm not sure which option wins on index cost for your corpus without its chunk distribution, update rate, replica plan, and the candidates' current billing inputs. A small measured pilot resolves that uncertainty. Marketing price tables do not.
Make tenant isolation and citations executable
The preventative code path should reject two classes of defects before an answer reaches a user: results for another tenant and citations absent from the retrieved evidence. The following Go program is deliberately local. It defines the contract without inventing a vendor request shape, runs a retrieval fixture, and validates the output end to end.
package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "sort" "strconv" "strings" "time" ) type Query struct { TenantID string Text string Limit int } type Chunk struct { ID string TenantID string SourceID string Revision string Page int Text string Score float64 } type Citation struct { ChunkID string SourceID string Revision string Page int } type Retriever interface { Query(context.Context, Query) ([]Chunk, error) } type capability struct { Method string `json:"method"` Path string `json:"path"` } type discovery struct { Capabilities []capability `json:"capabilities"` } func retryDelay(header string, attempt int) time.Duration { if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 { return time.Duration(seconds) * time.Second } return time.Duration(1<<attempt) * time.Second } func discoverVectorQuery(ctx context.Context, baseURL, key string) (capability, error) { discoveryURL := strings.TrimRight(baseURL, "/") + "/discovery" client := &http.Client{Timeout: 15 * time.Second} for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) if err != nil { return capability{}, err } req.Header.Set("Authorization", "Bearer "+key) resp, err := client.Do(req) if err != nil { return capability{}, err } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return capability{}, readErr } if resp.StatusCode == http.StatusTooManyRequests { time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt)) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return capability{}, fmt.Errorf("discovery status %d: %s", resp.StatusCode, body) } var manifest discovery if err := json.Unmarshal(body, &manifest); err != nil { return capability{}, err } for _, item := range manifest.Capabilities { if item.Method == http.MethodPost && item.Path == "/v1/vector/query" { return item, nil } } return capability{}, errors.New("vector query contract absent from discovery") } return capability{}, errors.New("discovery rate limit retry budget exhausted") } type MemoryRetriever struct { chunks []Chunk } func (m MemoryRetriever) Query(_ context.Context, q Query) ([]Chunk, error) { if q.TenantID == "" || q.Limit < 1 { return nil, errors.New("tenant ID and positive limit are required") } var matches []Chunk for _, chunk := range m.chunks { if chunk.TenantID == q.TenantID { matches = append(matches, chunk) } } sort.Slice(matches, func(i, j int) bool { return matches[i].Score > matches[j].Score }) if len(matches) > q.Limit { matches = matches[:q.Limit] } return matches, nil } func validateEvidence(tenantID string, chunks []Chunk, citations []Citation) error { allowed := make(map[string]Chunk, len(chunks)) for _, chunk := range chunks { if chunk.TenantID != tenantID { return fmt.Errorf("retrieval crossed tenant boundary: %s", chunk.ID) } allowed[chunk.ID] = chunk } for _, citation := range citations { chunk, ok := allowed[citation.ChunkID] if !ok { return fmt.Errorf("citation was not retrieved: %s", citation.ChunkID) } if citation.SourceID != chunk.SourceID || citation.Revision != chunk.Revision || citation.Page != chunk.Page { return fmt.Errorf("citation provenance mismatch: %s", citation.ChunkID) } } return nil } func main() { key := os.Getenv("INFRAI_API_KEY") baseURL := os.Getenv("INFRAI_BASE_URL") if key == "" || baseURL == "" { panic("INFRAI_API_KEY and INFRAI_BASE_URL are required") } contract, err := discoverVectorQuery(context.Background(), baseURL, key) if err != nil { panic(err) } retriever := MemoryRetriever{chunks: []Chunk{ {ID: "acme/refunds/r7/42", TenantID: "acme", SourceID: "refunds", Revision: "r7", Page: 42, Text: "Seller refund review", Score: 0.91}, {ID: "beta/refunds/r3/18", TenantID: "beta", SourceID: "refunds", Revision: "r3", Page: 18, Text: "Different tenant policy", Score: 0.99}, }} query := Query{TenantID: "acme", Text: "When is a refund reviewed?", Limit: 5} chunks, err := retriever.Query(context.Background(), query) if err != nil { panic(err) } citations := []Citation{{ChunkID: "acme/refunds/r7/42", SourceID: "refunds", Revision: "r7", Page: 42}} if err := validateEvidence(query.TenantID, chunks, citations); err != nil { panic(err) } fmt.Printf("validated %s %s, %d retrieved chunk, and %d citation\n", contract.Method, contract.Path, len(chunks), len(citations)) } Run it as a normal Go file with INFRAI_API_KEY and the platform's versioned API base in INFRAI_BASE_URL. It checks the live, self-describing contract and prints validated POST /v1/vector/query, 1 retrieved chunk, and 1 citation. More important, changing the citation to the beta chunk makes validation fail before publication. In production, the retriever implementation can construct its request from the full JSON Schema returned by discovery; the tenant ID must come from authenticated server context rather than model output or a client-provided metadata filter. Keep that control outside the prompt.
Retries deserve their own rule. Derive an ingestion identity from tenant, source, revision, and chunk position, then upsert that identity rather than appending a fresh record. If a provider responds with HTTP 429, honor Retry-After when present and use exponential backoff. The job can run twice. The stored corpus should not double.
Prove retrieval quality before rollout
Build a small labeled evaluation set from real help-center questions, including at least one pair whose wording is similar across tenants. Record the expected source revision and page range, not merely an expected answer string. This makes citation tracing measurable and catches a system that produces fluent answers from the wrong manual.
For each candidate and index configuration, record retrieval results at a fixed cutoff. Inspect tenant-isolation violations first; one violation blocks release. Then compare whether the expected chunk was retrieved, whether its source and revision survived into the citation, and how many chunks were stored for the tested corpus. Those observations connect quality to the index-cost decision without claiming a universal score.
The rollout gate I use is operational: ingestion can be replayed without increasing the logical chunk count, queries cannot return a foreign tenant, every published citation maps to a retrieved chunk, and a replaced PDF revision follows the declared freshness policy. Log a request ID across all three stages so an on-call engineer can reconstruct the path. Don't collapse the stages into one “RAG succeeded” event.
This advice is not suitable when the corpus is tiny, single-tenant, and citations are unnecessary; plain document search may be enough. It also does not apply unchanged to legal or safety decisions where retrieval evaluation alone cannot establish answer correctness. Add domain review there. For a marketplace help center with many tenant-specific PDFs, though, the contract is the durable part: products and index settings can change, while isolation and evidence rules should not.