☰
TypeSafe 实战:用可编程判断重构 RAG 重排序与护栏
2026/9/28 16:02:06 网站建设 项目流程

1. 从一次线上事故说起:为什么“重排序”和“护栏”必须被当成一等公民

去年冬天,我接手了一个已经跑了大半年的 RAG 知识库项目。上线初期效果惊艳,业务方拍手叫好,但三个月后投诉开始变多:同一个问题,今天回答得头头是道,明天就答非所问;有些明显该拒答的越界提问,模型却一本正经地编造答案。排查了两周,问题不在大模型本身,而在于整条链路里最容易被忽视的两个环节——重排序(Rerank)和输出护栏(Guardrail)。

这两个环节有个共同特点:它们本质上都是“判断”。重排序是在一堆候选文档里判断“哪几篇最相关”,护栏是在模型输出前判断“这段话能不能放出去”。过去我们做这些判断,靠的是写死在代码里的阈值、正则表达式、关键词黑名单,改一次要发一次版,测试一轮要半天。而 TypeSafe 这个思路真正打动我的地方,是它把“AI 判断”这件事从散落在各处的 if-else,收敛成了一种可编程、可版本化、可组合的能力。

这篇博文不打算复述官方文档,而是把我自己从零搭一套带重排序和护栏的 RAG 系统的完整过程拆开讲。核心关键词会反复出现:TypeSafe、RAG、重排序、AI 判断、可编程能力。如果你正在做 RAG 项目实战,或者被“检索召回一堆垃圾、模型输出不可控”折磨过,那这篇内容应该能帮你少走不少弯路。适合有基本 RAG 概念、动手写过检索链路的同学,纯小白也能看懂思路,因为我会把每个“为什么”都讲透。

先说清楚这套东西解决什么问题。传统 RAG 的流程是:用户提问 → 向量检索 Top-K → 拼进 Prompt → 大模型生成。问题在于,向量检索的相似度分数和“真正相关”之间隔着一道鸿沟,Top-K 里经常混进语义相近但答非所问的段落;而模型生成阶段,没有任何机制保证它不胡说、不越界。重排序负责把“召回”精炼成“精选”,护栏负责把“生成”约束成“可控”。TypeSafe 的价值,就是让这两个判断环节不再是硬编码,而是可以用类型系统描述、可以像写业务逻辑一样编程的模块。

2. 拆解 TypeSafe 的核心思路:把判断逻辑从代码里“抽”出来

2.1 传统 RAG 判断逻辑的三个死穴

在讲 TypeSafe 怎么做之前,得先明白传统做法为什么不行。我踩过的坑基本集中在三点。

第一是判断逻辑和业务代码耦合。重排序的阈值、护栏的规则,全都写在 Python 或 Java 的业务函数里。产品经理说“最近误拒率太高,阈值放宽一点”,你就得改代码、跑测试、走发布流程。一个阈值调整,周期两三天,这在快速迭代的 AI 产品里是致命的。

第二是判断标准无法复用和组合。A 项目的护栏规则是“不能出现竞品名称”,B 项目是“不能承诺具体收益”,两个规则其实可以抽象成同一类“实体黑名单”判断,但因为写死在各自代码里,只能复制粘贴。改一处漏一处,维护成本极高。

第三是判断过程不可观测。模型为什么拒答了这条?是因为命中了哪条规则?重排序为什么把这篇排到第一?中间的打分是多少?传统代码里这些信息要么没有,要么散落在日志里,排查问题像大海捞针。

TypeSafe 的思路,本质上是把“判断”这件事声明化。你不是写一段代码去执行判断,而是描述“什么样的输入、经过什么样的规则、应该得到什么样的输出”,然后由框架去执行。这跟数据库从存储过程转向声明式 SQL 是一个道理。

2.2 TypeSafe 的“类型”到底在约束什么

很多人第一次听到 TypeSafe 会以为是某种静态类型语言,其实在 RAG 语境下,它约束的是判断的输入输出契约。

