本地跑 llama.cpp 的人想加 RAG,往往卡在同一批地方:塞进去的片段超了上下文被截断、检索回来的东西跟问题不沾边、模型对着资料还是瞎编、多轮对话越聊越慢。这篇按「坑—现象—判断标准—怎么改」的顺序走一遍,每一节都给出能立刻动手的检查项。目标不是教你搭一套完美 RAG,而是让你在出问题时知道先看哪一段。
本地跑 llama.cpp 的人想加 RAG,卡住的地方往往不是模型本身,而是同一批细节:塞进提示的片段把上下文撑爆被截掉、检索回来的东西跟问题八竿子打不着、模型对着资料照样瞎编、聊上几轮首字越来越慢。这篇按「坑—现象—判断标准—怎么改」的顺序走一遍,每节都给能立刻动手的检查项。目标不是教你搭一套完美 RAG,而是出问题时知道先看哪一段。
先把一个前提摆明:llama.cpp 本身只负责推理,检索、切片、拼提示全在你自己的应用层。所以出了问题别第一时间去换模型或者调采样参数,绝大多数毛病在前面几步就埋下了。每节末尾的判断标准,都是你能在一台机器上、用几分钟手工验证的东西,做完再决定要不要动代码。
先分清:坑出在检索、拼装还是生成
整条链路拆成三段:召回(问题进去,片段出来)、拼装(片段和历史塞进提示)、生成(模型吐答案)。三段各有各的失败长相,混在一起调只会越调越乱。最省事的做法是每段留一个可观测信号:召回阶段把召回的原文打印出来,拼装阶段把最终送进模型的完整提示打印出来,生成阶段把输出和引用一起存下来。三个东西都有了,再看是哪一段断了。
- 召回段信号:拿一个你确定答案在库里的问题,肉眼在前几条召回结果里找原文,找不到就是召回的问题
- 拼装段信号:把最终提示原样落盘,数一下字符或 token,看尾部片段是不是被截没了
- 生成段信号:抽查回答里引用的片段编号,逐条对回原文,对不上就是约束没生效
- 排查顺序固定成召回→拼装→生成,不要跳步,也不要一上来改提示词
上下文塞法:以为塞得进,实际被截掉一半
llama.cpp 的上下文窗口是硬上限,系统提示、多轮历史、检索片段、输出预算全从这同一份额度里扣。很多人只盯着「我设了多大窗口」,没算输出要留多少、历史占了多少,结果召回塞进去十条,真正被模型看到的只有最后两三条。典型现象是:回答只覆盖资料末尾那几段,前面明明相关的完全不提。
- 先扣固定开销把系统提示和输出预算先减掉,剩下的才是资料能用的额度;输出预算按你最长的正常回答估,别拍脑袋。
- 再定条数和单条长度用剩余额度除以单条片段长度,得到本轮能塞几条;超了就减条数,不要靠硬截断片段来凑。
- 验证有没有被截把最终提示落盘,看最后一条片段是否完整;如果尾部是半句话,说明已经超了。
| 现象 | 大概率原因 | 先改哪里 |
|---|---|---|
| 回答只引用最后几段 | 提示超窗,前面被丢掉 | 减召回条数或缩短单条 |
| 资料明明在库里却答不出 | 片段被截在半句 | 按结构切片,别按固定字符数 |
| 多轮后质量突然下滑 | 历史挤占了资料额度 | 限制历史轮数或做摘要 |
| 同样问题两次结果差别大 | 拼装顺序或条数不稳定 | 固定拼装模板并打印核对 |
切片粒度:太大稀释相关度,太小丢上下文
切片是最容易被忽略、又最影响召回的一步。切太长,一条片段里混着好几段不相干的内容,向量被平均掉,相关度就稀了;切太短,一句话被拦腰截断,模型拿到半句也拼不出完整意思。判断标准其实很朴素:单条片段的长度大致对齐你留给资料的上下文份额,宁可少而准,不要多而杂。
另外,带标题、表格、代码块的文档不要按固定字符数切。标题要跟着它下面的正文走,表格和代码块最好整块保留,切散了模型根本读不懂。切片策略没有通用最优解,但有一条底线:切完之后随机抽几条出来读,你自己能不能看懂它在说什么;看不懂,模型也看不懂。
检索正确性:先证明能召回,再谈效果
在调任何生成相关的参数之前,先证明检索本身是通的。准备一组你自己领域的真实问题,每个问题你都知道答案在哪份文档里,然后人工核对前几条召回里有没有那个出处。这一步不过关,后面全是白忙。
- 召回对不上,先查向量模型是否和文档语言匹配:中文库配了个偏英文的模型,效果会明显塌
- 再查索引是不是建在最新数据上:文档更新了但没重建索引,召回永远是旧内容
- 还要查查询本身:用户问题太短、全是代词时,先做一轮改写或补全再检索
- 跨多份文档聚合类的问题,单轮向量召回天然吃力,这不是调参能解决的,得换思路或拆问题
什么时候不适用:如果你的问题本质是「把好几份文档里的信息汇总对比」,纯向量召回很难一次命中全部所需材料,这时候要么先把问题拆成子问题分别召回,要么走别的检索路径。别指望把召回条数调大就能解决,条数一多上下文又被挤爆。
生成约束:让模型只答资料里有的内容
模型对着资料照样编,是因为提示里没把「只能依据给定片段」这件事讲死。做法很直接:要求回答标注引用的是第几条片段,找不到依据就明确说不知道,而不是顺着问题往下补。关键是让「不知道」成为一个正常的、下游能识别的返回,而不是当成失败去重试。
- 提示里写清引用规则明确要求每个结论后面跟片段编号,没有编号的句子视为不可信。
- 把不知道设计成正常返回给一个固定句式或固定字段,下游能直接判断这是「没依据」而不是「答错了」。
- 随机抽查验证抽若干条回答,逐句回到片段里找出处;找不到的就记成坏例子,回头改提示或改召回,而不是换模型。
这套约束也有不适用的时候:如果用户问的是开放建议类问题,本来就没有标准答案在资料里,硬要求逐句引出处会显得很别扭。这种情况要提前在提示里区分「资料问答」和「开放讨论」两种模式。
越用越慢:历史、重复编码与常驻内存
多轮对话里无节制地追加历史,会让每轮输入持续变长,在 llama.cpp 侧的表现就是首字延迟一轮比一轮高。另一个隐形开销是重复编码:同一段文本每次请求都重新算向量,纯属浪费。切片和向量都应该提前算好存起来,请求时只做查询编码。
| 判断项 | 正常表现 | 异常说明什么 |
|---|---|---|
| 同一问题连问两次 | 第二次耗时接近第一次 | 差很多,多半有重复计算或缓存没生效 |
| 连续追问若干轮 | 首字延迟基本稳定 | 持续爬升,是历史在挤占上下文 |
| 首次请求与后续请求 | 首次可略慢 | 每次都慢,检查模型加载是否常驻 |
| 长文档入库后查询 | 查询耗时与文档量弱相关 | 随文档量线性暴涨,索引或检索方式有问题 |
内存方面,llama.cpp 侧要留意模型常驻和上下文缓存占用的那部分,别把检索服务的内存也算进去一起看。跑本地的话,最好把推理进程和检索进程分开观察,否则慢了都不知道怪谁。想对照不同机器上的表现,可以到 /tools/speed-test 跑一轮基准,把当时的数字记下来当基线。
上线前的检查清单与判断标准
前面每一节都能单独验,但上线前建议按层过一遍清单。每一条都写成一个「能用手工几分钟验证」的动作,验不过就不发版,别靠感觉。
- 召回层:抽查若干真实问题,正确出处能进前几条
- 拼装层:把最终提示打出来看一遍,确认没被截断、没混进无关片段
- 生成层:抽查回答是否带出处、无依据时是否明确说不知道
- 性能层:连续提问下首字延迟是否稳定,同一问题两次耗时是否接近
- 数据层:文档更新后索引是否重建,切片是否与最新内容一致
判断标准不要写成百分比目标,写成二值判断更好执行:能或不能。比如「正确出处是否出现在前几条召回里」,答案是能或不能;「回答里每句是否有出处」,也是能或不能。连续几次都是「能」,这条才算过。凡是拿不准的,都当作没过。
如果你还在挑本地推理的部署方式和硬件配置,别只盯着模型本身,先到 /sites 看看不同中转和本地方案的对比维度,再决定自己的链路怎么搭;模型资料和价格这种会变的数字,到 /price-watch 核对当时的数值,别拿旧印象做决策。
下一步把最终提示打印出来看一眼,是排查 RAG 问题成本最低的一步;看完再决定动召回还是动生成。→常见问题
llama.cpp 自带的检索能力够用吗,还要不要外挂向量库?
小规模、文档更新不频繁时,本地简单索引够用。文档上量、需要按语义匹配或多种过滤条件时,外挂一个向量库更好维护。判断点在于你能否接受召回质量随文档增长明显下滑。
为什么明明把资料塞进去了,模型还是答偏?
多半是资料被上下文截断,或者片段里噪声太多把关键句淹没了。先把最终提示完整打印出来看一眼,确认关键段落真的在里面且在靠前位置,再考虑换写法。
检索质量差的排查顺序是什么?
先查片段本身是否切得合理,再查向量模型与文档语言是否匹配,最后查索引是否最新。按这个顺序查,比一上来就换模型或加重排更省时间。能定位到哪一步出问题,再决定要不要上更复杂的方案。
本地跑 RAG 需要多大的上下文窗口?
取决于你打算塞几条片段、每条多长。先把输出预算和系统提示扣掉,剩下的才是资料份额。窗口不够时优先减少片段条数、提高单条质量,而不是硬塞。具体显存与吞吐换算可以到 /models/deploy 按你所用的模型规模过一遍。
什么时候这套做法不适用?
问题需要跨大量文档做汇总统计、或者要求严格数值计算时,纯检索增强不靠谱,要配合工具调用或结构化查询。另外文档更新极频繁的场景,索引维护成本会盖过收益。