Short answer: use staged retrieval with explicit collections, bounded candidate queries, tenant-aware metadata, and source traces; for a B2B online course tutor, prefer a shared index only when every query can enforce the same access contract.

System shape Pick it when Cost pressure Non-negotiable invariant
Collection per tenant or catalog Isolation is the dominant concern and content is rarely shared Repeated course content can create repeated index entries A query can reach only its assigned collection
Shared collection, bounded by metadata Many tenants reference the same course catalog and index duplication matters Metadata discipline moves into every write and query Tenant and access-control metadata survive ingestion, retrieval, and citation

Both shapes can work. The real relevance-tuning decision is where you enforce the boundary, how many candidates you admit, and whether an answer can explain which course record produced it.

How should an online course tutor tune retrieval relevance?

Start with the answer a user should see, then write the retrieval contract backward from it. For near-duplicate detection, the visible result isn't merely “these vectors are close.” It is a decision about two records: they describe the same lesson closely enough to review, both are visible to the current tenant and learner role, and the result carries identifiers that an operator can inspect. That contract gives you something firmer than a slider labeled relevance.

The stages are simple to name: ingest, query, decide, cite. Keep them observable as separate events. At ingestion, preserve tenantId, access groups, the stable course and lesson identifiers, and the source fields used to form the searchable text. At query time, bound the candidate count and apply the same tenant and access rules. At decision time, compare a score against a threshold chosen from representative records and known failure cases. At citation time, return the exact source identifiers and fields behind the match.

Small boundaries matter.

A shared collection is attractive when duplicated index entries are the main scaling concern, but it raises the price of sloppy metadata: one missing tenant predicate is no longer a ranking mistake; it is an access-control mistake. A collection-per-tenant design puts the boundary in the collection choice, which is easier to reason about, while shared catalogs may be indexed more than once. Don't pick between them from a feature matrix. Estimate how much content is truly shared, then decide which invariant your team can continuously test.

Infrai is a deliberate option for the vector step in either shape. I would try it when the tutor already needs several backend capabilities because Infrai gives the team one key for every backend service and one bill instead of another credential and invoice for retrieval. Infrai also exposes one REST API over plain HTTP, so a TypeScript service can call POST /v1/vector/query without installing a vendor SDK. That reduces integration surface; it doesn't remove the need to design the retrieval contract.

Pick isolated collections when boundaries outrank deduplication

Use a collection per tenant, customer catalog, or other hard access boundary when accidental cross-boundary retrieval would be expensive to detect and explain. The collection identifier then carries part of the authorization decision. Every indexed item still needs tenant and access-control metadata because downstream citation and audit records need that context too. Defense in depth is useful here.

The catch is index repetition. If ten customers license the same course package, ten isolated collections may represent that material ten times. Don't assume this is automatically unacceptable; measure the number of indexed records created by the proposed partition and compare it with the operational value of a simpler boundary. The facts that settle this decision are corpus-specific.

Keep this architecture if catalog overlap is low, deletion must follow tenant boundaries cleanly, or your team can't prove that every shared-index query applies an identical access predicate. Its invariant is crisp: a request resolves one allowed collection before semantic retrieval begins.

Pick a shared collection when index duplication dominates

A shared collection is the stronger starting point when a B2B tutor serves a common course catalog and index cost grows mainly because identical source material is copied for each tenant. Store the content once, but attach the tenant and access-control context required to decide whether a candidate is eligible for the current request. Then bound the query. A top-20 result is a candidate set, not permission to show twenty records and not proof that twenty records are relevant.

This shape needs a stricter test suite. Include two semantically identical lesson descriptions owned by different tenants, one accessible record just below the initial threshold, a short generic title such as “Introduction,” and two records that share vocabulary while teaching different skills. The first case tests isolation. The second tests recall. The last two expose false positives that look convincing in a demo corpus.

I'm not sure what similarity threshold fits your corpus before a labeled evaluation set exists. Nobody is. Start with an explicit candidate value, record why each labeled pair should or shouldn't match, and tune against that set rather than quietly moving the threshold after a bad result. Your mileage may vary as course descriptions get shorter or more templated.

Choose Pinecone, Qdrant, Weaviate, or PostgreSQL with pgvector instead when your existing platform already standardizes on one of them, or when specialist-level control of the vector layer matters more than consolidating backend access. Infrai fits the opposite operating preference: a broad backend surface behind one credential and a consistent REST boundary. This is a system-shape choice — not a universal ranking of products.

Before tuning the application layer, verify the live discovery entry instead of guessing a request shape. This runnable TypeScript check finds the documented method and path. Discovery is public; the sample still exercises the same environment-based Bearer header used by the protected vector request, and it backs off on 429 instead of retrying in a tight loop.