举个具体例子。一个重排序判断,输入是“用户问题 + 候选文档列表”,输出是“排序后的文档列表 + 每篇的相关性分数”。在传统代码里,这个输入输出是隐式的,靠函数签名和文档约定。而 TypeSafe 把它显式定义成一个类型:输入类型必须包含 query 字段和 documents 数组,输出类型必须包含 ranked_documents 和 scores。任何不符合这个契约的判断模块,都无法接入链路。

这么做的好处是可组合性。你可以写一个“关键词匹配重排序器”,再写一个“语义相似度重排序器”,两个都符合同一个输入输出类型,于是可以串联:先关键词粗排,再语义精排。护栏也一样,一个“敏感词护栏”和一个“事实一致性护栏”可以叠加,前者的输出作为后者的输入,形成判断流水线。

提示:类型约束最大的价值不是“防止写错”,而是“让不同人写的判断模块能拼在一起”。团队协作时,这个价值会被放大十倍。

2.3 为什么说这是“可编程能力”而不是“配置能力”

这里要区分一个关键概念。很多 RAG 框架提供的是配置能力:你在 YAML 里填几个参数,选一个重排序模型,选一个护栏策略,完事。配置能力的问题是,它只能覆盖框架预设的场景,一旦你的需求超出预设,就无能为力。

TypeSafe 提供的是可编程能力。判断逻辑本身可以用代码表达,可以调用外部服务,可以做条件分支,可以循环,可以组合。比如你可以写一个护栏:先判断输出是否包含敏感实体,如果包含,再调用一个外部事实核查接口,根据返回结果决定是拒答还是改写。这种逻辑用配置是表达不出来的,但用可编程的判断模块就很自然。

这也是为什么热词里会出现“skill 怎么和 RAG 结合起来”。Skill 本质上就是一种可编程的判断或动作单元,它和 RAG 的结合点,恰恰就在重排序和护栏这两个判断环节。检索到的知识经过 skill 判断后决定怎么用,模型输出经过 skill 判断后决定怎么放行,这就是 agentic RAG 的雏形。

3. 重排序实战:从 Top-20 到 Top-3 的精细化筛选

3.1 重排序在链路中的位置和选型考量

重排序不是可有可无的优化项,而是决定 RAG 质量的关键闸门。我的经验是,向量检索召回 Top-20,经过重排序精选 Top-3 到 Top-5 拼进 Prompt,效果比直接塞 Top-10 好得多。原因很简单:大模型的上下文窗口虽然大,但注意力是有限的,无关文档越多,干扰越大,越容易答偏。

选型上,我试过三种方案。第一种是交叉编码器(Cross-Encoder),把 query 和每篇文档拼在一起送进模型打分,精度最高但速度慢,适合候选集小的场景。第二种是基于 LLM 的打分,让大模型给每篇文档的相关性打分,灵活但成本高、延迟大。第三种是轻量级语义模型,速度快但精度一般。

实测下来,我的组合策略是:向量检索召回 Top-30,先用轻量级模型粗排到 Top-10,再用交叉编码器精排到 Top-3。这个两阶段策略在延迟和精度之间取得了不错的平衡。而 TypeSafe 在这里的作用,是让这三个阶段都变成可替换、可组合的判断模块,而不是写死在检索函数里。

3.2 用类型定义重排序的输入输出契约

下面是我实际用的重排序判断模块的类型定义思路。注意,这里展示的是契约结构,具体语言可以是 Python 的 dataclass、Java 的 record,或者 TypeScript 的 interface。

# 重排序输入契约 class RerankInput: query: str # 用户原始问题 documents: list[Document] # 候选文档列表 top_k: int # 期望返回数量 context: dict # 额外上下文,如用户身份、会话历史 # 重排序输出契约 class RerankOutput: ranked_documents: list[Document] # 排序后的文档 scores: list[float] # 每篇的相关性分数 reasoning: str # 判断理由,用于可观测性

