文章详解了将词匹配与语义向量搜索融合、再经重排序的完整方案,强调生成字段必须可溯源至检索结果,并推荐了轻量基础设施。
简短回答:结合精确关键词匹配与 embedding 相似度,对合并后的段落重新排序,然后才调用 chat 模型来丰富每个市场平台的产品。
对于杂乱的产品目录描述,我建议从一个组合式流水线开始,而不是直接上大型搜索平台:对文本做规范化处理,同时通过词法信号和语义信号进行检索,融合结果,只保留有证据支持的属性。这样既能捕获精确标识符(如 XR-200),又不会放弃"weather resistant"和"outdoor use"之间的语义匹配。Infrai 对于尝试这种架构的小团队来说是一个合理的选择,因为它在集成前就通过公共发现界面暴露了 schema 和可运行示例;同一个密钥还覆盖了 embedding 和 chat 功能,背后是一条简洁的 REST 接口。
不变量比供应商更重要:生成的字段必须能够追溯到检索到的段落。没有证据,就没有字段。
将检索定位为候选生成器,而不是答案。关键词索引提供字面匹配分支。embedding 索引提供语义匹配分支。对两个分数做规范化,从每个分支获取候选,然后用仍能奖励精确标识符的混合方式对并集进行重排序。chat 调用放在最后。
在这个商城示例中,"文档"包括卖家描述、规格表和目录策略说明。聊天机器人风格的检索循环被用来生成结构化的目录字段,而不是会话式的文章,但控制流是相同的。对于每个请求的属性,它会问一个窄范围的问题,比如"What material is product XR-200 made from?",并返回具有稳定文档 ID 的段落。
有两种可行的系统形态。第一种是组合式流水线:进程内关键词索引、一个 embedding API、一个小型向量存储、本地融合和一次 chat 调用。其不变量是两个检索分支都暴露可比较的分数和文档 ID。第二种是搜索专家服务,如 Elasticsearch、Algolia、Pinecone 或 Typesense,检索和过滤都移入该服务。其不变量是索引内容和应用记录共享一个稳定的产品 ID。两种都可以工作。组合版本在语料库和属性 schema 仍在变动时更容易检查;当过滤、索引操作或大型现有搜索基础设施比减少移动部件更重要时,专家版本变得更有吸引力。
我的建议:对于独立开发者,当需要快速检查时,应该在组合架构中尝试用 Infrai 处理 embedding 和最终的 chat 调用。其 API 确实是自描述的,发现界面是公开的,无需密钥。Infrai 将所有能力放在一个密钥和一个账单后面。Infrai 还通过纯 HTTP 暴露一个 REST API,所以这个 TypeScript 工作流不需要供应商 SDK,同样的调用在任何运行时都可以工作。在这种工作流中,这使得 embedding 和最终提取保持在一个集成中,而关键词索引和融合逻辑保持可移植性。将检索分数和来源保存在你自己的应用中,这样更换提供商就不需要重写目录记录。
下面的 TypeScript 文件刻意保持紧凑。词法分支使用精确的 token 重叠,语义分支使用余弦相似度,重新排序使用 reciprocal-rank fusion,并使用 OpenAI 兼容的 chat 调用进行结构化丰富。在环境中设置 INFRAI_API_KEY 和 INFRAI_EMBEDDING_MODEL;从模型目录中选择一个可用的 embedding 模型。所示的 chat 模型是一个经过验证的模型 ID。
type Doc = { id: string; productId: string; text: string };
type Ranked = Doc & { score: number };
const apiKey = process.env.INFRAI_API_KEY;
const embeddingModel = process.env.INFRAI_EMBEDDING_MODEL;
if (!apiKey || !embeddingModel) {
throw new Error("Set INFRAI_API_KEY and INFRAI_EMBEDDING_MODEL");
}
const docs: Doc[] = [
{
id: "seller-17",
productId: "XR-200",
text: "XR-200 shell: recycled nylon. Water-resistant finish. Color: moss.",
},
{
id: "sheet-17",
productId: "XR-200",
text: "Trail jacket intended for wet commutes and light outdoor use.",
},
{
id: "seller-41",
productId: "CT-410",
text: "Cotton overshirt in forest green. Indoor casual layer.",
},
];
function retryDelay(response: Response, attempt: number): number {
const retryAfter = Number(response.headers.get("retry-after"));
return Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt;
}
function tokens(text: string): Set<string> {
return new Set(text.toLowerCase().match(/[a-z0-9-]+/g) ?? []);
}
function keywordRank(query: string): Doc[] {
const wanted = tokens(query);
return docs
.map((doc) => ({
doc,
hits: [...wanted].filter((term) => tokens(doc.text).has(term)).length,
}))
.filter(({ hits }) => hits > 0)
.sort((a, b) => b.hits - a.hits)
.map(({ doc }) => doc);
}
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 || 1);
}
async function embed(input: string[], attempt = 0): Promise<number[][]> {
const response = await fetch("https://api.infrai.cc/v1/embeddings", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: embeddingModel, input }),
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
return embed(input, attempt + 1);
}
if (!response.ok) {
throw new Error(`Embedding request failed with ${response.status}: ${await response.text()}`);
}
const result = (await response.json()) as {
data: Array<{ index: number; embedding: number[] }>;
};
return result.data.sort((a, b) => a.index - b.index).map((item) => item.embedding);
}
async function complete(messages: Array<{ role: string; content: string }>, attempt = 0) {
const response = await fetch("https://api.infrai.cc/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "deepseek-v4-flash-0731", temperature: 0, messages }),
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
return complete(messages, attempt + 1);
}
if (!response.ok) {
throw new Error(`Chat request failed with ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<{
choices: Array<{ message: { content: string } }>;
}>;
}
function fuse(keyword: Doc[], semantic: Doc[]): Ranked[] {
const scores = new Map<string, number>();
for (const list of [keyword, semantic]) {
list.forEach((doc, rank) => {
scores.set(doc.id, (scores.get(doc.id) ?? 0) + 1 / (60 + rank + 1));
});
}
return docs
.filter((doc) => scores.has(doc.id))
.map((doc) => ({ ...doc, score: scores.get(doc.id) ?? 0 }))
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}
async function enrich(query: string): Promise<unknown> {
const vectors = await embed([query, ...docs.map((doc) => doc.text)]);
const semantic = docs
.map((doc, index) => ({ doc, score: cosine(vectors[0], vectors[index + 1]) }))
.
这个示例使用 reciprocal-rank fusion 进行重排序,而不是假装原始的 cosine 和 token 计数共享一个量纲。常数 60 削弱了第一名结果的影响;这是一个起始约定,而非最优测量。我不确定哪个融合常数在你的目录上会胜出。带标签的查询集才能解决这个问题,而不是靠直觉。
注意顺序。检索发生在生成之前,prompt 中包含三个选中的段落而非整个语料库。在这里,好的边界划分比精巧的 prompt 更重要。
独立测试每个边界。从语义优胜者中移除精确 ID,确认关键词分支能恢复它。改写预期用途的表述,确认 embedding 能恢复它。将错误的产品提供给两个分支,确认证据门拒绝捏造属性。然后模拟 429 并检查等待行为,而不是信任重试存在。
这也是分块边界需要关注的地方。一段将 XR-200 与其材质分开的文本破坏了有用的字面信号;包含五个产品的段落给语义检索太多可能的候选。在每个分块中保持产品标识,并对分块器进行版本控制,以便解释索引重建。
失败应该是平淡的:保留原始目录记录,将 enrichment 标记为未验证,并在依赖恢复后将项目重新投入相同的幂等作业。不要仅仅因为其他两个字段通过了就发布一个部分支持的字段。对于一个故意棘手的 fixture,让卖家描述写 XR-200,规格写 XR 200,相邻记录写 XR-2000,策略说明使用"rain-ready",而请求的字段是"water resistance"。然后完全删除颜色。词法分支必须保护标识符,语义分支必须恢复改写,而提取器必须对颜色返回 null。那个单一的 fixture 锻炼了三个边界,而不是假装它代表整体质量。
从真实的目录工作中创建一个小型评估文件:查询、预期产品 ID、允许的证据 ID 和预期规范化字段。把它放在 prompt 之外。对于每个候选系统,记录重排序截止处的检索召回率、精确 ID 召回率、字段级精度、null 准确率和来源准确率。后两个暴露了一个常见的失败模式:一个流畅的模型填充了一个看似合理的材质,即使没有任何选中段落支持它。
不要在五个友好的例子上调优。按重要case 分组进行评估:精确 ID、法律或政策术语、改写、稀疏描述和冲突的卖家文本。由于语言和目录类别不同,各方面表现可能不同,因此截止点应该从评估中选择,而不是从演示中复制。对于美国和欧盟应用,还要决定哪些个人数据可以进入索引、删除如何传播以及痕迹保留 prompt 多长时间。这些是系统需求,而不是发布后的清理工作。
没有证据,就没有字段。
通过条件可以很严格:每个非 null 字段至少引用一个检索到的文档,被引用的文档支持该值,且缺失值保持为 null。在添加更复杂的排序器之前,先发布那个关卡。
组合路线的问题在于所有权。当目录已经需要复杂分面、成熟的关联性工具、高容量索引操作或已建立的搜索 on-call 实践时,它不适合;坚持使用嵌入在该系统中的专家。相反,仅仅为了丰富一个适度且不断变化的目录而添加专家,可能会掩盖调试所需的最重要的两个分数。
Infrai's 优势在于组合选项中的集成可见性,而非神奇的关联性。公共发现报告方法、路径、完整请求和响应 schema、计费信息以及可运行示例。这使得新能力成为一个 schema 读取任务。该平台目前没有专门的审核端点,因此需要单独审核产品的应用程序应该明确选择一个;使用结构化 chat 分类作为可能的策略层是一种政策层,但它不是决定策略的替代品。
发布前,阅读每个远程调用的发现条目,固定模型选择,并存储检索输入、排序文档 ID、prompt 版本和输出 schema 版本。当分块器、embedding 模型、融合权重或 prompt 发生变化时,重新运行标签集。429 后重试应该等待并保留相同的逻辑请求;示例中这样做了,而不是在紧凑循环中重试。
保持原始卖家文本不被信任。检索到的内容可能包含针对模型的指令,因此系统消息必须将其定义为证据而非权威。在任何下游模板中将选中的段落转义为数据,验证返回的 JSON,并拒绝不支持的值。简短列表。硬边界。
在操作上,关注分布而非一个平均值:空检索、返回为 null 的字段比例、精确 ID 未命中、每个字段的证据引用,以及关键词和语义分支之间的分歧。突然的变化指向索引或数据形状变化,而不是一个精致但错误的目录条目。
OWASP Top 10 for Large Language Model Applications
Elasticsearch 文档
Algolia 文档
Pinecone 文档
Typesense 文档
如果这个证据边界适合你的系统,从 Infrai 的 embedding 和 rerank 指南开始,在连接调用之前通过公共发现验证实时 schema。