在自己的服务器上搭一套文档问答:全流程与踩坑
切块、向量库、召回、重排、生成、引用——把六个环节逐个拆开,记录每一处真正影响效果的细节。
需求很实在:几十页中文技术资料,希望提问时能拿到有出处的回答,而不是模型凭印象编。全文塞进长上下文我也试过,参数名编得很流畅。所以走了检索这条路。
一、六个环节,重要性不平均
| 环节 | 我的默认做法 | 出问题时的表现 |
|---|---|---|
| 解析 | 按标题层级保留结构 | 表格拍平后语义丢失,检索命中但答案错 |
| 切块 | 按段落聚合到 400~600 token,重叠 12% | 命中半句话,指代对象在前一块 |
| 向量化 | 本地 bge 中文模型 | 与查询分布不匹配,召回离谱 |
| 召回 | 向量 top-20 + BM25 top-20 合并去重 | 纯向量漏掉精确实体、型号、编号 |
| 重排 | cross-encoder 取 top-3 | 相关但不关键的内容挤掉正确答案 |
| 生成 | 温度 0 + 强制引用 + 允许拒答 | 输出与材料无关的内容 |
影响最大的是切块和召回,不是模型。我从 7B 换到更大的模型,效果提升远小于把"纯向量"改成"向量 + 关键词混合"。
二、切块:把上下文写进块里
按字数硬切是最常见的错误起点。我现在的做法:
- 优先在段落边界切,凑到目标长度为止;
- 每块前面拼
"《文档标题》> 《章节标题》\n"再编码。
第二条是我收益最明显的改动。很多块的单独语义是残缺的——"该参数需设置为 true"里的"该参数",脱离章节就没有指向。加上标题之后,短块的召回质量明显改善。
表格是硬骨头。拍平成纯文本基本等于毁掉。我的处理是把每行转成一句自然语言("字段 X,类型 Y,默认值 Z,说明 W"),检索单位从"块"变成"行"。这方案不优雅但有效。
三、混合召回:向量不是万金油
纯向量有一个我一直没预料到的弱点:它对必须字面命中的东西不可靠。搜具体型号、错误码、函数名,它能给你召回一堆"意思相近"的同族内容,就是不给那一条。
所以加一路关键词检索。Postgres 上是 tsvector + zhparser 中文分词,代码里:
SELECT id, content, ts_rank(search, query) AS rank
FROM chunks, plainto_tsquery('zhcfg', $1) query
WHERE search @@ query
ORDER BY rank DESC LIMIT 20;
两路结果用 RRF(Reciprocal Rank Fusion)合并,不用去纠结怎么把余弦相似度和 BM25 分数放到一个尺度上:
// RRF:只看名次不看分数,对两路量纲差异天然免疫
const K = 60;
const score = new Map();
for (const list of [vectorHits, keywordHits]) {
list.forEach((hit, i) => {
score.set(hit.id, (score.get(hit.id) || 0) + 1 / (K + i + 1));
});
}
const merged = [...score.entries()].sort((a, b) => b[1] - a[1]);
这个改动把我评测集里的 recall@5 拉高了一档,是整件事里性价比最高的一步。
四、重排:慢,但值得
双塔式的向量召回里,查询和文档各自编码、彼此看不见,快但粗。cross-encoder 把"查询 + 文档"拼在一起过模型,能看见交互,准但慢。所以典型搭配是召回几十条、重排留几条。
代价说清楚:每个候选都要过一次模型前向。20 个候选就是 20 次,这一步在我的机器上通常比生成还慢。想压低延迟,先减候选数,别急着换小模型。
五、生成:拒答比编造值钱
提示词里三条硬要求:
- 每条结论必须给依据(块 ID + 原文片段);
- 材料没有就说"材料未覆盖";
- 不复述材料之外的行业惯例。
第二条最容易被忽略,但它决定了这套东西能不能用。一个不会拒答的问答系统,会把"没查到"和"不存在"混成一类,用户分不清,这种错误比明显答错更贵。
伪引用是必然要抽查的。我最初的实现里引用能对上号但内容对不上,因为我把整段拼接后一起给模型,它自由发挥了。修法不是提示词里骂它,是程序化校验:引用片段必须真是那个块的子串。这条校验上线后,"看起来有据但其实编造"的输出在我这边的发生频次明显下降。
六、别跳过的评测
先攒 30~50 个真实问题,标好"正确应该召回哪几块",之后每次改动只跑 recall@k 和 MRR。有了这两个数,争论才有落脚点:
- 召回率不够 → 查切块和混合检索,别换生成模型。
- 召回命中但答错 → 查上下文顺序和提示词,考虑重排。
- 答对但引用为空或不对 → 查校验逻辑,这是代码问题。
七、这套东西的适用边界
RAG 不适合解决"模型缺乏常识"或"任务需要复杂推理",它只适合"知识在我手里、量大、要新、要出处"的场景。如果知识少且稳定,写进提示词就行;如果需要模型学新的表达风格或输出格式,那是另一件事。
这个判断我花了不少时间才建立起来——因为"文档问答"听起来万能,很容易什么都往里塞。
内容标识:本文正文与脚本为本人编写;文中效果对比来自我自己的 40 题评测集,仅说明方向,不是通用结论。
本文出处:http://honeysss.cn/post/rag-on-2g-vps.html 转载请注明出处。