从 0 手写一个企业知识库问答智能体:RAG 全链路 + 抗幻觉 + 评测门禁
手写 RAG 知识库问答智能体:混合检索 + 查询改写 + 抗幻觉,黄金集实测全过
一、为什么做这个
企业里有大量沉睡的文档:规章制度、技术手册、操作规范。新员工入职对着几十份文件反复问人,资深员工沦为"人肉客服",跨部门之间还容易因口径不一产生误会。
通用大模型能聊天,但不懂企业内部知识;敏感文档又不能传公有云。于是有了「灵知」:企业上传内部文档 → 切块向量化入库 → 员工用自然语言提问 → 流式回答 + 原文出处,私有化部署,数据不出企业。
技术栈:FastAPI + ChromaDB + Redis + DeepSeek + bge-m3,全部手写实现,不依赖 LangChain。
二、整体架构
perl
浏览器(上传/问答界面)
│ HTTP + SSE
▼
FastAPI 服务
├─ 上传模块 → 切块 → bge-m3 向量化 → ChromaDB
├─ 问答模块 → 查询改写 → 混合检索 → 拼接上下文 → DeepSeek 流式生成
│ ▲
│ Redis 多轮历史 ─────┘
└─ 抗幻觉约束:system prompt 强制"只根据资料回答,没有就拒答"
RAG 的链路人人都能画,真正的区别全在多轮场景下的检索质量。下面三个问题是我踩过、填过的坑。
三、三个真实问题与解法
坑 1:多轮追问全是代词,检索必然跑偏
用户先问"JWT 是什么",接着问"它和 Session 的区别?"------单独拿"它和 Session 的区别"去向量检索,什么都搜不回来,因为"它"没有语义。
解法:查询改写(无依赖简化版)------检索前把上一轮问题拼进来补主语:
python
history = get_history(body.session_id)
prev_q = ""
for m in reversed(history):
if m["role"] == "user":
prev_q = m["content"]
break
query = f"{prev_q} {body.question}".strip() if prev_q else body.question
生产做法是让 LLM 改写追问("它"→"JWT"),这里用拼接版,零额外调用。
坑 2:话题切换时,上一轮污染本轮检索
补主语解决了追问,但引入新问题:用户聊完年假,突然问"机房断电后 UPS 能撑多久"。带着"年假"拼接去检索,命中的块全被上一话题带偏。
解法:双路检索融合------带历史一路吃追问,原句一路防切换,轮流取、去重合并,两路都有发言权:
python
route1 = kb.search(query, k=3) # 带历史:吃追问
route2 = kb.search(body.question, k=3) if prev_q else [] # 原句:防污染
seen, sources = set(), []
for a, b in zip(route1, route2): # 两路轮流取
for s in (a, b):
key = (s["source"], s["text"])
if key not in seen:
seen.add(key)
sources.append(s)
坑 3:纯向量检索把"Prompt 缓存"和"缓存一致性"混为一谈
语义相近但关键词完全不同的两个概念,向量距离很近,纯向量检索分不开。
解法:混合检索------先向量粗筛 10 个候选,再用中文二字词滑窗(不引分词库的土办法)算关键词重合度,命中即加分重排:
python
def bigrams(text: str) -> set[str]:
words = re.findall(r"[一-鿿]", text)
return {words[i] + words[i + 1] for i in range(len(words) - 1)}
q_kws = bigrams(question)
scored = []
for d, m, dist in zip(docs, metas, dists):
overlap = len(q_kws & bigrams(d))
score = dist - 0.15 * overlap # 距离越小越相关;命中关键词就减距离
scored.append((score, d, m))
生产级做法是上 rerank 模型(bge-reranker),这是无依赖的简化版。
四、抗幻觉:拒答是功能,不是缺陷
消费级聊天可以编,企业场景编错一条规章制度就是事故。灵知的 system prompt 只有一句话量级的约束:
python
SYSTEM = (
"你是企业知识库问答助手。只根据用户提供的【参考资料】回答;"
"资料里没有的信息,直接说'知识库里没有相关资料',绝对不许编造。"
)
实测效果:问"公司健身房在几楼?"(库内没有),回答就是"知识库里没有相关资料"------"不知道"是产品功能,这是企业场景与消费场景的本质差异。
五、评测:质量不能靠感觉 测评也是产品
做完功能只是开始,怎么证明"检索质量真的行"?搭了一套可重复的评测门禁:
- 黄金集 8 题,覆盖:常规 / 跨块多事实 / 代词追问 / 话题切换 / 库外拒答
- 直接调用线上接口跑真实管道(不是单元测试级别的自嗨),评测库独立于生产库
- 指标分开记:检索命中率 / 生成分 / 拒答正确率------失分能定位到检索层还是生成层
- 生成分用 LLM-as-judge 对照标答打 0/1/2 分;拒答题也用裁判判
实测成绩:检索命中率 6/6,生成分 10/10,拒答正确率 3/3。
每次改代码、换配置、换模型,重跑一遍,分数不降才上线------防退化靠门禁,不靠救火。
六、几个小坑(真·血泪)
- 评测误判:最初用关键词规则判拒答,检测词"餐厅"撞上模型复述问题里的词,误杀正确答案------改成裁判 LLM 判断"拒答了吗 + 编造了吗"两个维度
- Starlette 的流式伪装 :同步生成器会被
StreamingResponse包成 async generator,直调接口做测试时必须async for+asyncio.run,否则'async_generator' object is not iterable - Windows 文件名 GBK :curl 上传中文文件名会乱码,浏览器不受影响,用
encode("latin-1").decode("gbk")兜底修 - 切块大小一变,向量库必须清库重建:旧块边界对不上,混存造成重复检索
七、总结与后续
完整代码开源:**github.com/xzh-support... README 快速开始 + 评测脚本)
后续计划:表格/图片解析、权限管理、rerank 模型、数据看板。
如果你也在做 RAG 方向,建议直接跑一遍我的评测脚本------8 道题里有 5 道是"看似简单、实则专打多轮检索"的题,比抄架构图有收获得多。