这个契约的关键在于reasoning字段。传统重排序只返回排序结果,出了问题你不知道为什么。加上判断理由后,排查问题时能直接看到“这篇被排到第一是因为它同时命中了问题中的三个关键实体”,可观测性大幅提升。

注意:context字段很容易被忽略,但它对重排序质量影响很大。比如同一个问题,新用户和老用户需要的文档可能不同,把用户身份传进去,重排序模块就能做个性化判断。

3.3 多阶段重排序的串联实现

有了统一契约,串联就变得很自然。我实现了一个RerankPipeline,它接收一个判断模块列表,依次执行,前一个的输出作为后一个的输入。

class RerankPipeline: def __init__(self, stages: list[RerankStage]): self.stages = stages def run(self, input: RerankInput) -> RerankOutput: current = input for stage in self.stages: current = stage.process(current) return current # 组装:粗排 -> 精排 -> 截断 pipeline = RerankPipeline([ LightweightSemanticStage(top_k=10), CrossEncoderStage(top_k=5), ThresholdCutStage(min_score=0.6) ])

这里每个 Stage 都符合RerankStage接口,输入输出都是RerankInput和RerankOutput。想换模型?换一个 Stage 实现就行。想加一个“去重 Stage”?插进去就行。这就是可编程能力带来的灵活性。

3.4 重排序的实操心得与避坑

踩过的坑里,最典型的是阈值设置。交叉编码器的分数分布因模型而异,有的模型 0.5 就算相关,有的要 0.8。我的做法是先用一批标注数据跑一遍,画出分数分布曲线,找到相关和不相关文档的分界点,再定阈值。千万别拍脑袋定 0.5。

第二个坑是文档切分粒度。重排序的效果高度依赖文档块的质量。如果切得太碎,一篇文档被切成十几个小块,重排序时每块都只包含部分信息,判断容易失准。我的经验是,切分时保留一定的重叠(overlap),并且给每个块加上所属文档的标题作为上下文,重排序时把标题一起送进去判断。

第三个坑是延迟。交叉编码器是逐篇打分的,30 篇文档就是 30 次模型推理,延迟可能到秒级。优化手段是批处理(batch inference)和模型量化。我用 ONNX Runtime 量化后,延迟从 800ms 降到 200ms 左右,效果基本无损。

4. 护栏实战:让模型输出从“不可控”到“可编程约束”

4.1 护栏要解决的三类风险

护栏不是简单的敏感词过滤,它要解决的风险至少有三类。

第一类是合规风险:输出包含不该出现的内容,比如竞品名称、未授权的承诺、敏感信息。这类风险靠关键词匹配能覆盖一部分,但模型换个说法就绕过了,需要语义级别的判断。

第二类是事实风险:模型编造了知识库里没有的信息,也就是幻觉。这类风险最难防,因为幻觉往往看起来很合理。我的做法是让护栏做“引用核查”:模型输出的每个关键论断,必须在检索到的文档里有对应依据,找不到依据的就标记为可疑。

第三类是格式风险:输出不符合预期格式,比如该返回 JSON 却返回了自然语言,该给数字却给了文字。这类风险靠结构化输出约束能解决大部分,但边界情况仍需护栏兜底。

4.2 护栏的可编程结构设计

护栏和重排序一样,也需要统一的输入输出契约。但护栏有个特殊之处:它可能需要修改输出,而不只是判断通过与否。

class GuardrailInput: query: str retrieved_docs: list[Document] model_output: str context: dict class GuardrailOutput: action: str # "pass" | "block" | "rewrite" final_output: str # 最终输出,可能是原文、空、或改写后 triggered_rules: list[str] # 触发了哪些规则 confidence: float # 判断置信度

action字段是关键。传统护栏只有“通过/拦截”两种结果,但实际场景里,“改写”往往比“拦截”体验更好。比如输出里有个别措辞不当,护栏可以调用一个改写模块,把不当措辞替换掉,而不是整段拒答。

4.3 组合式护栏:规则、语义、模型三层叠加

我的护栏实现是三层结构,从快到慢、从粗到细。

