手写 RAG 知识库问答智能体:混合检索 + 查询改写 + 抗幻觉,黄金集实测全过

从 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

每次改代码、换配置、换模型,重跑一遍,分数不降才上线------防退化靠门禁,不靠救火。

六、几个小坑(真·血泪)

  1. 评测误判:最初用关键词规则判拒答,检测词"餐厅"撞上模型复述问题里的词,误杀正确答案------改成裁判 LLM 判断"拒答了吗 + 编造了吗"两个维度
  2. Starlette 的流式伪装 :同步生成器会被 StreamingResponse 包成 async generator,直调接口做测试时必须 async for + asyncio.run,否则 'async_generator' object is not iterable
  3. Windows 文件名 GBK :curl 上传中文文件名会乱码,浏览器不受影响,用 encode("latin-1").decode("gbk") 兜底修
  4. 切块大小一变,向量库必须清库重建:旧块边界对不上,混存造成重复检索

七、总结与后续

完整代码开源:**github.com/xzh-support... README 快速开始 + 评测脚本)

后续计划:表格/图片解析、权限管理、rerank 模型、数据看板。

如果你也在做 RAG 方向,建议直接跑一遍我的评测脚本------8 道题里有 5 道是"看似简单、实则专打多轮检索"的题,比抄架构图有收获得多。

相关推荐
心易行者2 小时前
html在线运行搭AI编程验证流水线:5步走完从生成到到上线全流程
人工智能·python·ai编程
xiezhr2 小时前
别把豆包当聊天用了,现在的豆包和以前不一样了
agent·ai编程·豆包marscode
路多辛2 小时前
为什么用 Go 写 AI Agent,covo-agent 的选型思考
开发语言·golang·agent·ai编程
神奇霸王龙13 小时前
Cursor 3 + Claude Opus 4.8 屠榜:5 编程基座 IDE 卡位
ide·人工智能·ai·aigc·agent·ai编程·ai写作
子昕14 小时前
牛来原来是智谱,GLM-5.3-Flash 实测有惊喜也有硬伤
ai编程
OpenTiny社区15 小时前
Naive UI × GenUI SDK:自定义物料库搭建实战
前端·ai编程
kyriewen15 小时前
我拿 4 个真实前端任务试了 GLM-5.3 Flash:代码一遍跑通,账单 4 分钱
前端·程序员·ai编程
zhangfeng113315 小时前
AMD Instinct MI50(gfx906)上为 Qwen 系列模型优化并可用的 vLLM 相关仓库、Docker 镜像与实践指南。
人工智能·docker·ai编程·qwen·算子开发·vllm·mi50
机构师15 小时前
AI编程实战:效率与成本,AI 编程的 ROI 怎么算
人工智能·prompt·ai编程·deepseek