RAG上线三天被骂下来,我用两周把它救成了部门MVP
RAG上线三天被骂下来,我用两周把它救成了部门MVP
上周五下午四点半,产品经理冲进我们工位,脸色铁青:"客户说你们那个智能问答在胡说八道,合同金额都敢编,法务已经介入了。"
我当时脑子嗡的一下。这可是我们花了三个月搭的RAG系统,上线才三天。
出事的那天
事情是这样的。我们给内部法务团队做了一个合同条款查询系统,核心逻辑很简单:把历史合同文档灌进向量数据库,用户提问的时候检索相关片段,拼到prompt里让大模型生成回答。
demo阶段效果惊艳,领导拍板直接上生产。
上线第一天,法务同事问:"我们和A公司的框架协议里,违约金条款是怎么约定的?"
系统回答得头头是道,引用了具体条款编号,金额精确到小数点后两位。
问题是——那个条款编号根本不存在,金额是大模型自己编的。
更要命的是,因为回答里带着"根据合同第X条"这种看起来很权威的表述,法务同事差点就信了。要不是她顺手翻了原件核对,这个假信息可能就进入了正式法律意见书。
第一反应:切分粒度太粗了
我最初以为是文档切分的问题。我们用的是最朴素的方案:
# 最初的切分方案——现在看简直是灾难
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50
)
chunks = splitter.split_documents(documents)
500个token一切,看起来没毛病。但合同文档有个特殊性:一个条款可能跨越多个段落,包含定义、条件、例外情况。500 token一切,经常把一个完整的条款切成两半,模型拿到的上下文是残缺的。
我把chunk_size调到1500,overlap调到200,重新跑了一遍。
效果好了一点,但幻觉问题依然存在。模型还是会"脑补"它没看到的内容。
真正的问题:检索本身就有问题
调了一周切分参数,效果始终上不去。我开始怀疑是不是检索环节出了毛病。
写了个脚本,随机抽了50个真实查询,打印出每个查询检索到的top-5片段,逐条人工核对相关性。
结果吓我一跳:50个查询里,有23个查询的top-1结果和用户问题根本不相关。检索命中率不到54%。
模型拿到一堆不相关的文档片段,当然只能靠自己"编"了。这就好比你让一个实习生去找资料,他找回来的全是不相干的文件,但你还要求他必须给出答案——他除了瞎编还能怎么办?
问题出在两个地方:
第一,纯向量检索对精确匹配无能为力。 用户问"违约金条款",向量检索可能返回语义相近但内容不同的段落,比如"赔偿金条款""定金罚则"。语义上确实相关,但不是用户要的那个。
第二,合同文档里大量专业术语和数字,向量embedding模型对这些根本不敏感。 "合同金额500万"和"合同金额5000万"在向量空间里的距离可能近得离谱。
改造方案:混合检索 + 重排序
想明白之后,我把检索架构从单一向量检索改成了三阶段流水线:
# 第一阶段:BM25召回(关键词精确匹配)
from rank_bm25 import BM25Okapi
bm25 = BM25Okapi([doc.page_content.split() for doc in all_docs])
bm25_results = bm25.get_top_n(query.split(), all_docs, n=20)
# 第二阶段:向量检索(语义匹配)
vector_results = vectorstore.similarity_search(query, k=20)
# 第三阶段:Cross-Encoder重排序
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
pairs = [[query, doc.page_content] for doc in candidate_docs]
scores = reranker.predict(pairs)
# 按分数重新排序,取top-5
BM25负责精确关键词匹配,解决"违约金"vs"赔偿金"这种语义相近但语义不同的问题。向量检索负责语义匹配,解决用户换一种说法提问的情况。Cross-Encoder重排序把两路结果融合,选出真正最相关的片段。
这套方案上线后,检索命中率从54%飙到了89%。
但还不够:prompt工程才是临门一脚
检索准了,模型还是会偶尔"自由发挥"。因为它不知道自己该在什么范围内回答。
我加了一套严格的prompt约束:
system_prompt = """你是一个合同条款查询助手。回答规则:
1. 只根据提供的参考文档回答,不要使用你的预训练知识
2. 如果参考文档中没有相关内容,直接回答"未找到相关条款"
3. 引用条款时必须原文引用,不要改写或概括
4. 涉及金额、日期、百分比等数字时,必须原文引用
5. 不确定的内容标注"待核实"
参考文档:
{context}
"""
关键就两点:一是明确告诉模型"没有就别说",二是数字必须原文引用。别小看这两条约束,它把幻觉率又压低了60%。
最后一道防线:自检校验
即便做了以上所有,我依然不信任大模型的输出。毕竟这可是法律场景,错一个数字就是大事。
所以我加了一层后处理校验:
def validate_answer(answer: str, context_docs: list[str]) -> dict:
"""校验回答中的关键信息是否在原文中有据可查"""
combined_context = " ".join(context_docs)
# 提取回答中的数字
numbers_in_answer = re.findall(r'[\d,]+\.?\d*', answer)
unverified = []
for num in numbers_in_answer:
if num not in combined_context:
unverified.append(num)
return {
"verified": len(unverified) == 0,
"unverified_numbers": unverified
}
如果回答里出现了原文中不存在的数字,系统会自动在回答末尾加上警告:"⚠️ 以下数字未在原文中找到依据:XXX,请人工核实。"
改造效果
两周后重新上线,跑了两周的数据:
法务团队的态度从"这玩意儿不靠谱"变成了"帮我查一下那个条款"。部门季度总结的时候,这个项目被点名表扬。
我踩过的坑,你不用再踩
回过头看,这段经历给我最大的教训就三条:
一、向量检索不是万能的。 它擅长语义模糊匹配,但对精确关键词、专业术语、数字这些"硬信息"表现很差。生产环境一定要上混合检索。
二、大模型是"高级CLI工具",不是"可靠的信息源"。 别指望它自觉不编,必须在prompt层面硬约束,输出层面加校验。把它当成一个随时可能NullPointerException的第三方依赖来对待就对了。
三、demo效果好≠生产能用。 demo阶段用的都是精心挑选的测试用例,真实用户会问出你想不到的问题,用你想不到的表述方式。上线前一定要用真实流量做灰度测试。
关注「安全值班室」公众号
每天一篇AI安全早报 + 实战攻防案例
浙公网安备 33010602011771号