3.2 RAG 检索:本地知识库问答

场景:模型不懂我们的内部黑话

ask_llm 跑通没多久,同事就拿来一个需求:团队有一批内部文档——服务器巡检手册、报销流程、历史工单记录——能不能做一个问答机器人,新人遇到问题直接问,不用再翻 Excel 和飞书链接?

直接问模型是不行的。它不知道我们的工单编号规则,也不知道巡检脚本的某个参数为什么那么填。一开始我试了最笨的办法:把文档整个塞进提示词,结果两万字的文档直接顶满上下文窗口,回答还经常张冠李戴。这就是 RAG(检索增强生成)要解决的问题:先检索出与问题最相关的几段文档,再把它们和问题一起交给模型,让模型只基于这些材料回答。

方案:切块、向量化、检索、回答

RAG 的标准流水线四步走:文档切块、向量嵌入、相似度检索、拼提示词生成。工具选型上我刻意避开重型框架,用 sentence-transformers 做本地向量模型,FAISS 做向量索引,整套都在一台轻量服务器上跑得动,没花额外的云服务费用(后来流量大了才考虑迁移,部署细节见轻量服务器建站指南)。

先看切块。这是最容易被忽视、却决定检索上限的一步:

from pathlib import Path

def chunk_text(text, size=400, overlap=60):
    chunks = []
    start = 0
    while start < len(text):
        end = start + size
        chunks.append(text[start:end])
        start = end - overlap   # 留重叠,保住被切断的上下文
    return [c for c in chunks if len(c.strip()) > 50]

def build_index(doc_dir):
    docs = []
    for f in Path(doc_dir).glob("*.md"):
        text = f.read_text(encoding="utf-8")
        for i, c in enumerate(chunk_text(text)):
            docs.append({"file": f.name, "chunk_id": i, "text": c})
    return docs

overlap 是关键参数:巡检手册里一句“该参数默认 300,超时场景调到 900”横跨两块时,重叠能保证至少有一块完整包含它。

检索与回答

向量化和检索的部分,核心是“问题向量化后找最近邻”:

import numpy as np, faiss
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("bge-small-zh-v1.5")

def embed(texts):
    return model.encode(texts, normalize_embeddings=True)

def search(docs, query, top_k=3):
    mat = np.vstack([embed([d["text"]]) for d in docs])
    index = faiss.IndexFlatIP(mat.shape[1])
    index.add(mat)
    _, idx = index.search(embed([query]), top_k)
    return [docs[i] for i in idx[0]]

拿到相关段落后,拼提示词有讲究。我加了一句硬约束:“只根据以下材料回答,材料里没有就说不知道。”否则模型会发挥想象力编造流程编号,这在内部问答里是要命的。回答生成直接复用上一节的 ask_llm_stream,流式输出体验很好。

提示词模板大致长这样:系统指令给出角色与约束,检索到的材料用编号列出,末尾接用户问题,并要求答案标注引用编号。编号引用是个小技巧,用户能直接点着编号核对原文,比让模型自述“根据手册第三部分”可靠得多,因为后者 often 连它自己都不确定是哪一份手册。

离线评估:先有尺子再调参

调 RAG 最怕凭感觉。我的做法是先攒一个评估集:从工单系统里挑 40 个真实问句,人工标好每个问题应该命中哪个文档的哪一段,然后跑召回率与命中率两个指标。每次改参数都重新跑一遍,数字不会撒谎。评估脚本很简单,核心就是把检索结果和标注比对:

def evaluate(eval_set, docs, top_k=3):
    hit = 0
    for item in eval_set:
        got = search(docs, item["question"], top_k=top_k)
        if any(g["file"] == item["expect_file"] for g in got):
            hit += 1
    return hit / len(eval_set)

就是这一个函数,把“感觉检索变好了”变成“召回率从 0.62 到 0.81”。后来这套评估办法被推广到工具箱的其他模块,谁改了检索逻辑先跑评估集,成了团队的默认动作。

评估集之外还建议留一份 bad case 清单:每次有明显答错的对话人工标出来,攒到月底集中分析。我们的 bad case 大致分三类——检索没召回(切块或向量模型问题)、召回了但没用上(提示词约束没生效)、材料本身过期(数据治理问题),分类后该改哪一目了然。

参数对照表

把调参时记录的对照表直接搬过来,给同样在做 RAG 的人一个参照:

参数 起始值 调优后 现象与理由
chunk_size 800 400 块太大会稀释相似度,长文档里 Top1 命中差
overlap 0 60 跨块句子被切断,关键上下文丢失
top_k 5 3 多塞材料反而让模型混淆,回答引用串段
向量模型 通用英文模型 bge-small-zh 中文文档用英文模型检索召回率腰斩
提示约束 无 仅限材料回答 不加约束时模型编造不存在的流程号

踩坑与排查

坑一:按固定字数切块,把表格剪碎了。 巡检手册里大量参数对照表,按 400 字硬切后,表格从中间断开,检索到的半张表毫无意义。后来改成结构化切块:先按标题层级切段落,表格整块保留不切,超长表格单独成块。

坑二:检索分数高但答非所问。 有阵子问答机器人对“怎么重启服务”这种通用问题疯狂返回安全规范文档——因为安全规范里“重启”出现频率最高。纯向量检索对高频词敏感,我加了关键词预过滤:先用 BM25 交集筛一遍候选,再做向量精排。效果立竿见影,这也是多轮对话那篇里做意图识别的基础。

坑三:分块没有来源标记。 早期回答里模型说“根据手册第三部分”,但根本没法核对。排查发现是我在拼提示词时丢掉了来源元数据,只知道检索回文本,不知道它来自哪个文件。补上 file 与 chunk_id 并要求模型在答案里标注来源后,可信度才上来。这种小疏漏在代码审查那篇里专门讲过,AI 生成的 RAG 代码十有八九会漏这一步。

坑四:索引更新不一致。 文档改了以后问答结果还是旧的,以为是缓存,查了才知道向量索引每次启动才全量重建,热更新没做。临时方案是给文档目录加 md5 快速比对,变了才重建,变更日志落盘。规模再大点就该上独立的向量服务了。

坑五:冷启动向量模型慢。 sentence-transformers 第一次加载要下载模型权重,国内网络偶尔超时。后来把模型文件放到对象存储 COS 上,启动时先拉本地再加载,这套存储方案在后续COS 文件存取那篇里详细展开。

坑六:问答两套口径。 机器人说流程 A 已废弃,但相关文档没同步删除,新旧两段都被检索出来,模型一会儿照旧的说一会儿照新的说。治理办法是文档侧加生效状态字段,检索时过滤掉已废止版本,而不是等回答撞车了再去补。数据治理欠的债,RAG 一定加倍还回来。

小结

RAG 的本质是用检索给模型划定回答范围,效果七分看切块、两分看检索、一分看提示词。最值得投入的地方是数据准备:把文档结构理清、表格保持完整、来源标记贯穿全链路,比换更贵的向量模型管用得多。做完这一节,问答机器人已经成了团队新人入职的第一站,下一步自然是让它能动手干活——这正是Function Calling要解决的问题。上云运行的环境准备,建议同步翻Serverless 部署入门。

延伸阅读