项目记录
主链路编排与答案置信度
拆分 API、QAService 与 Pipeline 的职责,并通过置信度、引用和降级策略控制错误答案风险。
本文目录 · 9 节
Demo 和上线系统最本质的区别,不在于答对率,而在于答错时会发生什么。
知识库里没有答案时,模型不会说「我不知道」,它会流畅地编一个出来。 而且编得越像,越难被发现。这一篇讲的就是这套项目怎么处理这个问题。
先给结论
编排层只有一条心法:分层负责,参数透传。
API 层 只做接入:限流、校验、异常转错误码、事件转发
QAService 只做编排:场景解析、数据域、调用顺序,不碰检索和生成细节
pipeline 只做业务:检索步骤、置信度、引用、生成
而「敢不敢回答」这件事,由三段置信度共同决定。
一、为什么要有 QAService 这一层
如果没有它,WebSocket 处理函数里会塞进场景解析、数据域裁剪、意图调用、检索调用、 置信度计算、LLM 生成、落库——一个几百行、没人敢改的函数。
QAService 的边界画得很清楚:
stream_query() -> Generator 事件流:status / token / end / error
debug_retrieval() -> 检索诊断字典,不调用 LLM
两个方法共用同一套「场景解析 + 数据域 + 检索准备」,区别只在于最后要不要生成。 调试接口和真实链路共用前半段,这一点很关键:你在调试接口里看到的召回结果, 就是线上真实召回的那一批,不存在「调试用的另一套逻辑」。
实例由 factory.get_qa_service() 以进程级单例管理,构造函数不保存任何请求状态——
无状态是横向扩容的前提。
二、pipeline 为什么拆成三个文件
rag.py 主流程编排:把步骤串起来,不写细节
steps.py 业务步骤:准备检索、检索 FAQ、检索文档、准备生成参数
confidence.py 置信度纯逻辑:可单独测试,无 IO
拆分的收益不在「好看」,在于可测试。置信度被抽成纯函数之后, 可以用一堆构造好的输入直接断言输出,不需要起 Milvus、不需要调 LLM。 这是整套测试体系能跑得快的原因之一。
三、检索分数 ≠ 答案可信度
这是本篇最重要的一句话。Milvus 返回的 score 只说明「候选和 query 相关」,
它回答不了「这段证据支不支持这个答案」。所以置信度分三段算:
calculate_evidence_confidence() 生成前:证据本身够不够撑得住这个问题
calculate_generation_confidence() 生成后:答案有没有真的落在证据里
combine_answer_confidence() 合并:产出最终 answer_confidence
配套的两级门槛写在配置里:
faq_direct_score_threshold = 0.72 # FAQ 直出需要的最低分
rag_min_score_threshold = 0.2 # 进入 RAG 生成的最低证据门槛
低于门槛就不生成。拒答不是失败,是正确行为。 对内部制度类问答尤其如此:一个错误的报销标准,比「请咨询财务」危险得多。
四、引用是强制的,不是可选的
enforce_answer_citations() 会检查生成的答案是否带上了来源。
这一层兜底的意义在于:Prompt 里写了「必须引用来源」不等于模型一定会照做。
工程上不能依赖模型的自觉,要有代码层面的校验和补救。
召回结果进入 prompt 之前还有两道裁剪:
final_context_top_n = 4 # 最终给几段证据
max_context_doc_chars = 1600 # 单段证据最长多少字符
max_prompt_context_chars = 6000 # 整个上下文预算
上下文不是越多越好:无关证据会稀释注意力,超长上下文还会让模型忽略中间部分。
五、Prompt 按「意图 + 风险」双维度选
Prompt 模板不是一份,而是一个档位系统(qa_core/prompts/profiles.py):
意图维度 FAQ 强调短、准、直接
知识查询允许结构化回答
追问强调不重复历史已答内容
风险维度 费用类必须区分「已确认」与「未确认」
合规类使用更保守的措辞
排障类要求给出可执行步骤
选择优先级是三级(selector.py):风险类别专用 > 意图专属 > 默认通用。
也就是说,一个问题即使被识别成普通知识查询,只要它属于费用类别, 就一定会走费用专用模板。这个设计承认了一件事:风险分类比意图分类更贴近业务底线。
六、多轮对话怎么不跑偏
max_history_messages = 8 # 直接进 prompt 的最近消息数
history_summary_after_messages = 14 # 超过 14 条触发摘要
history_summary_max_chars = 1200 # 摘要长度上限
超长历史先摘要再拼接,而不是无脑全塞。追问场景下还会同步放宽 FAQ 召回, 因为「那它的审批人是谁」这种问题,脱离上下文根本没法检索。
动手验证
# 冒烟:接口连通性、流式事件、错误码
python scripts/acceptance_smoke.py --base-url http://127.0.0.1:18000
# 端到端:完整问答链路
python scripts/api_e2e_smoke.py --base-url http://127.0.0.1:18000
人工验证拒答策略:问一个知识库里明确没有的问题(比如「公司明年会不会上市」), 预期看到的是拒答或转人工提示,而不是一段编造的答案。再问一个费用类问题, 观察回答里有没有区分「已确认」和「待确认」。
取舍与边界
- 三段置信度是启发式,不是校准过的概率:它擅长排序和拦截明显不足的证据, 不要把它当作可以直接对外汇报的准确率指标。
- 拒答率会上升:门槛调高的直接后果是「本来能答的也拒答了」。 这是产品决策,不是技术决策,需要业务方一起定。
- 参数透传牺牲了封装:加一个新的检索参数要改多个文件, 换来的是「主链路可读、调试链路同源」。
下一篇讲那些不在主链路上、但决定了系统能不能长期运行的工程件: 缓存、知识库版本、租户隔离和 Trace。