1. 从一次线上事故说起:为什么重排序和护栏必须变成代码
去年冬天,我接手了一个企业知识库问答系统的优化项目。上线第一周,用户反馈就炸了锅:有人问“年假怎么算”,系统一本正经地引用了三年前已废止的旧版员工手册;有人问“报销流程”,回答里混进了隔壁部门的差旅标准。更离谱的是,当用户追问“你确定吗”的时候,模型居然开始编造不存在的制度条款,语气还特别笃定。
排查下来,问题出在两个环节。第一,检索阶段用的是纯向量相似度,BM25 的稀疏信号完全没参与,导致“年假”和“休假”这种词面匹配度极高的查询反而排到了后面。第二,生成阶段没有任何输出约束,模型拿到什么上下文就自由发挥,既没有引用溯源,也没有置信度判断。说白了,整个 RAG 链路里,AI 的判断是“黑盒”的,你没法干预,也没法复现。
这件事让我开始认真思考一个问题:能不能把 AI 的判断过程变成可编程的能力?不是靠调 prompt 碰运气,而是像写业务代码一样,把重排序、过滤、护栏这些环节都变成有类型、可测试、可组合的模块。TypeSafe 这个思路,就是在这个背景下进入我的视野的。
这篇文章适合正在做 RAG 项目、被检索质量和输出安全折磨过的开发者,也适合想了解如何把 LLM 能力工程化的技术管理者。我会从整体设计思路讲到具体实操,把重排序、BM25 融合、护栏机制这些环节拆开揉碎,配上可直接参考的代码和参数计算过程。读完之后,你应该能搭出一套“判断可编程”的 RAG 系统,而不是一个靠玄学调参的玩具。
2. 整体设计思路:把 AI 判断拆成可组合的类型化模块
2.1 为什么“可编程”比“调 prompt”更靠谱
很多人做 RAG 的第一反应是堆 prompt。检索效果不好?加一句“请仔细阅读上下文”。输出不安全?加一句“如果不知道就说不知道”。这种做法在 demo 阶段能跑通,但一到生产环境就崩。原因很简单:prompt 是自然语言,没有类型约束,没有单元测试,没有版本管理。你改了一个词,可能影响十个场景,而且你根本不知道哪个场景会坏。
TypeSafe 的核心思想,是把 RAG 链路里的每个判断节点都抽象成有输入输出类型的函数。比如重排序是一个(query, documents) -> ranked_documents的函数,护栏是一个(response, context) -> validated_response的函数。每个函数都可以单独测试、单独替换、单独监控。这样一来,AI 的判断就不再是“模型说了算”,而是“代码说了算,模型只是其中一个执行器”。
我试过用纯 prompt 方案和类型化方案做对比。同一个知识库,纯 prompt 方案的检索命中率在 62% 左右波动,改一次 prompt 可能涨到 68%,再改一次又掉到 55%。类型化方案把 BM25 和向量分数做加权融合后,命中率稳定在 78% 以上,而且每次调整权重都有明确的评估指标支撑。这个差距在生产环境里就是用户留存率的差距。
2.2 重排序在 RAG 链路中的真实位置
很多人把重排序当成一个“可选优化”,觉得向量检索已经够了。这是个误区。向量检索擅长语义相似,但对精确匹配、数字、专有名词很不敏感。用户问“2024 年第三季度营收”,向量模型可能给你返回“2023 年财报分析”,因为语义上都是“财务相关”。BM25 则能精准抓住“2024”“第三季度”这些关键词。
重排序要做的,就是在粗排之后,用一个更精细的模型或算法,把真正相关的文档推到前面。TypeSafe 的做法是:粗排用 BM25 和向量双路召回,各取 Top 50,然后用一个交叉编码器做精排,最后取 Top 5 送入生成。这个流程里,每一步的输入输出都有明确的类型定义,比如Document类型包含id、content、bm25_score、vector_score、rerank_score等字段,任何一步出问题都能快速定位。
2.3 护栏不是“加一句提示词”,而是独立的安全层
护栏这个词容易被误解。很多人觉得在 prompt 里写“不要回答敏感问题”就叫护栏了。真正的护栏应该是一个独立的验证层,在模型输出之后、返回用户之前执行。它要检查的东西包括:输出是否引用了上下文、引用是否准确、是否包含幻觉内容、是否符合业务规则。
TypeSafe 的护栏模块我把它设计成三个检查器串联:引用检查器验证每个事实性陈述是否能在上下文中找到出处;一致性检查器用一个小模型判断输出和上下文是否矛盾;规则检查器执行硬编码的业务规则,比如“不能出现竞品名称”“不能承诺具体收益”。三个检查器都通过,输出才放行;任何一个失败,就触发降级策略,比如返回“我暂时无法回答这个问题,请咨询人工客服”。
3. 核心细节解析:BM25 融合、重排序模型选型与护栏实现
3.1 BM25 与向量分数的加权融合计算
BM25 和向量分数的量纲不一样,不能直接相加。BM25 分数通常是 0 到几十的实数,向量相似度是 0 到 1 之间。直接相加的话,BM25 会完全主导排序。我踩过这个坑,当时调了半天重排序模型,发现根本没起作用,因为粗排阶段 BM25 已经把向量信号淹没了。
正确的做法是先归一化,再加权。我常用的归一化方法是 Min-Max 归一化,把每个分数映射到 0 到 1 之间。公式很简单:
normalized_score = (score - min_score) / (max_score - min_score)然后加权融合:
final_score = alpha * normalized_bm25 + (1 - alpha) * normalized_vectoralpha 的取值需要根据业务场景调。我实测下来,对于技术文档类知识库,alpha 取 0.4 左右比较合适,因为用户查询里经常有精确的术语和版本号。对于客服问答类场景,alpha 可以降到 0.2,因为用户表达更口语化,语义匹配更重要。
这里有个细节:归一化要在每次查询的候选集内做,不能用全局的 min 和 max。因为不同查询的分数分布差异很大,用全局值会导致归一化后的分数失去区分度。我一般会在粗排阶段取 Top 100 作为候选集,在这个集合内做归一化,然后融合排序取 Top 50 进入精排。
3.2 重排序模型的选型与推理优化
重排序模型我试过三种:Cross-Encoder、ColBERT 和基于 LLM 的 Listwise 重排。Cross-Encoder 效果最稳,但推理慢;ColBERT 快,但需要额外的索引结构;LLM 重排效果最好,但成本高、延迟大。
TypeSafe 的方案是分层使用:第一层用 ColBERT 做快速精排,取 Top 20;第二层用 Cross-Encoder 做精细重排,取 Top 5。这样兼顾了速度和效果。ColBERT 的推理延迟在 10ms 级别,Cross-Encoder 在 50ms 级别,整体重排序耗时控制在 100ms 以内,对用户体验影响很小。
模型选型上,Cross-Encoder 我推荐用bge-reranker-v2-m3,中文效果不错,而且有量化版本,推理速度能再快一倍。ColBERT 可以用colbert-v2,但要注意它需要预先建立 token 级别的索引,索引构建时间比普通向量索引长不少。如果知识库更新频繁,这个成本要考虑进去。
注意:重排序模型的输入长度有限制,通常是 512 个 token。如果文档块超过这个长度,需要截断。截断策略我建议保留开头和结尾,因为关键信息往往在这两个位置。中间部分可以按句子边界截断,避免把一句话切成两半。
3.3 护栏模块的三个检查器实现细节
引用检查器的实现思路是:把模型输出拆成句子,对每个事实性句子,在上下文中找最相似的段落,计算相似度。如果相似度低于阈值,就标记为“无引用支持”。阈值我一般设 0.75,用向量相似度计算。这个检查器能抓住大部分幻觉,但对“张冠李戴”式的错误(引用了上下文,但引用错了地方)效果一般。
一致性检查器就是用来补这个漏的。我用一个小模型(比如 1B 参数级别的)做 NLI(自然语言推理),判断“上下文是否蕴含输出”。如果输出和上下文矛盾,就标记为“不一致”。这个检查器延迟稍高,大概 30ms 一次,但能抓住引用检查器漏掉的错误。
规则检查器最简单,就是硬编码的业务规则。比如用正则表达式匹配竞品名称,用关键词列表匹配敏感承诺。这个检查器零延迟,但需要人工维护规则库。我建议把规则库做成配置文件,方便运营人员随时更新,不用改代码。
三个检查器的执行顺序有讲究。规则检查器最先执行,因为它最快,能快速过滤明显违规的输出。然后引用检查器,最后一致性检查器。任何一个失败,就短路返回,不再执行后面的检查器,节省计算资源。
4. 实操过程:从零搭建 TypeSafe RAG 护栏系统
4.1 环境准备与依赖安装
先说一下我的环境:Python 3.10,PyTorch 2.1,CUDA 11.8。如果你没有 GPU,CPU 也能跑,但重排序模型的推理速度会慢很多,建议至少用 ColBERT 做精排,Cross-Encoder 可以放到有 GPU 的机器上做异步调用。
依赖安装清单如下:
pip install rank_bm25==0.2.2 pip install sentence-transformers==2.2.2 pip install FlagEmbedding==1.2.5 pip install pydantic==2.5.0 pip install fastapi==0.104.0 pip install uvicorn==0.24.0rank_bm25用来做 BM25 召回,sentence-transformers和FlagEmbedding用来做向量编码和重排序,pydantic用来定义类型化的数据结构,fastapi用来暴露服务接口。
提示:
FlagEmbedding的版本更新比较快,建议锁定版本号,避免 API 变动导致代码跑不通。我用的 1.2.5 版本比较稳定,支持bge-reranker-v2-m3的加载和推理。
4.2 类型化数据结构定义
TypeSafe 的核心是类型定义。我用 Pydantic 定义了三个核心类型:Document、Query、RankedResult。每个类型都有明确的字段和校验规则。
from pydantic import BaseModel, Field from typing import List, Optional class Document(BaseModel): id: str content: str bm25_score: float = 0.0 vector_score: float = 0.0 rerank_score: float = 0.0 metadata: dict = Field(default_factory=dict) class Query(BaseModel): text: str top_k: int = 5 alpha: float = 0.4 filters: Optional[dict] = None class RankedResult(BaseModel): query: str documents: List[Document] latency_ms: float trace_id: str这样定义的好处是,任何一步的输出都可以用RankedResult来校验。如果某个文档缺少bm25_score,Pydantic 会直接报错,而不是等到排序的时候才发现分数是 None。我在实际项目里就遇到过因为字段缺失导致排序结果乱掉的情况,有了类型校验之后,这类问题在开发阶段就能发现。
4.3 BM25 与向量双路召回实现
BM25 召回我用rank_bm25库,先把知识库的所有文档分词,构建 BM25 索引。向量召回用sentence-transformers的bge-large-zh-v1.5模型,把文档编码成向量存到 FAISS 索引里。
from rank_bm25 import BM25Okapi import jieba import numpy as np from sentence_transformers import SentenceTransformer import faiss # 构建 BM25 索引 tokenized_corpus = [list(jieba.cut(doc)) for doc in corpus] bm25 = BM25Okapi(tokenized_corpus) # 构建向量索引 model = SentenceTransformer('bge-large-zh-v1.5') embeddings = model.encode(corpus, normalize_embeddings=True) index = faiss.IndexFlatIP(embeddings.shape[1]) index.add(embeddings.astype('float32')) def hybrid_retrieve(query: str, top_k: int = 100): # BM25 召回 tokenized_query = list(jieba.cut(query)) bm25_scores = bm25.get_scores(tokenized_query) bm25_top_indices = np.argsort(bm25_scores)[::-1][:top_k] # 向量召回 query_embedding = model.encode([query], normalize_embeddings=True) vector_scores, vector_top_indices = index.search(query_embedding.astype('float32'), top_k) # 合并候选集 candidate_ids = set(bm25_top_indices) | set(vector_top_indices[0]) return candidate_ids, bm25_scores, vector_scores[0], vector_top_indices[0]这里有个细节:BM25 和向量召回的候选集要取并集,而不是交集。取交集会漏掉一些只被单路召回的文档,而这些文档里往往有惊喜。我实测过,并集方案的召回率比交集方案高 12 个百分点。
4.4 归一化与加权融合的完整代码
拿到双路召回结果后,先做归一化,再加权融合。注意归一化要在候选集内做,不能用全局值。
def normalize_scores(scores: np.ndarray) -> np.ndarray: min_score = scores.min() max_score = scores.max() if max_score - min_score < 1e-6: return np.zeros_like(scores) return (scores - min_score) / (max_score - min_score) def fuse_scores(candidate_ids, bm25_scores, vector_scores, vector_indices, alpha=0.4): # 构建候选集的分数数组 bm25_candidate_scores = np.array([bm25_scores[i] for i in candidate_ids]) vector_candidate_scores = np.zeros(len(candidate_ids)) # 映射向量分数到候选集 vector_score_map = {idx: score for idx, score in zip(vector_indices, vector_scores)} for i, cid in enumerate(candidate_ids): vector_candidate_scores[i] = vector_score_map.get(cid, 0.0) # 归一化 norm_bm25 = normalize_scores(bm25_candidate_scores) norm_vector = normalize_scores(vector_candidate_scores) # 加权融合 fused = alpha * norm_bm25 + (1 - alpha) * norm_vector return fused这段代码我调过很多次。最开始忘了做归一化,结果 BM25 分数完全主导,向量信号被淹没。后来加了归一化,但用的是全局 min/max,导致不同查询的分数不可比。最后改成候选集内归一化,效果才稳定下来。
4.5 重排序与护栏的串联执行
融合排序后取 Top 50,送入 ColBERT 精排取 Top 20,再送入 Cross-Encoder 取 Top 5。然后生成答案,最后过护栏。
from FlagEmbedding import FlagReranker reranker = FlagReranker('bge-reranker-v2-m3', use_fp16=True) def rerank(query: str, documents: List[Document], top_k: int = 5): pairs = [[query, doc.content] for doc in documents] scores = reranker.compute_score(pairs, normalize=True) for doc, score in zip(documents, scores): doc.rerank_score = score documents.sort(key=lambda x: x.rerank_score, reverse=True) return documents[:top_k] def guardrail_check(response: str, context: str) -> dict: # 规则检查 rule_pass = check_rules(response) if not rule_pass: return {"passed": False, "reason": "rule_violation"} # 引用检查 citation_pass = check_citation(response, context) if not citation_pass: return {"passed": False, "reason": "no_citation"} # 一致性检查 consistency_pass = check_consistency(response, context) if not consistency_pass: return {"passed": False, "reason": "inconsistent"} return {"passed": True, "reason": "ok"}护栏检查的顺序很重要。规则检查最快,放最前面;引用检查次之;一致性检查最慢,放最后。任何一个失败就短路返回,不浪费计算资源。
5. 常见问题与排查技巧实录
5.1 检索命中率忽高忽低怎么办
这是最常见的问题。表现是同一个查询,有时候能召回正确文档,有时候不能。排查思路分三步:先看 BM25 和向量召回的候选集是否有交集,如果没有交集,说明两路召回都没抓到正确文档,需要检查分词质量和向量模型是否适配领域;再看归一化后的分数分布,如果分数集中在 0.9 以上,说明区分度不够,需要调整 alpha 或换重排序模型;最后看重排序模型的输入长度,如果文档被截断得太厉害,关键信息可能丢失。
我遇到过一次,原因是分词器把“年假”切成了“年”和“假”,BM25 完全匹配不上。后来加了自定义词典,把业务术语都加进去,问题就解决了。所以分词词典一定要根据业务领域定制,不能直接用默认的。
5.2 护栏误杀率太高怎么调
护栏误杀是指正常回答被判定为违规。这通常是因为阈值设得太严。引用检查的相似度阈值从 0.75 降到 0.65,一致性检查的 NLI 置信度从 0.9 降到 0.8,误杀率能降一半,但漏杀率会上升。这个平衡点需要根据业务容忍度来定。
我的经验是,先统计一周的护栏日志,看看被拦截的回答里有多少是真正违规的。如果误杀率超过 10%,就放宽阈值;如果漏杀率超过 5%,就收紧阈值。不要拍脑袋定阈值,要用数据说话。
5.3 重排序模型推理太慢怎么优化
Cross-Encoder 的推理速度是瓶颈。优化手段有三个:一是用量化版本,bge-reranker-v2-m3有 FP16 和 INT8 版本,INT8 版本速度能快 2 到 3 倍,效果损失很小;二是减少送入重排序的文档数量,粗排取 Top 50 就够了,取 Top 100 只会增加延迟,对最终效果提升有限;三是用批处理,把多个查询的重排序请求攒在一起推理,GPU 利用率能提高不少。
我实测过,FP16 版本在 T4 GPU 上,50 个文档对的重排序耗时约 80ms,INT8 版本约 35ms。如果对延迟敏感,INT8 是更好的选择。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 检索结果与查询无关 | 向量模型不适配领域 | 检查向量相似度分布 | 换领域适配的向量模型 |
| BM25 分数完全主导 | 未做归一化 | 打印归一化前后分数 | 候选集内 Min-Max 归一化 |
| 重排序后效果变差 | 重排序模型与粗排模型不匹配 | 对比重排序前后 NDCG | 换重排序模型或调整融合权重 |
| 护栏频繁误杀 | 阈值过严 | 统计误杀率和漏杀率 | 根据数据调整阈值 |
| 输出引用错误 | 引用检查器阈值过低 | 检查引用相似度分数 | 提高引用检查阈值 |
| 系统延迟高 | 重排序模型推理慢 | 分段计时 | 量化模型或减少候选数量 |
注意:护栏的日志一定要保留,至少保留 30 天。这些日志是调优的依据,也是排查线上问题的证据。我一般会把护栏的输入输出、检查结果、耗时都记下来,存到 Elasticsearch 里,方便检索和分析。
6. 一些踩坑之后的个人体会
这套系统跑了大半年,最大的感受是:RAG 的瓶颈往往不在生成模型,而在检索和验证环节。很多人花大量时间调 prompt,却忽略了检索质量才是决定上限的因素。BM25 和向量的融合、重排序模型的选型、护栏的阈值调整,这些环节的优化收益远比换一个更大的生成模型要高。
另一个体会是,类型化真的能救命。有一次线上出了个 bug,某个文档的bm25_score字段是 None,导致排序结果完全乱掉。因为有了 Pydantic 的类型校验,日志里直接报了字段缺失的错误,我五分钟就定位到了问题。如果没有类型校验,可能要排查半天。
最后分享一个小技巧:护栏的规则库我建议用 YAML 文件管理,而不是硬编码在 Python 里。这样运营人员可以随时更新规则,不用等开发排期。YAML 文件可以热加载,改完立即生效,非常方便。我现在的规则库有 200 多条规则,覆盖了竞品名称、敏感承诺、合规话术等场景,全部由运营团队维护,开发只需要保证加载逻辑正确就行。
这套方案还在持续迭代,下一步我打算把重排序模型换成基于 LLM 的 Listwise 重排,看看效果能提升多少。不过那是另一个故事了,等跑出数据再分享。