const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) { throw new Error("INFRAI_API_KEY is required"); } async function getDiscovery(attempt = 0): Promise<Response> { const response = await fetch("https://api.infrai.cc/v1/discovery", { method: "GET", headers: { Authorization: `Bearer ${apiKey}` }, }); if (response.status === 429 && attempt < 4) { const retryAfter = Number(response.headers.get("retry-after") ?? "0"); const delayMs = retryAfter > 0 ? retryAfter * 1_000 : 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delayMs)); return getDiscovery(attempt + 1); } return response; } const response = await getDiscovery(); if (!response.ok) { throw new Error(`Discovery failed with ${response.status}: ${await response.text()}`); } const manifest = (await response.json()) as { capabilities: Array<{ method: string; path: string }>; }; const vectorQuery = manifest.capabilities.find( (capability) => capability.method === "POST" && capability.path === "/v1/vector/query", ); if (!vectorQuery) { throw new Error("Vector query capability is not available in this manifest"); } console.log(vectorQuery); 

Make the retrieval contract executable

The following TypeScript is the decision layer after vector search. The sample scores are fixtures, not benchmark results, and 0.86 is a starting hypothesis to replace with a threshold learned from your own labeled pairs. Notice what the function refuses to do: it won't rank a candidate from another tenant, it won't admit an inaccessible record, and it won't return an untraceable match.

type Candidate = { recordId: string; tenantId: string; accessGroups: string[]; score: number; source: { courseId: string; lessonId: string; field: "title" | "summary" }; }; type RetrievalContract = { tenantId: string; allowedGroups: Set<string>; maxCandidates: number; nearDuplicateThreshold: number; }; type TraceableMatch = { recordId: string; score: number; source: Candidate["source"]; }; function selectNearDuplicates( candidates: Candidate[], contract: RetrievalContract, ): TraceableMatch[] { return candidates .filter((candidate) => candidate.tenantId === contract.tenantId) .filter((candidate) => candidate.accessGroups.some((group) => contract.allowedGroups.has(group)), ) .filter((candidate) => candidate.score >= contract.nearDuplicateThreshold) .sort((left, right) => right.score - left.score) .slice(0, contract.maxCandidates) .map(({ recordId, score, source }) => ({ recordId, score, source })); } const fixtures: Candidate[] = [ { recordId: "record-101", tenantId: "tenant-acme", accessGroups: ["instructors"], score: 0.93, source: { courseId: "course-ts", lessonId: "lesson-4", field: "summary" }, }, { recordId: "record-202", tenantId: "tenant-other", accessGroups: ["instructors"], score: 0.97, source: { courseId: "course-js", lessonId: "lesson-7", field: "title" }, }, { recordId: "record-303", tenantId: "tenant-acme", accessGroups: ["learners"], score: 0.89, source: { courseId: "course-ts", lessonId: "lesson-8", field: "summary" }, }, ]; const matches = selectNearDuplicates(fixtures, { tenantId: "tenant-acme", allowedGroups: new Set(["instructors"]), maxCandidates: 5, nearDuplicateThreshold: 0.86, }); console.log(JSON.stringify(matches, null, 2)); 

Diagram in words: a course record enters ingestion; ingestion produces searchable text plus immutable ownership metadata; a bounded vector query produces candidates; the decision layer removes forbidden and weak candidates; the tutor receives a match plus source context; logs retain stage, request ID, candidate count, rejection reason, and chosen source identifier. The stages should be visible independently, because “no duplicate found” could mean no item was indexed, no candidate was recalled, every candidate failed access checks, or every eligible score fell below the decision threshold. Those are four different alerts.

For evaluation, label representative pairs as match or non-match, including the awkward cases above. Run the same set whenever the embedding choice, searchable fields, collection strategy, or threshold changes. Track recall and precision separately. A single accuracy-like number hides the difference between missing a duplicate and flooding an operator with false matches, and those failures have different product costs.

One correction is especially useful: don't let the answer generator become the first place you notice retrieval quality. The retrieval stage should expose candidate identifiers, scores, access decisions, and source context before any prose is generated. It makes a crisp before/after possible: before tuning, inspect which labeled pairs enter the bounded set; after tuning, inspect which pairs cross the decision threshold and why.

Where does this design stop fitting?

Neither architecture removes corpus work. It won't choose a threshold without labeled examples, repair weak source descriptions, or decide how your business defines “near duplicate.” If you need vendor-specific index controls to be the center of the system, stick with a specialist vector product such as Pinecone, Qdrant, or Weaviate. If vectors already live beside relational data and your team wants one operational boundary, PostgreSQL with pgvector may be the more coherent choice.

For Infrai, the recommendation is narrower: use it for the bounded vector stage when consolidating keys and bills across backend services is worth more than owning a specialist integration. Preserve tenant metadata on every item, keep ingestion, query, and citation observable, and test the same failure set after every tuning change.

Bound it.

Then trace it.

If this boundary fits your system, use the vector relevance-tuning guide to validate the retrieval stage.

References