第一层是规则护栏,用正则和关键词做快速过滤。这层延迟极低,能拦住大部分明显违规。比如检测输出里是否包含手机号、身份证号格式的字符串。

第二层是语义护栏,用轻量级模型判断输出的语义是否越界。比如判断输出是否在承诺收益、是否在贬低竞品。这层比规则灵活,能识别换汤不换药的表达。

第三层是模型护栏,用大模型做最终的判断和改写。这层最慢但最准,只在前面两层有疑点时触发,避免每次都调用大模型拖慢响应。

class GuardrailPipeline: def __init__(self): self.rule_guard = RuleGuardrail() self.semantic_guard = SemanticGuardrail() self.model_guard = ModelGuardrail() def check(self, input: GuardrailInput) -> GuardrailOutput: # 第一层:规则 rule_result = self.rule_guard.check(input) if rule_result.action == "block": return rule_result # 第二层:语义 semantic_result = self.semantic_guard.check(input) if semantic_result.action == "block": return semantic_result # 第三层:模型,仅在有疑点时触发 if semantic_result.confidence < 0.8: return self.model_guard.check(input) return semantic_result

这个分层设计的核心逻辑是成本与精度的平衡。规则护栏几乎零成本,语义护栏成本中等,模型护栏成本最高。让大部分请求在前两层就得到结论,只有少数疑难请求进入第三层,整体延迟和成本都可控。

4.4 护栏的实操心得:误杀比漏放更可怕

护栏设计里有个反直觉的经验:误杀(把正常输出拦掉)比漏放(放过违规输出)对用户体验的伤害更大。漏放一次,用户可能没注意到;误杀一次,用户直接觉得系统坏了。

所以我的护栏策略是“宁可放过,不可错杀”。规则护栏只拦最明确的违规,语义护栏的阈值设得偏宽松,模型护栏在置信度不足时倾向于放行并记录日志,而不是直接拦截。被放行的可疑输出会进入人工审核队列,用于后续优化规则。

另一个心得是护栏要可解释。每次拦截或改写,都要记录触发了哪条规则、判断理由是什么。这不仅方便排查,也方便向业务方解释“为什么这条被拦了”。没有可解释性的护栏,在跨团队协作时寸步难行。

提示:护栏的规则库应该版本化管理,每次调整都记录变更原因和影响范围。我见过太多团队护栏规则改乱了,最后没人知道某条规则为什么存在。

5. 把重排序和护栏串成完整链路:TypeSafe 的编排价值

5.1 完整 RAG 链路的判断节点梳理

把重排序和护栏放回完整链路,判断节点其实不止这两个。我梳理了一下,一条完整的 RAG 链路至少有五个判断点:

判断节点位置判断内容失败后果
查询改写判断检索前是否需要改写、如何改写检索方向错误
召回过滤判断检索后哪些文档明显不相关噪声进入重排序
重排序判断过滤后文档相关性排序精选质量下降
引用核查判断生成后输出是否有依据幻觉流出
输出护栏判断输出前是否合规、是否改写合规风险

TypeSafe 的价值在于,这五个判断点可以用同一套类型契约来描述,用同一个编排引擎来执行。它们不再是散落在代码各处的函数,而是一条可观测、可组合、可版本化的判断流水线。

5.2 编排引擎的设计要点

编排引擎要解决的核心问题是执行顺序和依赖管理。有些判断可以并行(比如多个护栏同时检查),有些必须串行(比如重排序必须在召回过滤之后)。我的做法是用一个有向无环图来描述判断节点之间的依赖关系。

class JudgmentGraph: def __init__(self): self.nodes = {} # 节点ID -> 判断模块 self.edges = {} # 节点ID -> 依赖的节点ID列表 def add_node(self, node_id: str, module, depends_on: list[str] = None): self.nodes[node_id] = module self.edges[node_id] = depends_on or [] def execute(self, input_data): # 拓扑排序确定执行顺序 order = self.topological_sort() results = {} for node_id in order: node_input = self.prepare_input(node_id, input_data, results) results[node_id] = self.nodes[node_id].process(node_input) return results

