文档 chatbot 检索应并行 keyword 和 embedding、融合排名后 Rerank 小候选集、设置 Evidence Gate 过滤低支持度答案,Node.js 与 Python 分离部署边界设计。
当语义匹配和关键词精确度同时重要时,为文档聊天机器人选择混合搜索;保持融合逻辑透明,只对少量候选结果进行重排序,并在调参前测量答案支撑度。
简言之:一个简单的文档聊天机器人应该并行检索关键词和嵌入候选,用排名组合而非不兼容的原始分数合并结果,用原始问题对合并后的集合进行重排序,只将通过证据检验的段落传给答案生成。当存活下来的段落没有提供足够证据时,拒绝回答。
这是一个检索系统,而非巧妙的提示词工程。有效的思维模型是漏斗:先广泛召回,再精确排序,最后过证据门。Node.js 应用可以拥有 HTTP 端点和对话状态,而小型的 Python 检索服务则负责索引、融合和评估。这个边界是刻意为之的。它让检索实验开始的 notebook 可以使用生产环境调用的相同函数,而不将排序逻辑绑定到某个 Web 框架。
Node.js 文档聊天机器人应该如何组合关键词搜索和语义搜索?
首先,将每个文档转换为段落,这些段落保留其来源 URL、标题路径和稳定的文档标识符。将同一段落的文本建立两次索引:一次为词汇索引(用于字面术语),一次为嵌入向量(用于语义相似度)。在查询时,并发地从两个索引中检索,融合两个排名列表,用原始问题对排名靠前的候选进行重排序,只将有关联证据支撑的段落传给答案生成。响应应携带指向这些稳定来源记录的引用。
关键词检索在语料库包含版本字符串、方法名、错误码、配置键和产品特定词汇时发挥其价值。嵌入检索器覆盖的是另一种失败模式:读者说"登录一直过期",而文档写的是"会话生命周期"。两个分数都不是通用单位。余弦相似度 0.78 和词汇得分 12.4 不会因为碰巧能放在同一个算术表达式里就变得有意义。
在那个边界使用排名融合。倒数排名融合(Reciprocal Rank Fusion)根据每个结果在各自列表中的位置为其分配贡献值,通常表示为 1 / (k + rank)。这个常数削弱了前几个位置的优势;这是一个调参,而非关于相关性的声明。两个检索器都找到的段落自然排名上升,而来自任一方独特的、有用的结果也能在重排序阶段存活下来。
生产环境的数据流很容易检查:问题 → 两个检索器 → 融合候选 → 重排序器 → 证据阈值 → 带引用的答案。记录每个箭头的标识符和排名。默认不记录原始私有文档或对话文本;检索可观测性可以使用哈希值、延迟、结果数量和访问控制的追踪替代。
在加入模型前先给检索函数建立评估
下面的参考实现刻意保持小巧。它接受来自关键词索引和向量索引的排名 ID,融合它们,获取段落,并调用注入的重排序器。实际的索引和重排序器保持可替换。Node.js API 可以通过内部 HTTP 边界调用这个组件,但排序行为可以作为纯 Python 进行测试。
from dataclasses import dataclass
from typing import Callable, Iterable
@dataclass(frozen=True)
class Passage:
passage_id: str
text: str
source_url: str
@dataclass(frozen=True)
class RankedPassage:
passage: Passage
score: float
def reciprocal_rank_fusion(
ranked_lists: Iterable[list[str]],
rank_constant: int = 60,
) -> list[tuple[str, float]]:
fused: dict[str, float] = {}
for results in ranked_lists:
for rank, passage_id in enumerate(results, start=1):
fused[passage_id] = fused.get(passage_id, 0.0) + 1.0 / (
rank_constant + rank
)
return sorted(fused.items(), key=lambda item: item[1], reverse=True)
def retrieve(
question: str,
keyword_ids: list[str],
semantic_ids: list[str],
passages: dict[str, Passage],
rerank: Callable[[str, list[Passage]], list[float]],
candidate_limit: int = 20,
answer_limit: int = 5,
) -> list[RankedPassage]:
fused = reciprocal_rank_fusion([keyword_ids, semantic_ids])
candidates = [
passages[passage_id]
for passage_id, _ in fused[:candidate_limit]
if passage_id in passages
]
scores = rerank(question, candidates)
if len(scores) != len(candidates):
raise ValueError("reranker returned the wrong number of scores")
ranked = sorted(
(
RankedPassage(passage=passage, score=score)
for passage, score in zip(candidates, scores, strict=True)
),
key=lambda item: item.score,
reverse=True,
)
return ranked[:answer_limit]
这段代码使两个重要选择变得清晰可见。融合使用位置信息,所以一个检索器的分数规模不会压过另一个。重排序发生在去重之后,所以被检索两次的段落只消耗一个模型输入槽位。在真实服务中,验证 candidate_limit,限制段落长度,在文本进入任一索引前应用授权,并为每个外部调用设置超时。
第一个测试不应该问"它返回了东西吗?"给它一个带有硬核案例的小型标注查询集:一个精确的 API 符号、一个改写表述、一个答案跨越相邻段落的问句、一个过时页面、一个未授权页面,以及一个无法回答的问题。追踪候选阶段的检索召回率、顶部附近的排序质量、引用正确性、弃权准确率、延迟,以及发送给重排序器和答案模型的 token 数量。我不会因为某个 notebook 设置赢得一个愉快的演示就推广它;它必须能改进那个冻结的查询集而不破坏那些丑陋的案例。然后检查每个改变的结果,包括明显的提升,因为更高的 aggregate 分数可能掩盖了一个新缺失的引用,或者一个与主题相关但不被检索段落支撑的答案。
在任何托管模型进入循环之前,一个最小的确定性测试可以证明融合契约:
def test_fusion_rewards_agreement() -> None:
keyword = ["auth-errors", "session-lifetime", "api-keys"]
semantic = ["session-lifetime", "browser-cookies", "auth-errors"]
fused = reciprocal_rank_fusion([keyword, semantic])
assert fused[0][0] == "session-lifetime"
assert {passage_id for passage_id, _ in fused[:2]} == {
"auth-errors",
"session-lifetime",
}
注意这个测试没有证明什么。它没有说明嵌入质量、语料库覆盖率,或者答案模型是否会引用正确的句子。这些需要标注的检索示例和端到端的答案检查。我不确定单一的全局阈值能适用于每个文档类别;只有当评估集证明存在稳定的差异时,按类别校准才是合理的。
校准证据门,而不只是排序
重排序分数是有用的排序信号,但它们的数值含义取决于所选模型和输入分布。不要从博客文章里复制一个阈值。从你自己的语料库中收集重排序器对有支撑和无支撑问题的分数,针对你关心的错误选择阈值,并保留一条弃权路径。对于支持文档,一句礼貌的"我在索引文档中找不到这个"往往比从弱相关段落拼凑出的流畅综合更好。
问题是,更多的候选改善了重排序器找到证据的机会,同时也增加了延迟和 prompt 成本。太小的 chunk 可能检索到正确的短语却没有足够的上下文来回答;太大的 chunk 则稀释了匹配度并消耗了重排序预算。从与文档结构一致的边界开始,保留标题上下文,只有在评估显示答案被分割时才添加有限的相邻段落。实际情况因 API 参考、教程和政策文档有不同的有用单元而异。
我把 prompt 成本作为检索设计的一个输出来对待。记录候选数量、每个候选的字符或 token 数、重排序器输入大小、答案上下文大小,以及弃权率,同时记录质量指标。这样,诸如将候选池翻倍这样的提议就有一条明确的质量-成本曲线。
将语料库版本、嵌入配置、融合常数、重排序器、prompt 和评估集一起版本化管理。否则,重新索引后的分数变化将无法归因。在上线过程中,在相同的冻结查询上比较新管道和当前管道,然后只在应用的数据处理策略下对实时流量进行影子测试。200 响应是传输成功,而非有支撑答案的证明。
生产检索管道周围可能出现哪些故障?
访问控制必须在检索结果成为答案上下文之前发生。在向量搜索之后才过滤可能将未授权文本暴露给重排序器,或在追踪中留下敏感细节,即最终响应已将其移除。OWASP 关于 LLM 应用的指南将提示词注入和敏感信息披露视为不同的风险;检索系统需要对两者都有防御。将索引文档视为不受信任的输入。说"忽略之前的指令"的段落是被引用或拒绝的内容,绝不是给应用的指令。
数据保护也影响架构。如果查询或文档包含个人数据,在发版遥测之前定义处理目的、保存期限、删除和访问控制。GDPR 原则包括目的限制和数据最小化,所以"记录一切以防调试需要"不是一个合理的默认。具体的实现取决于司法管辖区和组织策略,本文不构成法律建议。
故障处理需要同样明确的契约。将检索器超时视为一种降级的搜索路径,其在元数据中可见,而不是静默等同于"无相关文档"的空结果。我用固定重试上限和总请求截止时间测试 429 处理——无限重试策略可能在生成开始前消耗整个延迟预算。拒绝格式错误的重排序器输出。如果词汇索引正在更新而向量索引仍在服务早期版本的语料库,给每个结果打上其索引版本戳,并避免在一个答案中混合版本。还要区分空的成功搜索和部分检索:前者可以自信地触发证据门,而后者应该告诉调用者系统没有检查每个配置的来源。
当语料库小到可以进行确定性查找时,当精确的结构化过滤器可以回答问题时,或者当团队无法维护两个索引和一个评估集时,混合检索并不适合。在精确引用集合(同义词帮助不大)坚持使用词汇搜索。用数据库查询处理结构化事实。另一方面,当融合检索已在延迟预算内达到测量得出的 top-k 目标时,重排序器可能是不必要的。每个额外的阶段都增加了一个依赖和另一个消耗 token 的地方。
将清单作为行为来交付
部署前,让一份经过审查的配置描述 chunking、两个检索深度、融合、重排序深度、证据阈值和上下文限制。将 secrets 放在配置之外。从相同的标准化段落记录构建索引,用共享版本发布它们,预热索引,并将冻结的评估套件作为发布门禁。应用应该为有支撑答案、无支撑答案、部分检索、无效请求和限流暴露有界的超时和结构化结果。
在运行中,分别图表征检索延迟、重排序延迟和生成延迟;在答案质量样本旁边图表征候选数量和弃权数。根据提供服务的精确语料库版本审计引用。在保留策略下对失败样本进行人工审查,然后将反复出现的遗漏转化为标注的评估案例。如果一个改变改进了语义改写却丢失了精确错误码匹配,aggregate 分数正在掩盖你需要做出的决定。
那个循环才是真正的实现:检查、标注、改变一个变量、重新运行,只有当质量、延迟和 token 预算仍然符合时才发布。架构保持简单因为每个阶段都有一个job和一个可测量的契约。聊天机器人通过展示证据和拒绝无支撑答案来赢得信任,而不是通过总是产生文本。
https://owasp.org/www-project-top-10-for-large-language-model-applications/