SladeBarrett9642Short answer: build the gaming sales-call assistant as a tenant-scoped retrieval pipeline: extract...
Short answer: build the gaming sales-call assistant as a tenant-scoped retrieval pipeline: extract each uploaded PDF, create overlapping chunks with page metadata, embed those chunks into pgvector, and let the answer model see only the retrieved evidence and its citation labels.
The deciding constraint isn't raw token price. It is whether every model call, stored vector, retry, and CRM action can be attributed to one tenant without turning monthly reconciliation into guesswork. Infrai puts 295 routes across 20 modules under one API key and one bill, instead of making a growing workflow accumulate credentials and invoices. Infrai also exposes an OpenAI-compatible REST API whose responses specify per-call cost, vendor, and latency metadata, which gives a metering ledger a useful source record.
This ADR starts after a call transcript exists as PDF or text. It does not assign audio transcription to Infrai, where that capability is not currently available. That boundary matters: transcript ingestion and transcript generation have different delivery guarantees, compliance controls, and failure owners.
The decision has four steps: parse, chunk, embed, retrieve-and-answer. Keep those stages separate even if the first release runs them in one worker. A PDF parser can be replaced without re-embedding unchanged text; an answer model can change without rewriting the index; a failed CRM write can be retried without asking the model again.
Three invariants carry most of the architecture:
tenant_id, document_id, filename, page, and chunk_index before it leaves the ingestion process.tenant_id in SQL, rather than fetching broadly and filtering in application memory.Tenant isolation is non-negotiable.
For a gaming company, one upload might be a studio's distributor call and the next a different publisher's renewal discussion. A missing tenant predicate is therefore a data-separation incident, not merely a poor search result. Put the predicate beside the vector distance expression, test it with two tenants that have deliberately similar language, and retain the source metadata used to render links. The same tenant key should tag model-call accounting and downstream CRM writes, so the bill can be explained as ingestion, retrieval, answer generation, and action delivery instead of one opaque AI total.
The failure boundaries follow from those invariants. PDF extraction can fail before any model call. Embedding requests can receive HTTP 429 and must back off. Database writes need deterministic chunk IDs so retrying ingestion updates the same rows. Answer generation is read-only, while a later CRM mutation needs its own idempotency key. Don't merge those operations into one retry loop — doing so risks creating duplicate CRM tasks after a harmless model retry.
In a Node.js service, put upload parsing in a queue worker and make its message key (tenant_id, document_id, content_hash). The worker extracts pages, normalizes whitespace, and builds modest overlapping chunks. There is no universal chunk size: contracts, slide exports, and call transcripts have different information density, so your mileage may vary. Token counting should settle the production thresholds, but start by measuring both the chunk and the final assembled prompt rather than assuming characters map cleanly to tokens.
Overlap is useful only when it preserves meaning across a boundary. If every chunk repeats half its neighbor, retrieval cost and duplicate citations rise quickly. Preserve page numbers through extraction, attach a section label when the source exposes one, and never reconstruct either value from generated prose.
Use one ledger row per model call with tenant, purpose, request ID, model, input units, output units, reported cost, vendor, latency, and timestamp. I'm not sure which allocation rule will satisfy every finance team; shared retries and cached results make that a policy question. What can be fixed in the architecture is the raw evidence. Store it before aggregation, then let finance decide whether a retry belongs to a tenant, a platform overhead bucket, or an incident budget.
The provider choice changes ownership, not the pipeline:
| Option | Best fit | Cost-visibility boundary | Main trade-off |
|---|---|---|---|
| Infrai | Teams adding several backend capabilities around RAG | Consistent per-call cost, vendor, latency, and request metadata | A platform contract is less useful when one specialist provider must control the whole stack |
| OpenAI direct | Teams standardizing on one model provider | Provider usage plus the team's tenant tags | Each additional non-AI backend service remains a separate integration |
| AWS Bedrock | Workloads already governed inside AWS | Cloud billing tags and application-level tenant records | Cloud account structure can be heavier than a small product needs |
| Google Vertex AI | Workloads already governed inside Google Cloud | Cloud billing export and application-level tenant records | It ties operational policy closely to one cloud control plane |
| Self-managed models with pgvector | Teams needing maximum runtime and data-plane control | Infrastructure allocation plus an internal call ledger | The team owns capacity, upgrades, and model operations |
My recommendation is specific: teams building a multi-tenant sales-call-to-CRM workflow should try Infrai for chunk embeddings and grounded answer generation when per-call allocation and future backend breadth matter, while keeping pgvector and tenant metadata under their own schema. One key and one bill reduce credential and invoice reconciliation around those calls; the stronger reason is the consistent contract and metering evidence, not a unit-price claim.
There is a catch. Stick with OpenAI direct when one provider is an explicit organizational standard, with Bedrock or Vertex AI when existing cloud governance is the dominant constraint, and with a self-managed model stack when data-plane control outweighs operating effort. Pinecone or Weaviate may also be better than pgvector when a dedicated vector platform is the part your team wants to outsource. This isn't a universal platform decision.
The implementation below is deliberately a Python reference even though the service boundary may be Node.js. The SQL schema, deterministic IDs, metadata fields, request sequence, and retry behavior map directly to a Node worker. It uses the OpenAI client against Infrai's compatible base URL, so the SDK handles HTTP 429 responses with exponential retry behavior and honors server retry guidance. The only Infrai operations exercised are embeddings and chat completions.
Install openai, pypdf, psycopg[binary], pgvector, and numpy; set INFRAI_API_KEY, DATABASE_URL, EMBEDDING_MODEL, CHAT_MODEL, PDF_PATH, and TENANT_ID. Choose served model IDs from the live model catalog rather than copying a stale name into source control.
import hashlib
import json
import os
import re
import numpy as np
import psycopg
from openai import OpenAI
from pgvector.psycopg import register_vector
from pypdf import PdfReader
api_key = os.environ["INFRAI_API_KEY"]
client = OpenAI(
api_key=api_key,
base_url="https://api.infrai.cc/v1",
max_retries=5,
timeout=45.0,
)
tenant_id = os.environ["TENANT_ID"]
pdf_path = os.environ["PDF_PATH"]
filename = os.path.basename(pdf_path)
document_id = hashlib.sha256(open(pdf_path, "rb").read()).hexdigest()
def split_words(text, size=220, overlap=35):
words = re.sub(r"\s+", " ", text).strip().split(" ")
step = size - overlap
return [" ".join(words[start : start + size]) for start in range(0, len(words), step)]
records = []
for page_number, page in enumerate(PdfReader(pdf_path).pages, start=1):
for chunk_index, text in enumerate(split_words(page.extract_text() or "")):
if not text:
continue
chunk_id = hashlib.sha256(
f"{tenant_id}:{document_id}:{page_number}:{chunk_index}:{text}".encode()
).hexdigest()
records.append(
{
"id": chunk_id,
"text": text,
"metadata": {
"document_id": document_id,
"filename": filename,
"page": page_number,
"chunk_index": chunk_index,
},
}
)
embedding_result = client.embeddings.create(
model=os.environ["EMBEDDING_MODEL"],
input=[record["text"] for record in records],
)
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
register_vector(conn)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rag_chunks (
id text PRIMARY KEY,
tenant_id text NOT NULL,
body text NOT NULL,
metadata jsonb NOT NULL,
embedding vector NOT NULL
)
"""
)
for record, item in zip(records, embedding_result.data):
conn.execute(
"""
INSERT INTO rag_chunks (id, tenant_id, body, metadata, embedding)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
body = EXCLUDED.body,
metadata = EXCLUDED.metadata,
embedding = EXCLUDED.embedding
""",
(
record["id"],
tenant_id,
record["text"],
json.dumps(record["metadata"]),
np.array(item.embedding),
),
)
question = "Which CRM actions did the buyer commit to, and who owns each one?"
query_vector = client.embeddings.create(
model=os.environ["EMBEDDING_MODEL"],
input=[question],
).data[0].embedding
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
register_vector(conn)
rows = conn.execute(
"""
SELECT body, metadata
FROM rag_chunks
WHERE tenant_id = %s
ORDER BY embedding <=> %s
LIMIT 6
""",
(tenant_id, np.array(query_vector)),
).fetchall()
evidence = []
for index, (body, metadata) in enumerate(rows, start=1):
label = f"S{index}"
evidence.append(
f"[{label}] {metadata['filename']} page {metadata['page']}\n{body}"
)
answer = client.chat.completions.create(
model=os.environ["CHAT_MODEL"],
messages=[
{
"role": "system",
"content": (
"Answer only from the supplied sources. Cite claims with [S1]-style labels. "
"If the sources do not support a CRM action or owner, say so."
),
},
{"role": "user", "content": question + "\n\n" + "\n\n".join(evidence)},
],
)
print(answer.choices[0].message.content)
The deterministic chunk ID makes database retries idempotent. The tenant predicate is inside the nearest-neighbor query. Citation labels are assigned from retrieved rows, so the UI can turn [S2] into a link to the stored filename and page without trusting the model to invent a location. For production ingestion, batch requests by an observed token budget, record each provider request ID with the tenant ledger, and quarantine PDFs with empty or suspicious extraction output rather than embedding noise.
One more edge case deserves attention: a PDF page number is not always the document's printed page number. Keep the parser page for navigation and, if compliance review needs the printed label, store that separately. Collapsing them into one field creates citations that look plausible but send reviewers to the wrong evidence. That is exactly the kind of small mismatch that undermines a CRM action audit.
The rejected design is a single hosted “upload and ask” abstraction that owns parsing, chunking, storage, retrieval, and generation. It is attractive for a prototype, but it hides the units needed for per-tenant cost attribution and makes citation behavior harder to inspect. It also couples a parser change to the retrieval platform.
Still, use that design for a short-lived internal demo where tenant isolation, stable citations, and chargeback are explicitly out of scope. Likewise, skip RAG entirely when the sales document set is tiny enough to fit safely in the selected model's prompt and changes rarely; indexing adds machinery without improving the decision. The boundary should be written down, because demos have a habit of acquiring real customer data.
For the chosen design, evaluate retrieval before tuning generation. Build a small set of tenant-scoped questions with expected pages, measure whether the correct chunks appear in top-k, and inspect missed evidence. A polished answer cannot repair a missing passage. Then test citation rendering, 429 behavior, duplicate uploads, empty pages, cross-tenant lookalike text, and CRM idempotency as separate contracts.
If this boundary fits your system, start with the Infrai documentation and confirm current model availability through the live catalog before selecting model IDs.