
本地知识库问答机器人这个方向我从去年开始陆续折腾过好几套方案从最朴素的“向量检索拼prompt”到后来用框架把整条链路串起来踩的坑基本都集中在两个地方一是文档切分和召回质量二是模型对上下文的“消化能力”。这次拿 LlamaIndex 配 Qwen1.5 做一套完全跑在本地的问答机器人算是把之前零散的经验收拢了一遍。整套东西不依赖任何外部接口文档不出机器模型权重落在本地磁盘适合对数据流向比较敏感、又想快速验证 RAG 效果的场景。下面我按实际搭建顺序拆开讲包括选型理由、关键代码、参数怎么定以及那些文档里不会写、只有真跑起来才会撞上的问题。1. 为什么这套组合值得先跑一遍1.1 LlamaIndex 在 RAG 链路里到底承担了什么很多人第一次接触 RAG会以为核心是“把文档塞进向量库然后搜出来”真做起来才发现检索只是中间一环前后还有一堆琐碎但决定成败的活文档怎么读进来、按什么粒度切、切完怎么变成向量、检索时取几条、取出来怎么塞进有限的上下文窗口、模型答完之后引用怎么标。LlamaIndex 的价值不在于它发明了什么新算法而在于它把这套流程抽象成了可替换的组件——Reader 负责解析、NodeParser 负责切分、Index 负责组织、Retriever 负责召回、QueryEngine 负责编排。我选它而不是自己手搓主要原因是组件边界清晰。比如你后面想换切分策略只动 NodeParser想换向量模型只动 embedding 那层想加个重排序插一个 postprocessor 就行。自己写的话这些逻辑容易糊成一坨改一处牵动全身。另一个实际好处是它对本地模型的支持比较直接Qwen 这类模型通过 HuggingFace 的接口就能挂上去不用绕太多弯。需要说明的是LlamaIndex 版本迭代很快不同版本 API 差异不小。我下面用的写法基于较稳定的核心接口如果你装的版本较新个别类名或参数可能需要对照官方迁移说明微调这是这类快速演进框架的通病心里有数就行。1.2 Qwen1.5 作为本地生成模型的取舍生成模型这块本地能跑的选择其实不少我最终定 Qwen1.5 是几个因素叠加的结果。第一是中文语料的底子好知识库问答大量场景是中文文档模型对中文长句的理解和生成质量直接影响体验这一点上 Qwen 系列的表现比较稳。第二是尺寸档位齐全从很小的参数量到较大的都有意味着你可以在开发机上先用小尺寸把链路跑通再换大尺寸看效果提升不用一开始就卡在显存上。第三是对话模板规范。Qwen1.5 用的是 ChatML 风格的模板|im_start|和|im_end|这对标记把角色和内容分得很清楚这在 RAG 里很重要——你要把检索到的上下文、用户问题、系统指令拼成一段输入模板不规范很容易让模型把上下文当成用户说的话答非所问。用官方 tokenizer 自带的apply_chat_template能省掉手工拼串的麻烦也避免格式错位。至于具体选多大我的建议是先看显存。7B 级别在量化后对消费级显卡比较友好14B 以上就需要更宽裕的显存或者更激进的量化。这里不展开具体硬件数字因为不同量化方案差异很大你自己用nvidia-smi看一眼余量留出模型加载和上下文占用的空间就行。1.3 全本地链路意味着什么“全本地”这三个字不是噱头它直接决定了架构上的几个约束。文档解析、向量化、检索、生成全部在本机完成没有任何一步把数据发出去。好处是数据可控坏处是所有环节的算力都得自己扛——embedding 模型要本地跑生成模型要本地跑两者还可能在显存上打架。这就引出一个实操中必须提前想清楚的问题embedding 和生成模型怎么共存。如果两个都放显存小显存机器直接爆。常见做法是 embedding 用较小的模型甚至 CPU 跑生成模型独占显存或者两个都量化。我自己的做法是 embedding 选一个轻量的中文向量模型生成模型单独占卡中间用批处理控制峰值。这个取舍后面在环境那节会具体说。2. 环境搭建与依赖版本的那些坑2.1 依赖安装顺序比版本号更影响成败装依赖这件事很多人习惯把 requirements 一把梭结果装完 import 就报错。LlamaIndex 生态拆得很细核心包和各个集成包是分开的顺序和组合不对就容易出现版本冲突。我的经验是先装核心再按需装集成每装一层跑一次 import 验证出问题能立刻定位是哪一层引入的。大致顺序是这样先装llama-index核心再装llama-index-llms-huggingface这类模型集成包最后装向量存储相关的包。如果你用的是本地文件型向量库还要装对应的 store 包。每装完一组跑一句python -c import llama_index.core; print(ok)确认没炸再往下走。这个习惯看着笨但能帮你把“装完一堆包之后不知道哪个坏了”的困境挡在门外。提示不要盲目追最新版。框架和集成包之间常有版本耦合装之前先看一眼核心包要求的集成包版本范围能省掉大量排查时间。2.2 模型权重与 tokenizer 的本地加载细节Qwen1.5 的权重从官方渠道下载后落在本地目录加载时用AutoModelForCausalLM和AutoTokenizer指向该目录即可。这里有两个容易忽略的点。第一是tokenizer 的trust_remote_code部分模型的自定义实现需要这个参数为 True 才能正确加载否则会报找不到类。第二是数据类型加载时指定torch_dtype为半精度能显著降低显存占用但要注意你的显卡是否支持老卡上强行半精度可能出数值问题。加载完模型后建议先做一次“裸测”不接任何 RAG 逻辑直接给一句中文问题看模型能不能正常生成。这一步是为了把“模型本身能不能跑”和“RAG 链路对不对”两个问题分开。我见过不少人一上来就调整个链路结果报错分不清是模型加载失败还是检索逻辑写错白白浪费时间。from transformers import AutoModelForCausalLM, AutoTokenizer import torch model_dir ./local_models/qwen1_5 tokenizer AutoTokenizer.from_pretrained(model_dir, trust_remote_codeTrue) model AutoModelForCausalLM.from_pretrained( model_dir, torch_dtypetorch.float16, device_mapauto, trust_remote_codeTrue, )device_mapauto会让框架自动把层分配到可用设备上单卡场景下基本就是全放显存多卡或显存紧张时会做切分。这个参数在调试阶段很好用能避免手工指定设备带来的错配。2.3 embedding 模型与生成模型的显存博弈前面提到两个模型抢显存的问题这里展开说。embedding 模型虽然参数量小但它要在建索引时对大量文本块做前向计算峰值占用不容小觑生成模型则是在问答时吃上下文。如果两个都常驻显存小卡很容易 OOM。我的处理方式是分时复用建索引阶段只加载 embedding 模型生成模型先不加载索引建完落盘问答阶段再加载生成模型embedding 模型如果显存不够就切到 CPU 跑。CPU 跑 embedding 慢一些但检索阶段每次只处理一个 query延迟可以接受。这个策略的核心是“不要同时把两个大件都压在显存里”用时间换空间。具体到代码就是把 embedding 模型的加载和生成模型的加载放在不同阶段中间用索引持久化隔开。LlamaIndex 支持把索引存到本地目录下次直接 load 回来不用每次重建这也顺带解决了重复计算的问题。3. 文档解析与切分决定召回上限的一步3.1 不同格式文档的读取策略知识库的文档格式往往很杂PDF、Word、Markdown、纯文本都有。LlamaIndex 的 Reader 体系对常见格式都有对应实现但解析质量差异很大。纯文本和 Markdown 基本无损Word 依赖底层解析库PDF 则是最容易出问题的——扫描版 PDF 没有文字层直接读出来是空的排版复杂的 PDF 读出来顺序错乱表格变成一堆散字。我的做法是先分类再解析。对 PDF 先判断有没有文字层没有的走 OCR 路线这一步可以离线做把结果存成文本再进流程有文字层的也要抽查几页看段落顺序是否正常。表格密集的文档解析后单独检查表格区域必要时手工修正或换用更擅长表格的解析方式。这一步偷懒后面检索再优化也救不回来因为垃圾进就是垃圾出。注意解析完的文本建议先落一份纯文本副本。一是方便人工抽查二是万一后面切分策略要调不用重新解析原始文档。3.2 切分粒度块太大和太小都难受切分是 RAG 里最需要“手感”的一环。块太大一个块里混了好几个主题检索命中后塞给模型的上下文有一半是噪音块太小语义被切碎检索到的片段缺前因后果模型答得支离破碎。LlamaIndex 的SentenceSplitter提供了块大小和重叠两个关键参数我一般从块大小 512 token、重叠 50 token起步再根据实际召回效果调。为什么留重叠因为句子边界和块边界不一定对齐一个关键结论可能横跨两个块重叠区能保证它至少在一个块里是完整的。重叠不宜过大否则索引膨胀、检索重复度高。512 这个起点也不是拍脑袋它大致对应中文几百字既能容纳一个完整小节又不至于跨太多主题。from llama_index.core.node_parser import SentenceSplitter splitter SentenceSplitter(chunk_size512, chunk_overlap50) nodes splitter.get_nodes_from_documents(documents)调参时我有个笨办法但很有效随机抽十几个块打印出来读一遍看每个块是不是“讲清楚了一件事”。如果读着别扭、前后不搭说明切分粒度不对先调这个再谈检索优化。3.3 元数据被低估的检索增强手段切分出来的每个块除了文本本身还应该带上元数据比如来源文件名、章节标题、页码。这些信息在检索时不一定直接参与相似度计算但在两个地方极其有用一是答案引用模型答完可以标出出处用户能核对二是过滤检索比如你只想在某个子目录的文档里搜元数据过滤能大幅缩小范围。LlamaIndex 的节点支持metadata字段解析时把来源信息挂上去检索时可以按元数据做过滤。我习惯至少挂上文件名和一级标题成本很低收益在后期排查“这个答案到底从哪来的”时特别明显。4. 索引构建与向量化实操4.1 向量模型的选择与批量处理embedding 模型决定了“语义相近”这件事能不能被正确捕捉。中文场景下要选在中文语料上训练过的向量模型直接用英文模型效果会打折扣。选型时看两个指标一是检索召回率二是向量维度。维度高表达能力强但存储和计算成本高维度低则相反一般几百维是比较平衡的区间。建索引时不要一条一条喂要批量处理。LlamaIndex 的索引构建接口支持传入节点列表底层会分批做 embedding。批量大小受显存限制太大 OOM太小慢。我的经验是从较小批量起步观察显存占用再往上加找到一个不爆的稳定值。这个过程没有通用数字因为模型和硬件组合千变万化。from llama_index.core import VectorStoreIndex from llama_index.embeddings.huggingface import HuggingFaceEmbedding embed_model HuggingFaceEmbedding( model_name./local_models/bge-small-zh, devicecuda, ) index VectorStoreIndex(nodes, embed_modelembed_model) index.storage_context.persist(persist_dir./index_store)persist把索引落到本地目录下次用load_index_from_storage直接读回来。这一步在建索引耗时较长时尤其重要避免每次调试都重跑一遍向量化。4.2 索引持久化与增量更新知识库不是一成不变的文档会增删改。全量重建索引在小库上还能忍库一大就难受。LlamaIndex 支持在已有索引上做增量插入新文档解析切分后insert_nodes进去即可已有向量不用重算。但要注意删除和修改的处理比新增麻烦通常需要按文档 ID 定位并移除对应节点再插入新版本。这块逻辑要自己写清楚否则容易出现“旧内容还在、新内容也进来了”的重复召回。我的做法是给每个文档一个稳定 ID比如文件路径的哈希更新时先按 ID 删旧节点再插新节点。删除操作在部分向量存储上支持程度不一用本地文件型存储时相对直接换成别的存储要确认它是否支持按元数据删除。4.3 检索参数top_k 不是越大越好检索时取回几条这个top_k直接影响答案质量。取太少可能漏掉关键信息取太多上下文里塞满不相关片段模型反而被干扰还挤占上下文窗口。我一般从top_k3 到 5起步配合相似度阈值过滤掉明显不相关的块。相似度阈值这个参数值得单独说。向量检索总会返回“最相似”的几条哪怕它们其实都不相关。设一个阈值低于它的直接丢弃能有效减少噪音。阈值定多少要看你的向量模型和相似度度量方式没有统一值建议先不设阈值跑一批问题看返回的相似度分布再定一个能过滤掉明显离群值的点。5. 把检索结果喂给 Qwen1.5 的正确姿势5.1 上下文拼接的结构设计检索到相关块之后怎么把它们和用户问题拼成模型输入这一步的格式设计很关键。我的结构大致是系统指令说明“你是基于给定资料回答的助手资料里没有的不要编”然后是若干条资料带编号最后是用户问题。资料之间用清晰的分隔符隔开每条前面标上序号方便模型引用。为什么强调“资料里没有的不要编”本地小模型在没有约束时很容易自由发挥把训练时的记忆当成资料内容答出来。加一句明确的约束能显著降低幻觉。同时给资料编号模型答完可以带一句“根据资料 2”用户能追溯这个体验提升很明显。def build_prompt(contexts, question): ctx_text \n\n.join( f[资料{i1}] {c} for i, c in enumerate(contexts) ) messages [ {role: system, content: 你是知识库问答助手只依据给定资料回答资料中没有的信息请明确说明不知道。}, {role: user, content: f参考资料\n{ctx_text}\n\n问题{question}}, ] return messages拼好 messages 后交给 tokenizer 的apply_chat_template转成模型输入不要手工拼特殊标记容易错。5.2 上下文窗口与截断策略Qwen1.5 有上下文长度上限检索回来的资料加上问题如果超了必须截断。截断策略有讲究不能简单从尾部砍因为可能把问题砍掉也不能无脑砍资料可能砍掉最相关的那条。我的做法是按相关性排序后从低往高砍优先保留高相关块同时给问题和系统指令留足空间。具体实现时先算问题和系统指令占多少 token剩余额度按检索得分从高到低分配给资料块装不下就停。这样保证最相关的信息一定在上下文里。这个逻辑要自己写框架不一定提供现成的按得分截断。5.3 生成参数温度、重复惩罚与长度生成阶段的参数对答案风格影响很大。知识库问答追求的是准确、稳定不是创意所以温度要调低接近贪心解码减少随机性。重复惩罚适度开一点防止模型绕圈说车轱辘话。最大生成长度按答案预期设太长容易跑题太短可能答不完整。我常用的组合是温度 0.1 到 0.3、重复惩罚 1.1 左右、最大新生成 token 几百。这些不是金科玉律要结合你的文档长度和问题复杂度调。调的时候一次只动一个参数观察输出变化同时动多个会分不清是谁的功劳。6. 实测中撞上的几个真问题6.1 检索到了但模型答非所问这是最常见的问题表现是检索结果明明相关模型却答偏了。排查下来原因往往不在检索而在上下文拼接。比如资料块之间没有清晰分隔模型把两条资料连读成一句或者系统指令太弱模型没意识到要“依据资料”。我的修复办法是加强分隔符、把系统指令写得更硬并在资料前明确标注“以下为参考资料不是用户原话”。还有一种情况是资料本身有歧义比如同一概念在不同文档里定义不同模型取了其中一个。这种要么在检索时做去重和冲突检测要么在系统指令里要求“如资料间有冲突请指出”。后者更省事但依赖模型能力。6.2 长文档切分后语义断裂前面提过切分粒度这里说一个具体表现一份长文档按固定大小切正好把一个“因为……所以……”的因果句切成两半检索只命中后半句模型看到“所以”却不知道“因为”答案就缺了逻辑。重叠区能缓解但不能根治。我的应对是优先按语义边界切比如按段落、按标题层级切而不是纯按 token 数硬切。LlamaIndex 的切分器支持按分隔符优先切分把段落分隔符、标题标记作为优先断点实在超长再按 token 切。这样切出来的块语义更完整代价是块大小不均匀但检索质量优先于整齐。6.3 相似度虚高导致的误召回有时候检索返回的相似度很高但内容其实不相关这是向量模型的“语义漂移”。典型场景是短 query 对长文档或者专业术语被向量模型理解偏了。表现是模型拿着不相关的资料硬答答案看着像那么回事但经不起核对。缓解手段有几个一是换更适合领域的向量模型二是在检索后加一层重排序用交叉编码器对候选块重新打分把虚高的压下去三是设相似度阈值宁可少召回也不误召回。重排序会增加延迟但对准确率提升明显文档量大、对准确率要求高的场景值得加。6.4 模型对“不知道”的处理理想情况下资料里没有答案时模型应该说“不知道”。实际测试中小模型经常硬编一个答案出来。除了在系统指令里强调还可以在拼接时明确告诉模型“如果资料不足以回答请直接说明”。另外检索阶段如果最高相似度低于阈值可以在进模型之前就短路直接返回“未找到相关资料”不浪费一次生成。这个短路逻辑很实用既省算力又避免幻觉。7. 效果调优的几条实操心得7.1 先修数据再调模型调优时人的本能是去动模型参数但我踩过的坑告诉我大部分效果问题出在数据和检索层。文档解析错乱、切分断裂、元数据缺失这些修好之后效果提升往往比换模型、调温度大得多。我的习惯是每次调优前先抽一批 badcase看是“没检索到”还是“检索到了但答错”前者修检索后者修拼接或生成约束对症下药。7.2 建立一个小型评测集没有评测集调优就是盲人摸象。我一般从知识库里挑几十个有明确答案的问题人工标注期望答案的关键点每次改动后跑一遍看命中率变化。这个集子不用大但要覆盖不同类型的问题——事实型、对比型、需要跨文档综合的。有了它你才能判断某次改动是真提升还是心理作用。7.3 日志要记全问答链路的每一步都值得记日志用户问题、检索到的块及相似度、拼好的 prompt、模型原始输出。出问题时这些日志能帮你快速定位是哪一环坏了。尤其是 prompt很多人不记结果模型答错时无法判断是资料问题还是拼接问题。日志量大了可以采样但关键字段不能省。7.4 延迟与质量的平衡全本地链路延迟主要花在生成上检索相对快。如果对响应速度有要求可以考虑流式输出让用户先看到字一个个出来体感上快很多。另外embedding 如果跑在 CPU 上检索阶段会有额外延迟能放显存就放显存放不下再考虑量化或换更小的向量模型。这些取舍没有标准答案看你的场景更看重什么。整套跑下来我的体会是 RAG 的门槛不在“搭起来”而在“调得好”。框架帮你把链路串通但每个环节的参数和策略都得结合自己的文档特点去磨。上面这些参数和做法是我在若干次折腾后沉淀下来的起点你拿去跑第一版应该能少走不少弯路剩下的就是拿真实文档去喂、拿真实问题去测慢慢把召回和生成两头都调顺。