用嵌入检索获取最相关发票片段,仅将证据传入 chat completion,要求返回带引用溯源的 JSON Schema 答案,避免 UI 层二次解析。
简短回答:用 Embedding 检索最相关的发票片段,只把这些证据传入聊天补全,并要求返回的 JSON Schema 答案中的引用可以被校验——校验依据是检索阶段返回的元数据,在 UI 渲染前完成。
对于供应商发票提取,这份契约比花哨的 Prompt 更有价值。有用的输出不是一段听起来合理的文字,而是一个答案、一个置信度值、精确提取的字段、引用来源,以及后续问题——应付账款界面无需再次解析就能直接渲染。我不会因为一个答案恰好是合法 JSON 就信任它。每条引用还必须能够回溯到一个文档 ID、页码或 URL 锚点,而检索阶段必须真的返回了这些内容。
这是一个质量与延迟之间的权衡。先用 Embedding 检索加一次补全。如果固定评估集显示更好的证据选择确实值得多一跳网络开销,再加入重排。
一个"问答"演示通常优化的是流畅的答案。发票工作的考验更严苛:审核人能否从 total_due 直接跳转到确切的信息来源?以及当证据不足时,应用能否拒绝填充某个字段?供应商名称可能同时出现在页眉、汇款栏、采购单引用和邮件落款中。检索可以找到全部四处,但工作流需要的法律意义上的供应商名称只有一个。
这正是我分别对两个阶段做基准测试的原因。检索质量问的是:正确的片段是否进入了候选集。生成质量问的是:补全是否严格基于这些片段,并产生了承诺的输出结构。把两个分数混在一起会掩盖失败边界。如果正确的页面从未到达模型,Prompt 调优就是表演;如果页面存在但输出引用了一个不存在的片段,那问题不在检索。
在接触生产流量之前,先用一小批标注集。它应该包含多页总计的发票、重复的采购单编号、贷项通知单和缺失到期日的发票。分别测量字段准确率和引用有效率,并记录检索和补全各自的 p50 和 p95 延迟。这里没有通用的截止值——发票布局和审核成本因人而异——所以我不确定在你的 workload 中重排器是否值得它的延迟,直到用同样的标注查询在两种方案下都跑过。
保持第一个版本简洁。
Embedding 按语义相似性对片段排序。聊天补全将选定的证据转化为最终的结构化响应。可选的重排阶段可以在生成之前重新排序初始候选,但它应该解决一个已测量的漏检问题,而不是为了满足某个架构图。
让 Schema 足够严格,使得无效证据不会悄无声息地变成 UI 状态。引用 ID 必须来自检索到的集合,未知属性应该导致校验失败,可空字段应该代表不支持的事实。模型仍然可能出错。这份契约使那种错误变得可审查。
引用对象需要的是检索元数据,而不是补全过程中杜撰的文字。以这家媒体公司为例,每个索引片段都带有 document_id、page 和 url_anchor。Prompt 给模型提供紧凑的片段 ID(如 c1);解析后,应用代码将这些 ID 重新解析回它已经拥有的元数据。这个间接层是刻意设计的。它防止模型伪造一个令人信服的 URL,并将存储细节隔离在生成契约之外。
置信度对路由有用,不代表真相。把它当作模型提供的信号,可以将结果发送给人工审核。除非有标注评估证明了在当前使用的具体发票、模型、Prompt 和检索设置下校准是成立的,否则不要把 0.91 变成已校准准确率的声明。
还有一条硬规则:弃权必须可表示。如果发票从未说明付款条款,正确的结果是 null 字段加一个后续问题,而不是基于常见供应商条款猜测。简短的输出没问题,不支持的输出不行。
以下 TypeScript 示例在内存中嵌入三个虚构发票片段,检索排名前二,请求 Schema 约束的补全,并拒绝检索阶段未提供的任何引用。将 OPENAI_API_KEY、EMBEDDING_MODEL 和 CHAT_MODEL 设为你要测试的 provider 所提供的模型 ID。客户端在遇到限速时以有界指数退避重试,并在响应暴露了 Retry-After 时遵循它。
import OpenAI from "openai";
type Chunk = {
id: string;
text: string;
document_id: string;
page: number;
url_anchor: string;
};
type Answer = {
answer: string;
confidence: number;
fields: {
supplier_name: string | null;
invoice_number: string | null;
total_due: string | null;
due_date: string | null;
};
citations: Array<{
chunk_id: string;
document_id: string;
page: number;
url_anchor: string;
}>;
follow_up_questions: string[];
};
const apiKey = process.env.INFRAI_API_KEY;
const baseURL = process.env.INFRAI_BASE_URL;
const embeddingModel = process.env.EMBEDDING_MODEL;
const chatModel = process.env.CHAT_MODEL;
if (!apiKey || !baseURL || !embeddingModel || !chatModel) {
throw new Error(
"Set INFRAI_API_KEY, INFRAI_BASE_URL, EMBEDDING_MODEL, and CHAT_MODEL before running",
);
}
const client = new OpenAI({ apiKey, baseURL, maxRetries: 0 });
const chunks: Chunk[] = [
{
id: "c1",
document_id: "invoice-1042",
page: 1,
url_anchor: "invoice-1042#page=1",
text: "Northstar Licensing LLC. Invoice INV-1042. Total due: USD 8,240.00.",
},
{
id: "c2",
document_id: "invoice-1042",
page: 2,
url_anchor: "invoice-1042#page=2",
text: "Payment is due on September 30. Reference purchase order PO-7718.",
},
{
id: "c3",
document_id: "invoice-0991",
page: 1,
url_anchor: "invoice-0991#page=1",
text: "Archive record for a different supplier invoice.",
},
];
function retryAfterMs(error: unknown, attempt: number): number {
if (error instanceof OpenAI.APIError && error.status === 429) {
const raw = error.headers?.get("retry-after");
const seconds = raw ? Number(raw) : Number.NaN;
if (Number.isFinite(seconds)) return seconds * 1_000;
}
return 250 * 2 ** attempt;
}
async function withRateLimitRetry<T>(operation: () => Promise<T>): Promise<T> {
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
return await operation();
} catch (error) {
const retryable = error instanceof OpenAI.APIError && error.status === 429;
if (!retryable || attempt === 3) throw error;
await new Promise((resolve) => setTimeout(resolve, retryAfterMs(error, attempt)));
}
}
throw new Error("Rate-limit retry budget exhausted");
}
function cosine(a: number[], b: number[]): number {
const dot = a.reduce((sum, value, index) => sum + value * b[index], 0);
const normA = Math.sqrt(a.reduce((sum, value) => sum + value * value, 0));
const normB = Math.sqrt(b.reduce((sum, value) => sum + value * value, 0));
return dot / (normA * normB);
}
async function askDocs(question: string): Promise<Answer> {
const embedded = await withRateLimitRetry(() =>
client.embeddings.create({
model: embeddingModel,
input: [question, ...chunks.map((chunk) => chunk.text)],
}),
);
const [queryVector, ...chunkVectors] = embedded.data.map((item) => item.embedding);
const selected = chunks
.map((chunk, index) => ({ chunk, score: cosine(queryVector, chunkVectors[index]) }))
.sort((a, b) => b.score - a.score)
.slice(0, 2)
.map(({ chunk }) => chunk);
const allowedIds = new Set(selected.map((chunk) => chunk.id));
const evidence = selected
.map((chunk) => `${chunk.id} | ${chunk.text}`)
.join("\n");
const completion = await withRateLimitRetry(() =>
client.chat.completions.create({
model: chatModel,
messages: [
{
role: "system",
content:
"Extract invoice fields only from EVIDENCE. Use null for unsupported fields. Cite every factual field with a supplied chunk_id.",
},
{ role: "user", content: `QUESTION\n${question}\n\nEVIDENCE\n${evidence}` },
],
response_format: {
type: "json_schema",
json_schema: {
name: "invoice_answer",
strict: true,
schema: {
type: "object",
additionalProperties: false,
required: ["answer", "confidence", "fields", "citations", "follow_up_questions"],
properties: {
answer: { type: "string" },
confidence: { type: "number", minimum: 0, maximum: 1 },
fields: {
type: "object",
additionalProperties: false,
required: ["supplier_name", "invoice_number", "total_due", "due_date"],
properties: {
supplier_name: { type: ["string", "null"] },
invoice_number: { type: ["string", "null"] },
total_due: { type: ["string", "null"] },
due_date: { type: ["string", "null"] },
},
},
citations: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["chunk_id", "document_id", "page", "url_anchor"],
properties: {
chunk_id: { type: "string", enum: Array.from(allowedIds) },
document_id: { type: "string" },
page: { type: "number" },
url_anchor: { type: "string" },
},
},
},
follow_up_questions: {
type: "array",
items: { type: "string" },
},
},
},
},
},
}),
);
const raw = completion.choices[0]?.message?.content;
if (!raw) throw new Error("Empty completion");
const parsed = JSON.parse(raw) as Answer;
const validatedCitations = parsed.citations.filter((c) => allowedIds.has(c.chunk_id));
const resolved = validatedCitations.map((c) => {
const meta = chunks.find((chunk) => chunk.id === c.chunk_id)!;
return { ...c, document_id: meta.document_id, page: meta.page, url_anchor: meta.url_anchor };
});
return { ...parsed, citations: resolved };
}
这个示例使用 provider SDK 的类型化方法,发出 Embedding 和聊天补全请求。它禁用了隐藏的重试逻辑,让外层包装拥有 429 处理策略。非限速的 4xx 响应会携带 provider 的真实错误体抛出,而不是被误认为空答案。
解析后的元数据替换很容易被忽略。JSON Schema 将 chunk_id 限制为检索到的 ID,但应用仍然用可信的本地元数据覆盖 document_id、page 和 url_anchor。多一次 Map 查找可以消除整类伪造引用目标。
首先,将片段向量移出进程,并保持相同的元数据契约。在摄入期间批量做 Embedding,对分块策略做版本控制,并在数据处理规则允许的地方缓存查询 Embedding。这些变更都不应该泄漏到前端响应结构中。当索引、检索、生成和渲染都可以发明自己的源标识符时,配置膨胀就开始了。
其次,在初始检索和补全之间测试重排。已验证的 AI 运行时路由包括 POST /v1/ai/rerank,而标准模型接口包括 /v1/embeddings 和 /v1/chat/completions。在评估期间保持候选池和最终 top-k 固定。用同样的发票问题比较引用召回率和端到端延迟;只有当重排修复了足够多的证据漏检以抵消额外调用成本时,才保留它。
第三,将操作失败与证据失败区分开。429 可以用退避重试。当只检索到 c1 和 c2 时却返回了引用 c9 的答案,这不是。对前者重试;对后者拒绝并检查。 对于管道中其他写操作,使用幂等键以使重试不会产生重复效果,尽管这个面向读的示例不执行任何创建或发布操作。
在更大规模下,我还会为 null 或低置信度字段添加显式的审核状态,以及为每个供应商布局准备回归 fixture。一个有用的 fixture 保存原始片段文本、预期字段、可接受的引用 ID,以及某个字段缺失时必须保持 null 的原因。在模型变更、Prompt 编辑、OCR 更新或分块迁移后运行它。分别报告四个阶段:初始检索、可选重排、结构化生成和本地引用校验。这个更长的 trace 不如一个汇总分数好看,但它告诉运维人员是应该重新索引文档、调优 top-k、更改 Schema,还是检查 provider 响应。我不会为了文笔更好而添加第二个模型调用。输出是给工作流用的,不是参加写作比赛,而且每次调用都有延迟成本,哪怕 UI 在 spinner 后面隐藏了它。
在实际的边界上对组件做基准测试。单一端到端分数让供应商选择看起来比实际简单,而且无法判断哪个阶段需要替换。
Infrai 选项适合重视跨多个后端服务使用一套凭证和一份月末账单的小团队,同时为本工作流保持 OpenAI 兼容的客户端。问题是边界。如果系统需要一个专属的审核端点,这个平台不提供;审核必须使用带 JSON Schema 防护的聊天模型或单独的审核服务。它的实时语音会话能力也尚未就绪,仅限于西部区域,而且 ASR 目前不可用,因此语音优先的发票摄入应该使用语音路径已就绪的服务。这些边界不影响文本发票检索,但对平台级决策很重要。
当单一补全面是整个 workload 且可以接受独立账户时,坚持使用直连 provider。当重排是已测量的瓶颈时测试 Cohere。当检索已经是自主运维层时保留 Pinecone 或 Elasticsearch。建议应该跟随标注发票集,而不是最长的功能清单。因扫描质量、页面结构和供应商重复率会改变难点,你的实际体验可能不同。
Cohere, Rerank overview: https://docs.cohere.com/docs/rerank-overview
Prompt Engineering Guide: https://www.promptinguide.ai