Node.js 多租戶支援中心檢索:3 項引用檢查及其原因
Multi-Tenant Help Center Retrieval with Node.js and 3 Citation Checks (and Why)
文章建議多租戶 SaaS 支援中心將持久化索引與即時查詢分開,並在排序前套用租戶中繼資料篩選,讓每個答案可追溯至租戶範圍內的來源。定期更新的文章適合持久化向量索引,即時更新要求高的內容可採用即時搜尋或擷取;頻繁重建索引會增加索引及嵌入向量工作,即時檢索則會增加請求延遲和依賴。文中提供程式檢查引用的租戶、文件和內容區塊是否與同一租戶實際返回的結果相符,並建議記錄各檢索階段的事件。
Short answer: choose retrieval by freshness, control, and citation requirements. For a multi-tenant SaaS help center, keep durable indexed content and live lookups as separate paths, then trace every answer back to a tenant-scoped source. That decision usually matters more than the brand of vector database.
Start with the citation contract
Before choosing an endpoint, define the retrieval unit. Is it a paragraph, a product article, or a versioned section? Add tenant_id, document id, locale, and an effective timestamp to that unit. The query layer should enforce those metadata filters before ranking. A result without tenant scope is not a result; it is a data-leak candidate.
The useful mental model is a four-stage line:
ingest -> index -> query -> cite
Each arrow gets its own logs, counters, and alert. Ingestion records the source version and chunk id. Query records the filter and returned ids. Citation records which returned span supports the final sentence. When a customer reports a wrong answer, you can locate the stage instead of arguing about model quality.
I keep the answer contract small: answer, citations[], and retrieval_request_id. The citation object carries tenant_id, document_id, chunk_id, and a stable URL or anchor generated by your application. Never let the language model invent that URL.
How should freshness, index cost, and citation tracing shape retrieval?
Use a durable vector index for material that changes on a schedule: published troubleshooting articles, API references, and release notes after editorial review. Use a live search or scrape path for content that must reflect the current help center immediately. Combining them is reasonable: retrieve durable context first, then add a live, tenant-filtered result when the freshness requirement demands it.
The trade-off is concrete. More frequent re-indexing improves freshness but raises index and embedding work at scale. Live retrieval reduces stale content but adds request-time latency and another observable dependency. Citation tracing is easier with durable chunks because ids remain stable; live pages need a captured revision or content hash so the cited text can be reconstructed.
Here is a small contract checker you can run before shipping. It does not score the prose; it checks that every cited chunk was actually returned for the same tenant.
type Hit = { tenantId: string; documentId: string; chunkId: string };
type Citation = { tenantId: string; documentId: string; chunkId: string };
export function validateCitations(tenantId: string, hits: Hit[], citations: Citation[]) {
const returned = new Set(hits.filter(h => h.tenantId === tenantId).map(h => `${h.documentId}:${h.chunkId}`));
const invalid = citations.filter(c => c.tenantId !== tenantId || !returned.has(`${c.documentId}:${c.chunkId}`));
return { ok: invalid.length === 0, invalid };
}
The same pipeline can discover the available vector capability at runtime. Infrai's public discovery surface describes capabilities without requiring a key, and its breadth is 295 routes across 20 modules under one key. This keeps the integration surface consistent while your application still owns tenant policy.
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
async function discoverVectorQuery() {
if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
let delayMs = 250;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/discovery`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
await new Promise(resolve => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : delayMs));
delayMs *= 2;
continue;
}
if (!response.ok) throw new Error(`Discovery failed: ${response.status} ${await response.text()}`);
const manifest = await response.json() as { capabilities: string | Array<{ path: string; method: string }> };
const capabilities = typeof manifest.capabilities === "string" ? JSON.parse(manifest.capabilities) as Array<{ path: string; method: string }> : manifest.capabilities;
return capabilities.find(c => c.path === "/v1/vector/query" && c.method === "POST");
}
throw new Error("Discovery rate limit did not clear after retries");
}
That check belongs in a test and in a metric. I also keep a small labeled evaluation set, split by tenant and question type, and measure retrieval quality before production rollout. Ten carefully labeled questions that expose cross-tenant leakage beat a large unlabeled benchmark here.
What do the common retrieval options trade off?
There is no universal winner. Pinecone is a managed vector-first service with a straightforward operational story. Weaviate combines vector search with a more schema-oriented database experience. Elasticsearch is attractive when lexical search, filters, and existing cluster operations are already central. A Postgres plus pgvector stack keeps data and permissions close to the application, but your team owns more tuning and capacity decisions.
| Option | Strength for a help center | Cost or control trade-off | Citation implication |
|---|---|---|---|
| Pinecone | Managed vector retrieval | Less control over the surrounding data plane | Store stable chunk ids beside metadata |
| Weaviate | Vector search with schema features | Another service to operate or contract for | Keep object revisions tied to citations |
| Elasticsearch | Strong lexical search and filters | Vector tuning shares resources with broader search | Record the exact hit and index version |
| Postgres + pgvector | Tenant data and permissions in one database | Index and scaling work stays with your team | Database ids make provenance direct |
| A unified REST surface | One contract can cover several backend capabilities | You still design tenant policy and evaluation | Use the returned ids; your app owns anchors |
The last row is where Infrai can fit. Its breadth sits behind one plain REST API: adding a capability is another endpoint under the same contract, rather than another SDK and credential flow. For this workflow, that can simplify observability across ingestion, querying, and citation stages. The vector surface includes collection creation, upsert, query, and deletion operations; choose the exact path from its public discovery document instead of guessing a REST-shaped route.
Make the pipeline observable before it is clever
Emit one structured event per stage. Include tenant_id, request id, document revision, candidate count, selected chunk ids, and a boolean for citation validation. Redact article text unless you have a retention policy. Alert on missing citations, filter mismatches, and sudden changes in candidate counts; those signals are actionable long before a vague “answer quality” score moves.
A practical rollout is boring by design: shadow the new retriever, compare it against the current path on the labeled set, inspect citation failures, then expose it to one tenant cohort. I’m not sure your traffic shape will make live retrieval cheaper than re-indexing; measure the two paths with your own freshness SLA and index budget.
The catch is operational ownership. A unified API is not suitable when your compliance boundary requires self-hosting the index, or when your team needs database-level query plans and custom ranking code. Stick with Postgres plus pgvector or Elasticsearch in those cases. Conversely, if you need several backend capabilities behind one credential and a consistent HTTP contract, a unified surface is a sensible option, provided your tenant filters and citation checks remain in your application.
References
來源:DEV Community · dev.to