本地跑 llama.cpp 的人做 RAG,多半会在几个地方卡住:切块太粗召回不到、向量模型和生成模型不是一套服务、检索结果一股脑塞进上下文把窗口挤爆、模型没套对对话模板导致答非所问、CPU 上慢得以为程序卡死。这篇不讲大道理,按「检索段」和「生成段」拆开,把每个坑的成因、判断标准和不适用的情况说清楚,最后给一份从能跑到跑好的排查顺序。
本地用 llama.cpp 跑 RAG,很多人卡住不是因为模型不行,而是链路里某一段悄悄坏了:召回结果本身就看不懂,却一直在调 prompt;上下文塞爆被静默截断,以为是模型笨;对话模板没配套,模型开始自问自答。这些问题的共同点是都不会报错,只表现为「回答怪怪的」。
下面按检索段和生成段拆开讲,每节给一个能当场验证的判断标准,最后给一份从能跑到跑好的排查顺序。这里不写死任何数字,涉及具体参数和开销,请到站内对应页面核对当时的值。
先把链路拆成两段:检索和生成不是一个进程的事
检索段做切块、向量化、召回,生成段只负责拼 prompt 和调 llama.cpp 推理。这两段可以用不同语言、不同进程甚至不同机器,中间用一条 JSON 或 HTTP 接口隔着。拆开的好处是出问题能定位:到底是没捞到正确材料,还是捞到了但模型不会用。
最常见的坏习惯是让 llama.cpp 的主生成模型顺手做 embedding。主模型的向量不是为相似度检索训练的,召回质量会明显打折,而且它一边做向量一边生成,两边互相排队抢资源。正确做法是单独起一个 embedding 服务,哪怕跑在同一台机器上也算两套东西。
| 链路环节 | 出问题时的表现 | 先看哪里 |
|---|---|---|
| 切块 | 召回片段缺前因后果 | 打印块内容肉眼读 |
| 向量化 | 相似度排序离谱 | 固定问题查前几条 |
| 召回条数 | 回答缺后半段 | 查上下文窗口配置 |
| 对话模板 | 自问自答、复述指令 | 看模板与模型是否配套 |
切块:切太粗召回不到,切太碎模型拼不出意思
按固定字数硬切是最省事也最容易出事的做法。它会把一张表格切成两半、把一段代码从中间截断、把结论和前提分开。检索命中的时候看着像中了,实际给模型的是半句话,模型只能猜。
更稳的做法是顺着文档结构切:按标题层级先分大类,再按段落或问答对切小段;每块头部带上来源文件名和所在小标题。这样回答被要求给出处时,有东西可引用,事后排查也知道是哪份材料出的问题。
- 块与块之间留少量重叠,避免一句话被切断后两边都读不通
- 表格和代码块尽量整块保留,宁可超一点长度也不要在中间切
- 每块附加来源元信息:文件名、标题路径、块序号
- 短问答型资料就按一问一答切,别硬套多层标题
不适用的情况要说清:如果知识库本身就是一条条独立短问答,强行做层级切块反而把完整意思拆散,检索指标未必更好。判断标准是拿几条有标准答案的问题去跑,看命中的块拼起来能不能直接回答问题。
向量化服务单独起,别和推理抢同一个端口
llama.cpp 的 server 模式跑 embedding 和跑生成是两套参数,混在同一个实例里,并发请求容易互相拖到超时。建议 embedding 单独起一个进程,端口分开,日志分开,这样检索段慢的时候一眼能看出来。
另一个硬约束是向量维度和索引库必须对齐。换 embedding 模型等于重建整个索引,这件事要提前想好怎么重建、重建期间服务怎么降级,别等上线了才发现旧索引全废。
- 固定一句测试话从知识库里挑一句原文,拿它去检索,看前几条里有没有这句原文本身。
- 看相似度分布如果前几条相似度都挤在一起分不开,多半是向量质量问题或切块太粗。
- 换模型先建小索引换 embedding 模型时先在几十条样本上重建,验证召回没问题再全量重建。
- 留重建预案记录当前索引对应的模型和维度,写进配置,避免以后没人记得当初用的哪个。
把召回结果塞进上下文:塞多少是判断问题不是越多越好
llama.cpp 的上下文窗口是显式配置的参数。塞爆了通常不报错,而是悄悄截断,表现是回答只覆盖前半段材料,或者后半段问题直接答偏。所以召回条数不能凭感觉,得先按 token 估算留出生成余量,再决定塞几条。
原则是宁少而准。召回十条里有八条不相关,不只是浪费窗口,还会把模型带偏,让它引用错材料。可以先按相似度卡一个阈值,过线的才进 prompt;过线太少就说明检索段还有问题,不该在生成段硬凑。
- 先算 prompt 占用:系统提示加召回片段加用户问题,估一个总量
- 给生成留余量,别把窗口占满,否则回答中途被截
- 召回条数做成配置项,改的时候能回滚
- 不同条数下的实际开销到 /tools/token-price-calculator 估一下,别凭感觉
对话模板和系统提示没套对,模型会用错角色说话
llama.cpp 支持多种对话模板,模板和模型不配套时,症状很典型:模型自问自答、把系统指令当正文复述、回答里冒出不存在的角色标签。这不是模型能力问题,是格式喂错了。
检索内容放在系统消息还是用户消息,也会影响模型愿不愿意引用来源。放在用户消息里并明确要求标注出处,通常比塞进系统提示更稳;但如果你需要严格约束行为,系统消息仍然要留。两种都试一遍看输出稳定性。
| 症状 | 可能原因 | 验证方式 |
|---|---|---|
| 自问自答 | 模板与模型不配套 | 换配套模板再问同一句 |
| 复述指令 | 指令写进了正文位置 | 把指令移到系统消息 |
| 硬编答案 | 缺少「不知道就说不知道」约束 | 问一个库里明确没有的问题 |
| 不给出处 | 没要求引用或来源没带进块 | 检查块里有没有来源元信息 |
速度幻觉:慢不一定是 bug,先分清是加载慢还是生成慢
第一次请求特别慢,多半是模型加载和缓存没热,跟 RAG 链路没关系,别急着改检索。区分方法很简单:连续发两次同样的请求,第一次慢第二次明显快,那就是加载问题,不是链路问题。
生成慢要分两种情况:一是 prompt 被召回条数撑长了,长 prompt 在本地推理里就是线性变慢;二是硬件本身吃不下这个模型规模。前者改召回条数就能缓解,后者改代码没用。
- 加载慢
- 首次请求慢、重复请求变快,属于模型或缓存冷启动
- 生成慢
- 每次请求都慢,看 prompt 长度和模型规模
- 检索慢
- 生成还没开始就卡住,看 embedding 服务的日志
硬件够不够,别拿别人的机器预期套自己。可以到 /models/deploy 按模型规模反推所需资源,再对照自己的配置判断。想要一个粗略的体感基准,也可以用 /tools/speed-test 跑一次看当时的数字。
从能跑到跑好的排查顺序
排查最忌讳一次改三处,改完不知道是哪处生效。按下面顺序单点验证,每一步只动一个变量,跑通了再进下一步。
- 只看检索打印召回片段和相似度,肉眼判断相关性;这一步不过关,后面全白搭。
- 只看生成把召回片段手工粘进 prompt,确认模型能拿这些材料作答,排除模型本身的问题。
- 再看拼接和模板检查角色分配、分隔符、上下文长度,确认没有静默截断。
- 做回归固定几条问答当测试集,任何一环改动后重跑一遍,别凭一次对话下结论。
这套顺序不适用的情况也要说:如果你的知识库极小、问题类型很单一,可能不需要完整 RAG,直接把全部材料塞进 prompt 反而更稳更快。这时先判断值不值得搭检索,再谈优化。
另外,换模型、换 embedding、改切块策略这类变动,都会让旧的回归结果失效,需要重新建立基线。把这些变动记录在配置里,比事后回忆省事得多。
下一步把召回和生成两段的日志先分开,再拿几条固定问答跑一遍,结果会告诉你该往哪个方向改。→常见问题
llama.cpp 里能不能直接用它的 embedding 接口做检索?
能,但要单独确认它加载的是向量模型而不是对话模型,且向量维度和你建的索引一致。很多人失败是因为拿生成模型顺手做向量化,相似度排序完全没意义。建议把向量化和生成分成两个实例,各自调参互不干扰。
为什么检索到了正确片段,模型还是答错?
多半是拼装环节:片段被截断、角色消息用错、或者指令里没说清「只能依据材料回答」。把召回片段手工粘进对话先验证一次,能答对就说明问题在拼接模板,答不对再回头查模型本身和提示词。
本地跑 RAG,召回条数给几条合适?
没有通用数字,取决于你的上下文配置和单块长度。做法是先估一块大概占多少 token,留出生成余量后反推条数,再拿几条已知答案的问题试。条数增加但准确率不再涨,就是该停的地方;检索不准时加条数通常只会更糟。
改了切块大小,需要重建索引吗?
需要。切块决定入库内容,旧索引里的片段不会自动跟着变,混着用会出现新旧两种粒度互相打架。换 embedding 模型同理。建议把切块参数和嵌入模型版本记在配置里,改动时一起走重建流程。
CPU 上跑能撑住 RAG 问答吗?
看并发和资料量。单用户偶尔问一句通常能用,多人同时问会明显排队,表现为越问越慢而不是报错。如果主要卡在长 prompt 上,先减召回条数比换硬件见效快;要多人共用再考虑上 GPU,去 /models/deploy 按规模测算一下再决定。