这个设计的妙处在于,判断节点之间的依赖关系是声明式的。你不需要写代码控制执行顺序,只需要声明“护栏 B 依赖重排序 A 的输出”,引擎会自动处理。新增判断节点时,只需要声明它依赖谁、被谁依赖,不用改动现有代码。

5.3 可观测性:判断过程必须留痕

判断流水线最大的价值之一是可观测性。每个判断节点的输入、输出、耗时、判断理由,都应该被记录下来。我用的是结构化日志,每个节点执行完输出一条 JSON 日志,包含节点 ID、输入摘要、输出摘要、耗时、置信度。

这些日志的用途很多。排查线上问题时,能快速定位是哪个判断节点出了问题。优化效果时,能分析每个节点的拦截率和误判率。做 A/B 测试时,能对比不同判断模块的效果差异。没有这些日志,判断流水线就是个黑盒,出了问题只能靠猜。

注意:日志里不要记录完整的用户输入和模型输出,涉及隐私的内容要脱敏。我一般只记录摘要和哈希值,需要详情时再通过哈希去查原始数据。

6. 常见问题与排查技巧实录

6.1 重排序相关的高频问题

问题一:重排序后,最相关的文档反而排到了后面。

排查思路:先看交叉编码器的输入格式是否正确。很多模型的输入是[query] [SEP] [document],如果拼接格式错了,打分完全不可信。再看文档是否被截断,交叉编码器通常有最大长度限制,超长文档会被截断,导致关键信息丢失。

问题二:重排序延迟太高,拖慢整体响应。

排查思路:先确认候选集大小,30 篇和 100 篇的延迟差好几倍。如果候选集太大,先做粗排。再看是否用了批处理,逐篇推理和批量推理的延迟差很多。最后看模型是否量化,量化能带来数倍加速。

问题三:重排序分数分布不稳定,阈值难定。

排查思路:分数分布不稳定通常是因为 query 类型差异大。事实型问题和开放型问题的分数分布完全不同。解决方案是分场景定阈值,或者用相对排序而非绝对阈值——只取 Top-K,不设绝对分数线。

6.2 护栏相关的高频问题

问题一:护栏误杀率太高,用户投诉。

排查思路:先看规则护栏是否过于宽泛,比如用单个关键词匹配,容易误伤。再看语义护栏的阈值是否太低。我的经验是,新上线的护栏先设宽松阈值,观察一周误杀率,再逐步收紧。

问题二:护栏被绕过,违规输出仍然流出。

排查思路:检查护栏是否覆盖了所有输出路径。有些系统有多个输出口,护栏只挂在主路径上,旁路就漏了。另外检查护栏的触发条件,是否有些情况被跳过了。

问题三:护栏判断本身消耗太多 token,成本失控。

排查思路:护栏判断尽量用轻量级模型,不要动辄调用大模型。规则和语义护栏能解决的,不要上升到模型护栏。模型护栏只在必要时触发,并且要限制输入长度。

6.3 判断流水线的通用避坑清单

坑点表现解决方案
判断模块耦合业务代码改规则要发版用类型契约解耦,规则外置
判断过程无日志出问题无法排查每个节点记录结构化日志
阈值拍脑袋定效果不稳定用标注数据画分布曲线定阈值
护栏只拦不改用户体验差增加改写动作,能改不拦
判断节点串行执行延迟高无依赖的节点并行执行
规则库无版本管理规则混乱规则版本化,记录变更原因

7. 从 RAG 到 Agentic RAG:判断能力的延伸

7.1 判断能力是 Agent 的基础设施

把重排序和护栏抽象成可编程的判断能力后,会发现这套东西的适用范围远不止 RAG。Agent 的核心能力是什么?是“根据当前状态决定下一步做什么”。这个“决定”本质上就是判断。检索到的信息要不要用、工具调用返回的结果可不可信、下一步该调用哪个工具,全都是判断。

