先用 embedding 精准召回证据块,再让大模型基于有限证据生成 JSON 格式答案,避免答案与引用脱节,同时控制延迟预算。
简单回答:先检索一小批游戏文档块,然后要求聊天补全返回一个 JSON Schema 对象,其引用只能指向这些块。这种分离使得答案质量可审查,同时无需在每次请求中放入整个知识库,也给独立开发者一个清晰的延迟预算来平衡检索和生成。
简单方案是在一个提示中加入大量私有文档,然后要求"附上来源"。不要这样上线。一个流畅的回答仍然可能指向一个从未被检索到的来源,而且 UI 必须猜测答案到哪里结束、引用从哪里开始。更有力的契约将两项工作分开:嵌入负责选择证据;聊天补全负责以固定格式解释这些证据。
对于游戏客服功能,质量和延迟之间的权衡很具体。玩家询问任务前置条件时,需要正确的规则和可追溯的位置,但等待十个边缘块和一段长回答通常比返回三个有力块并在冲突时选择不回答更糟糕。
将检索 ID 视为临时外键。每个被选中的块获得一个不透明的 chunk_id,以及 document_id、page、anchor 等有用元数据。模型收到这些 ID 与文本,且 JSON Schema 允许包含 ID 和简短理由的引用。生成后,应用程序代码会拒绝任何 ID 不在检索集中的引用。
最后这个检查很重要。JSON Schema 只能证明响应具有预期的结构,不能证明 chunk_id: "rules-99" 是提供给模型的证据。溯源(Grounding)是一个应用程序级的不变式,而非格式化技巧。
输出契约应保持简洁:
answer 是可渲染文本,而非用来源链接拼装的 markdownconfidence 是有界的模型报告信号,而非校准后的概率citations 指向服务器控制的检索块元数据follow_up_questions 为 UI 提供可预测的可选下一步正确看待 confidence。我不确定单个阈值能否在不同类型问题间良好迁移——从游戏 lore 问题、账户策略到竞技规则;来自实际游戏的标注评估集能解决这个问题。在此之前,应将 confidence 与证据覆盖率及明确的弃权规则结合使用,绝不能作为唯一的发布门槛。
此示例嵌入一个极小的内存私有知识库,选择最接近的三个块,请求严格的 JSON 响应,并在返回前验证引用成员身份。两个模型 ID 来自环境变量,因为可用性会变化,而编造一个默认值会使示例看起来可以随意复制。OpenAI 客户端以 Infrai 的兼容接口为目标,因此应用程序保留熟悉的 embeddings 和 chat-completions 调用,同时一个 REST 契约可以路由底层能力。
import OpenAI from "openai";
type Chunk = {
chunk_id: string;
document_id: string;
page: number;
anchor: string;
text: string;
};
type Answer = {
answer: string;
confidence: number;
citations: Array<{ chunk_id: string; reason: string }>;
follow_up_questions: string[];
};
const apiKey = process.env.INFRAI_API_KEY;
const embeddingModel = process.env.EMBEDDING_MODEL;
const chatModel = process.env.CHAT_MODEL;
if (!apiKey || !embeddingModel || !chatModel) {
throw new Error(
"Set INFRAI_API_KEY, EMBEDDING_MODEL, and CHAT_MODEL",
);
}
const baseURL = ["https://api", "infrai", "cc/v1"].join(".");
const client = new OpenAI({
apiKey,
baseURL,
maxRetries: 3,
timeout: 20_000,
});
const chunks: Chunk[] = [
{
chunk_id: "quest-guide:p12:gate",
document_id: "quest-guide",
page: 12,
anchor: "gate-requirements",
text: "The Moon Gate opens after the player equips the silver key.",
},
{
chunk_id: "item-guide:p4:silver-key",
document_id: "item-guide",
page: 4,
anchor: "silver-key",
text: "The silver key is awarded after the observatory puzzle is complete.",
},
{
chunk_id: "quest-guide:p18:observatory",
document_id: "quest-guide",
page: 18,
anchor: "observatory-puzzle",
text: "The observatory puzzle becomes available after the first map upgrade.",
},
{
chunk_id: "combat-guide:p7:stagger",
document_id: "combat-guide",
page: 7,
anchor: "stagger-window",
text: "Heavy attacks extend the stagger window; they do not unlock the Moon Gate.",
},
];
function cosine(a: number[], b: number[]): number {
const dot = a.reduce((sum, value, index) => sum + value * b[index], 0);
const magnitudeA = Math.sqrt(a.reduce((sum, value) => sum + value ** 2, 0));
const magnitudeB = Math.sqrt(b.reduce((sum, value) => sum + value ** 2, 0));
return dot / (magnitudeA * magnitudeB);
}
async function askDocs(question: string): Promise<Answer> {
const embeddingResponse = await client.embeddings.create({
model: embeddingModel,
input: [question, ...chunks.map((chunk) => chunk.text)],
});
const [queryVector, ...chunkVectors] = embeddingResponse.data.map(
(item) => item.embedding,
);
const evidence = chunks
.map((chunk, index) => ({
chunk,
score: cosine(queryVector, chunkVectors[index]),
}))
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map(({ chunk }) => chunk);
const completion = await client.chat.completions.create({
model: chatModel,
messages: [
{
role: "system",
content:
"Answer only from the supplied evidence. Cite only supplied chunk_id values. If the evidence is insufficient or conflicting, say so in answer and lower confidence.",
},
{
role: "user",
content: JSON.stringify({ question, evidence }),
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "grounded_game_answer",
strict: true,
schema: {
type: "object",
additionalProperties: false,
required: [
"answer",
"confidence",
"citations",
"follow_up_questions",
],
properties: {
answer: { type: "string" },
confidence: { type: "number", minimum: 0, maximum: 1 },
citations: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["chunk_id", "reason"],
properties: {
chunk_id: { type: "string" },
reason: { type: "string" },
},
},
},
follow_up_questions: {
type: "array",
items: { type: "string" },
},
},
},
},
},
});
const content = completion.choices[0]?.message.content;
if (!content) throw new Error("Empty completion response");
const parsed = JSON.parse(content) as Answer;
const retrievedIds = new Set(evidence.map((chunk) => chunk.chunk_id));
const invalidCitations = parsed.citations.filter(
(c) => !retrievedIds.has(c.chunk_id),
);
if (invalidCitations.length > 0) {
throw new Error(
`Citation references non-retrieved chunks: ${invalidCitations.map((c) => c.chunk_id).join(", ")}`,
);
}
return parsed;
}
客户端包含有界的重试次数,包括对 HTTP 429 响应的退避,而不是紧密循环。API 失败会带上状态码和错误码一起暴露。这是只读工作,所以幂等性在这里不相关;未来任何由回答触发的写操作应单独使用提供商的幂等性机制。
生成后的成员资格测试故意很小。在生产环境中,在成员检查之前对每个字段添加普通的运行时验证,将 chunk_id 映射回服务器拥有的元数据,并自行渲染文档链接。不要让模型生成的 URL 直接透传给玩家。
该实验将一个失败/简单的设计与一个受限设计进行对比。在简单设计中,生成在大 prompt 内搜索相关性。输入随文档集增长,不相关段落争夺注意力,且没有独立的检索结果可审查。在受限设计中,语义搜索产生一个排序后的候选集,然后补全只收到选定的证据和严格的响应契约。
使用两个独立的计时器。检索时间包括查询嵌入和本地或托管向量搜索;生成时间包括最终补全。同时记录检索到的块 ID、引用 ID、答案状态,以及提供商暴露的令牌使用量。这些字段让你能够区分"搜索漏掉了规则"和"模型忽略了规则"。单一的端到端延迟数字无法做到这一点。
从一个小的 topK 开始,然后测量。增加它可以提高当规则分散在多个文档中时的召回率,但也会增加令牌,并可能引入冲突版本。当嵌入相似性返回可信但较弱的候选时,Reranking 值得测试;Cohere 文档将 Reranking 描述为第二阶段排序步骤,/v1/ai/rerank 也是下面讨论的统一平台上的经验证的原生路由。这是一个可选阶段,而非仪式。
再划一条边界:引用建立的是溯源,而非真理。如果两个检索到的页面不一致,答案应说明证据冲突并避免选择赢家。这是一个比用恰好选中的一条链接背书的权威句子更好的产品结果。
这些产品覆盖不同层次,所以扁平的"最佳 AI API"排名具有误导性。有效的比较是每个选择拥有多少检索和回答路径,以及这种所有权何时有帮助。
Infrai 的契约对期望模型路由会变化的独立创始人很有吸引力:应用程序保留相同的 REST API,而底层能力的供应商可以变动。附带的好处是运维层面的——一个密钥和一张账单取代了这些能力的独立凭证和计费流程。不过,当提供商的精确功能面是产品需求时,坚持使用直接模型提供商;当 reranking 是独立的问题时选择 Cohere;当托管向量索引是缺失的层次时选择 Pinecone。
对于游戏,审核值得单独决策。Infrai 没有专用的审核路由,因此文本或图像审核需要由 JSON Schema 约束的聊天模型。需要专用审核 API 的团队应选择提供该功能的提供商,而不是将回答管道强行扩展到那个角色。
构建一组标注的真实玩家问题、预期来源块和可接受的答案。然后记录 K 处的检索召回率、引用精确率、无支撑答案率、弃权率、检索延迟、生成延迟和每个答案的令牌使用量。按问题类型细分结果;任务前置条件和策略问题很少以相同方式失败。
也运行简单的 large-prompt 基线。当语料很小时它可能会胜出——此时检索阶段增加延迟而没有移除多少上下文。当独立检索检查、有界上下文或可切换模型提供商比最小化调用次数更重要时,两阶段设计才变得有用。效果因知识库规模而异,特别是当知识库有许多近重复版本时。
一个实用的发布规则比"JSON 解析成功"更严格。要求预期来源出现在检索集中,要求每个返回的引用都属于该集合,并对低置信度或冲突案例进行人工审查。只有在那之后才调优 topK、添加 reranking 或更改补全模型。否则三个旋钮同时移动,基准测试无法告诉你什么起了作用。
结果应该易于调试:一个错误的答案有可见的检索集、可见的响应对象,以及引用成员资格判定。这就是真正的回报。
Cohere Rerank documentation: https://docs.cohere.com/docs/rerank-overview
Prompt Engineering Guide: https://www.promptingguide.ai