所以 TypeSafe 这套思路,其实是在为 Agentic RAG 打地基。当你的 RAG 系统里有了可编程的判断能力,往 Agent 方向演进就很自然:把判断节点从“处理检索和输出”扩展到“决定行动序列”,就是一个 Agent 编排引擎。

7.2 Skill 与 RAG 的结合点

热词里反复出现“skill 怎么和 RAG 结合起来”,我的理解是:Skill 就是封装好的判断或动作单元,它和 RAG 的结合点就在判断环节。一个 Skill 可以是“判断这段输出是否符合品牌调性”,也可以是“判断这个查询是否需要调用外部 API”。RAG 提供知识,Skill 提供判断,两者结合就是完整的智能问答。

具体实现上,Skill 可以注册到判断流水线里,作为一个判断节点。它的输入输出符合统一契约,可以被编排引擎调度。这样 Skill 的开发和 RAG 链路的开发就解耦了,各做各的,通过契约对接。

7.3 本地化部署的考量

很多团队关心本地 RAG 的部署,尤其是数据敏感的场景。我的经验是,判断流水线的本地化比模型本地化更容易实现。重排序模型和护栏模型通常参数量不大,本地部署成本可控。大模型可以用本地开源模型,也可以用外部 API,取决于数据敏感程度。

关键是把判断逻辑和模型解耦。判断逻辑是代码,可以完全本地运行;模型是资源,可以按需选择本地或远程。这样即使模型换了,判断逻辑不用动,系统的稳定性有保障。

8. 我踩过的三个印象最深的坑

第一个坑是重排序和检索用了同一个模型。我一开始图省事,检索和重排序都用同一个 embedding 模型,结果重排序几乎没效果,因为两者的判断维度完全一样,重排序只是把检索结果重新算了一遍。后来换成交叉编码器,效果立竿见影。教训是:重排序模型必须和检索模型异构,否则就是重复劳动。

第二个坑是护栏规则写得太死。早期我用精确关键词匹配,结果模型换个同义词就绕过了。后来改成“关键词 + 语义相似度”双重判断,召回率大幅提升。但语义判断又带来了误杀,于是加了置信度阈值和人工审核队列。这个过程反复迭代了三四轮才稳定。

第三个坑是判断流水线没有降级方案。有一次护栏依赖的外部服务挂了,整个问答链路直接不可用。后来我给每个判断节点都加了降级策略:外部服务不可用时,退回到规则判断;规则判断也不可用时,默认放行并记录日志。可用性比绝对安全更重要,尤其是在线服务。

9. 给正在做 RAG 项目的你几条实在建议

如果你正准备搭一套 RAG 系统,或者正在被检索质量和输出可控性折磨,我的建议是:先把判断环节独立出来,用统一的类型契约管理。不要一开始就追求大而全的框架,先把重排序和护栏这两个最痛的点用可编程的方式管起来,效果会立刻显现。

具体落地路径可以这样走:第一步,定义重排序和护栏的输入输出契约,哪怕只是简单的 dataclass。第二步,把现有的判断逻辑迁移到契约下,写成独立的判断模块。第三步,加一个简单的编排引擎,支持模块串联和并行。第四步,补上结构化日志,让判断过程可观测。第五步,根据日志数据持续优化判断规则和阈值。

这套东西的价值不在于技术多先进,而在于它把 AI 系统里最不可控的“判断”环节,变成了工程上可管理、可迭代、可复用的能力。TypeSafe 这个名字起得挺准,类型安全只是手段,真正的目标是判断安全——让每一次 AI 判断都有契约、有记录、有兜底。

最后分享一个我一直在用的小技巧:每次调整重排序阈值或护栏规则,都先用历史日志跑一遍回归测试,看看新规则对存量请求的影响。这个习惯帮我避免了好几次“改一个规则,崩一片场景”的事故。判断能力的迭代,本质上和代码迭代一样,需要测试、需要灰度、需要回滚机制。把它当成正经的软件工程来做,而不是当成调参玄学,RAG 系统的稳定性会有质的提升。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询