引言:本地 RAG 的真实驱动力过去一年,我参与过几个企业内部知识库项目,几乎每一次技术选型会议都会出现同一个分歧:用云端大模型 API,还是全部本地化部署?云端方案的优势很明确——模型能力强、开箱即用。但真正卡住项目的往往不是能力,而是三件事:数据出域合规(合同、代码、财务单据不能上传第三方)、成本不可控(长文档 RAG 每次检索要塞几千 token 上下文,费用随调用量线性上涨)、版本漂移(API 背后的模型悄悄升级,昨天调好的 Prompt 今天效果变了)。先说成本这件事,很多人低估了。假设每次问答检索 6 个 chunk、每个 chunk 约 700 字,加上系统提示和历史对话,单次请求的输入 token 很容易到 4000~6000。如果按每天 1000 次调用计算,一个月就是 1.2 亿~1.8 亿输入 token。云端 API 即便按较低价位算,每月也是一笔不小的固定支出,而且随着用户养成使用习惯,调用量只会涨不会跌。本地部署则是另一套账本:一台 4090 或 4090D 的工作站一次性投入,之后边际成本几乎只有电费。对于知识库这种“高频、低复杂度、答案有明确依据”的场景,本地小模型的性价比优势非常明显。Ollama + LangChain 的组合正好覆盖这三点:Ollama 基于 llama.cpp,把 GGUF 量化模型的下载、加载、推理封装成一个 HTTP 服务,一条ollama pull就能跑起 7B/14B 模型;LangChain 提供文档加载、切分、向量化、检索、Agent 编排的标准抽象。两者拼起来,一台带 16GB 显存的机器就能撑起一个可用的问答系统。这里稍微展开一下 Ollama 的底层机制,理解它有助于后面调参。Ollama 的推理后端是 llama.cpp,模型以 GGUF 格式分发,GGUF 里除了权重,还包含分词器词表、量化配置、对话模板等元信息。所谓 Q4_K_M,指的是 4bit 混合量化:大部分权重压到 4bit,少数敏感层保留更高精度,从而在体积和效果之间取平衡。一个 7B 模型 Q4 量化后约 4.5GB,14B 约 9GB,32B 约 20GB。Ollama 加载模型时会用内存映射(mmap)把权重文件映射进地址空间,所以首次加载稍慢,之后常驻显存/内存,重复请求的冷启动成本很低。它还内置了模型常驻时间(默认 5 分钟)、并发队列、上下文窗口等运行时参数,这些参数直接决定了 RAG 场景下的表现,后文会反复提到。但我要先泼一盆冷水:“跑通 demo”和“业务可用”之间隔着一条鸿沟。默认参数下的本地 RAG,召回率经常只有 50% 出头,模型还特别爱编。我见过最典型的情况是:检索明明返回了正确段落,模型却因为上下文被截断而答了另一段的内容;或者检索没召回到,模型凭先验知识一本正经地编了一个数字。这篇文章讲清楚原理、给出可运行的代码,重点放在那些只有踩过才知道的坑上。一、核心原理:RAG 是上下文工程,Agent 是决策层1.1 RAG 的三个阶段RAG(Retrieval-Augmented Generation)的本质不是“让模型记住知识”,而是在推理时把正确的上下文塞进窗口。它分为离线索引和在线检索生成两段:索引阶段:文档 → 加载解析 → 切分为 chunk → 用 Embedding 模型编码为向量 → 写入向量库,同时保留来源、页码、标题层级等元数据。检索阶段:用户 query → 同样编码为向量 → 在向量库做近似最近邻搜索(ANN)→ 取 Top-K 片段。生成阶段:把片段拼进 Prompt 模板,约束模型“只依据资料回答”,输出答案并附引用。关键认知:生成模型的能力上限决定了答案的表达质量,但检索质量决定了答案对不对。90% 的 RAG 效果问题出在检索侧,而不是模型侧。把这三个阶段再拆细一点,能看清每个环节的失败模式。索引阶段的第一道关是文档解析。Markdown、TXT 这类纯文本最好处理;PDF 就麻烦得多,尤其是带双栏排版、表格、页眉页脚的扫描件。unstructured会尝试用布局分析还原阅读顺序,但表格往往被拆成一行行错位的文本,数字和表头对不上。我的做法是:对表格密集的文档单独走一条解析路径,先转成 Markdown 表格或 HTML 表格,再切分;对扫描件先做 OCR,并人工抽检 20 页确认质量。解析错了,后面所有环节都是在错误的地基上盖楼。第二道关是切分。chunk 太小,一个完整结论被劈成两半,检索到上半句却答不出下半句;chunk 太大,一段里混了多个主题,向量被“平均”得没有区分度,还会挤占生成窗口。经验值是中文 500~800 字、英文 300~500 词,overlap 取 chunk 的 10%~15%。但比数值更重要的是分隔符的优先级:优先按标题、段落切,最后才按句号切,保证每个 chunk 尽量是语义完整的单元。第三道关是向量化。Embedding 模型通常是双塔结构,用对比学习训练,把语义相近的文本映射到向量空间里相近的位置。检索时用余弦相似度或内积衡量距离。这里有个容易被忽略的点:Embedding 模型是有“领域偏好”的。bge-m3 在通用中文和多语言上表现很好,但如果你的知识库全是法律条文或医疗术语,通用模型的距离度量未必准,这时候要么换领域微调过的 Embedding,要么在检索后加一层重排序(rerank)来兜底。检索阶段用的是近似最近邻(ANN)而不是暴力全量比对,因为十万级向量做精确搜索延迟太高。Chroma 默认用 HNSW(分层可导航小世界图),通过多层图结构把搜索复杂度降到对数级;FAISS 则提供 IVF、PQ 等多种索引。ANN 的代价是可能漏掉少量真正最近的邻居,所以fetch_k要设得比k大,给后续的重排和筛选留余量。生成阶段的关键是 Prompt 约束。片段拼进上下文后,要明确告诉模型“只依据资料回答”“每个结论标注来源”“资料不足就说不知道”。这三条规则看起来简单,但缺一条,幻觉率就会明显上升。另外,片段之间要有清晰的分隔符和编号,否则模型分不清哪句话出自哪份文档,引用就会张冠李戴。1.2 从固定流水线到 Agentic RAG朴素 RAG 是一条写死的链:一次检索、一次生成。真实提问往往不是这样——“对比 A 产品和 B 产品的退款政策”需要两次检索,“上个季度提到的那个方案后来怎么定的”需要先改写 query 消解指代。Agentic RAG 把检索封装成工具(Tool),由模型自主决定调用几次、用什么 query 调用、要不要基于中间结果再检索一轮。LangChain 的create_tool_calling_agent提供了这个能力:模型输出结构化的工具调用请求,执行器运行工具并把结果回填到agent_scratchpad,循环直到模型给出最终答案。它的循环大致是这样的:第一轮,模型看到用户问题和工具描述,判断需要检索,输出一个 JSON,形如{"name": "search_kb", "arguments": {"query": "差旅报销 住宿标准 一线城市"}};执行器调用工具,把返回的文本追加到 scratchpad;第二轮,模型基于检索结果决定是直接回答,还是再换关键词检索一次;直到模型输出不含工具调用的普通文本,循环结束。max_iterations就是给这个循环设的硬上限,防止模型反复检索不收敛。代价是延迟和 token 消耗上升,且小模型的工具调用稳定性不如 GPT-4 级别。我的经验是:7B 模型做单轮工具调用够用,超过 3 轮迭代就容易跑偏,所以max_iterations必须设上限。小模型跑偏的典型表现有三种:一是把工具名拼错,或者参数 JSON 少个引号,导致解析失败;二是反复用几乎相同的 query 检索,陷入死循环;三是检索到内容后不回答,继续调用工具。前两种靠max_iterations和handle_parsing_errors=True兜底,第三种则要在 Prompt 里明确写“若已获得足够信息,请直接给出最终答案,不要再次调用工具”。还有一个工程上的权衡:Agentic RAG 的每次工具调用都是一次完整的 LLM 推理,延迟是朴素 RAG 的两到三倍。对交互式问答,这个延迟可以接受;对批量离线任务,我通常还是用固定流水线加查询改写,把多跳问题拆成预定义的子查询,稳定性和吞吐都更好。1.3 本地栈的组件选型组件推荐说明推理框架Ollama开发调试极快;生产高并发可换 vLLM生成模型qwen2.5:7b-instruct / llama3.1:8b中文场景优先 Qwen 系列Embeddingbge-m3 / nomic-embed-textbge-m3 中文与多语言更强,1024 维向量库Chroma / FAISS单机十万级文档 Chroma 足够编排LangChain注意 0.2+ 版本已拆包选型表背后有几个判断依据,值得展开说。生成模型方面,7B 是本地部署的“甜点区”:Q4 量化后约 4.5GB,16GB 显存可以留出充足空间给上下文缓存,推理速度在消费级显卡上能到每秒二三十个 token。14B 效果更好,但显存占用翻倍,上下文一长就容易爆。中文场景优先 Qwen 系列,是因为它的中文分词和指令遵循在开源模型里比较扎实,工具调用的 JSON 格式也相对稳定。llama3.1:8b 的英文和通用推理更强,中文长句偶尔会出现表达生硬的问题。Embedding 方面,bge-m3 输出 1024 维,支持稠密、稀疏、多向量三种检索模式,中文语义匹配明显优于早期的 all-MiniLM 系列;nomic-embed-text 只有 768 维,体积小、速度快,适合对延迟敏感或显存紧张的场景。维度越高,向量库占用越大,检索也略慢,但对十万级文档来说差异可以忽略。向量库方面,Chroma 的优点是零配置、自带持久化、支持元数据过滤,Python 接口友好;FAISS 更底层、更快,但需要自己管理索引文件和元数据映射。单机十万级 chunk 以内,Chroma 完全够用;上到百万级再考虑 Milvus、Qdrant 这类专业向量库。编排层要特别注意版本。LangChain 0.2 之后做了大规模拆包,langchain-community、langchain-ollama、langchain-chroma、langchain-text-splitters都是独立包,from langchain.vectorstores import Chroma这种老写法会直接报 ImportError。装依赖时最好锁定版本,避免不同包之间 API 不兼容。一个必须强调的细节:Embedding 模型和生成模型是两套独立模型,都要通过 Ollama 拉取。很多人只 pull 了对话模型,结果代码一跑就报model not found。验证方法是分别调用两个接口:/api/chat测生成,/api/embeddings测向量化,两个都返回正常再往下写代码。二、实战:从零搭建一个带引用的知识库问答 Agent2.1 环境准备# 安装 Ollama(Linux)curl-fsSLhttps://ollama.com/install.sh|sh# 拉取生成模型与嵌入模型ollama pull qwen2.5:7b-instruct ollama pull bge-m3# 验证服务与嵌入接口curlhttp://localhost:11434/api/tagscurlhttp://localhost:11434/api/embeddings\-d'{"model":"bge-m3","prompt":"测试向量化"}'# Python 依赖pipinstalllangchain langchain-community langchain-ollama\langchain-chroma langchain-text-splitters unstructured这段命令看似简单,实际执行时容易卡在几个地方。第一,Ollama 安装脚本会注册 systemd 服务并监听127.0.0.1:11434。如果服务没起来,curl会直接 connection refused。排查顺序是:systemctl status ollama看服务状态,journalctl -u ollama -n 50看日志,确认端口没被其他进程占用。第二,ollama pull下载的是 GGUF 权重,7B 模型约 4~5GB,网络不稳时容易中断,重跑即可断点续传。第三,unstructured解析 PDF 需要额外的系统依赖(poppler、tesseract),只装 Python 包会在解析时报“缺少 libmagic”之类的错误,Debian/Ubuntu 上补一句apt install libmagic1 poppler-utils tesseract-ocr即可。Python 版本建议 3.10 以上,3.9 在部分 LangChain 子包上会有类型注解兼容问题。装完后可以用下面几行代码做一次端到端自检,确认生成和嵌入两条链路都通:fromlangchain_ollamaimportChatOllama,OllamaEmbeddings# 生成链路自检llm=ChatOllama(model="qwen2.5:7b-instruct",temperature=0)print(llm.invoke("用一句话说明什么是RAG").content)# 嵌入链路自检,同时确认维度emb=OllamaEmbeddings(model="bge-m3")vec=emb.embed_query("测试向量化")print(len(vec))# bge-m3 应输出 1024如果len(vec)不是 1024,说明模型没拉对,或者请求打到了别的服务上。2.2 索引构建:切分策略比什么都重要fromlangchain_community.document_loadersimportDirectoryLoader,UnstructuredMarkdownLoaderfromlangchain_text_splittersimportRecursiveCharacterTextSplitterfromlangchain_ollamaimportOllamaEmbeddingsfromlangchain_chromaimportChroma# 1. 加载:保留 source 元数据,后续引用要用loader=DirectoryLoader("./docs",glob="**/*.md",loader_cls=UnstructuredMarkdownLoader,show_progress=True)docs=loader.load()# 2. 切分:优先按 Markdown 标题切,保证语义完整splitter=RecursiveCharacterTextSplitter(chunk_size=700,chunk_overlap=100,separators=["\n## ","\n### ","\n\n","\n","。"," "],)chunks=splitter.split_documents(docs)fori,cinenumerate(chunks):c.metadata["chunk_id"]=f"chunk-{i}"# 便于溯源与反馈闭环# 3. 向量化入库(bge-m3 输出 1024 维)embeddings=OllamaEmbeddings(model="bge-m3",base_url="http://localhost:11434")vectordb=Chroma.from_documents(documents=chunks,embedding=embeddings,persist_directory="./chroma_db",collection_name="internal_kb",)print(f"已索引{len(chunks)}个片段")这里用RecursiveCharacterTextSplitter并把 Markdown 标题作为第一优先级分隔符,目的是避免把一个完整章节从中间劈开——语义断裂的 chunk 是召回噪声的主要来源。具体解释一下separators的顺序:切分器会先尝试用\n##把文档切成二级标题块,如果某块仍超过chunk_size,再用\n###切三级标题,然后退到空行分段,最后才用句号和空格。这个“逐级降级”的策略保证了大结构优先、句子兜底,不会出现一个 chunk 从标题中间开始的情况。chunk_overlap=100是为了让相邻 chunk 共享一段上下文,避免答案刚好卡在边界上时两边都只拿到半句。元数据的设计同样重要。除了加载器自动写入的source,我通常还会补三类字段:title(章节标题,从 Markdown 文本里正则提取)、chunk_id(唯一标识,用于反馈闭环和去重)、acl(权限标签,检索时按用户角色过滤)。后两个在 demo 里看不出价值,但一旦要做“引用可点击跳转”和“不同部门看到不同文档”,没有它们就得重建整个索引。索引构建还有两个实战中绕不开的问题。一是增量更新:全量重建在文档上千后要跑几十分钟,正确做法是对每个文件算内容 hash,只重新索引 hash 变化的文件,并删除旧 chunk。Chroma 支持按 metadata 过滤删除,配合chunk_id里的文件前缀就能实现。二是编码问题:批量加载时偶尔会遇到 GBK 编码的 txt 导致整个 loader 抛异常,建议在loader_cls外层包一层 try/except,把失败文件记进日志单独处理,不要让一个坏文件阻断整批索引。2.3 封装检索工具并构建 Agentfromlangchain_ollamaimportChatOllamafromlangchain_core.toolsimporttoolfromlangchain_core.promptsimportChatPromptTemplatefromlangchain.agentsimportcreate_tool_calling_agent,AgentExecutor# MMR 检索:兼顾相关性与多样性,避免返回 6 段几乎重复的内容retriever=vectordb.as_retriever(search_type="mmr",search_kwargs={"k":6,"fetch_k":20,"lambda_mult":0.5},)@tooldefsearch_kb(query:str)->str:"""在本地企业知识库中检索相关文档片段。输入应为一句精炼的检索语句。"""docs=retriever.invoke(query)ifnotdocs:return"知识库中未检索到相关内容。"return"\n\n".join(f"[{i+1}] 来源:{d.metadata.get('source')}\n{d.page_content}"fori,dinenumerate(docs))# num_ctx 必须显式调大,默认窗口装不下 6 个 chunkllm=ChatOllama(model="qwen2.5:7b-instruct",temperature=0.1,num_ctx=8192)prompt=ChatPromptTemplate.from_messages([("system","你是企业内部知识库助手。规则:\n""1. 回答必须完全基于检索工具返回的资料,禁止使用自身先验知识补充;\n""2. 每个结论句末用 [编号] 标注来源;\n""3. 若资料不足以回答,直接回复“知识库中未找到相关信息”,不要猜测;\n""4. 若一次检索不足,可换用不同关键词再次调用检索工具。"),("human","{input}"),("placeholder","{agent_scratchpad}"),])agent=create_tool_calling_agent(llm,[search_kb],prompt)executor=AgentExecutor(agent=agent,tools=[search_kb],max_iterations=4,# 防止小模型陷入检索死循环return_intermediate_steps=True,verbose=True,)resp=executor.invoke({"input":"我们公司差旅报销的住宿标准是多少?一线城市和其他城市有区别吗?"})print(resp["output"])这套代码跑起来后,return_intermediate_steps会暴露每一轮的工具调用参数——这是调试 RAG 最有价值的信息,你能直接看到模型把 query 改写成了什么,从而判断问题出在改写还是召回。几个参数值得单独说明。search_type="mmr"是最大边际相关(Maximal Marginal Relevance),它在“与 query 相关”和“与已选结果不重复”之间做加权平衡,公式可以粗略理解为score = λ·sim(q, d) - (1-λ)·max sim(d, d_selected)。lambda_mult=0.5表示相关性和多样性各占一半;如果发现返回的片段太发散、答非所问,就调高到 0.7;如果返回的全是同一段落的重复表述,就调低到 0.3。fetch_k=20是先召回 20 个候选再从中挑 6 个,给 MMR 留出选择空间。@tool装饰器的函数名和 docstring 就是给模型看的“工具说明书”,模型据此判断什么时候调用、参数怎么填。docstring 里写“输入应为一句精炼的检索语句”,是在引导模型做查询改写,而不是把用户的整句口语原样传进来。实测这句话能明显减少“把‘你好,我想问一下……’整句当 query”的情况。num_ctx=8192是这套配置的命门。Ollama 旧版本默认上下文只有 2048,6 个 700 字 chunk 加上系统提示、工具返回格式、scratchpad,很容易到五六千 token,超出部分会被静默截断——模型看到的是残缺的上下文,表现就是“检索到了却答非所问”。改成 8192 后,显存占用会增加,7B Q4 模型在 16GB 卡上仍然宽裕。Prompt 里的四条规则各有用处:第 1 条压制先验知识,第 2 条保证可追溯,第 3 条给模型一个“体面的退路”(否则它倾向于硬编),第 4 条允许有限的多跳检索。规则不是越多越好,超过六条小模型就开始顾此失彼,建议按业务逐步加、每加一条都回归测试。跑通后,一次典型的intermediate_steps长这样:第一轮工具调用 query 是“差旅报销 住宿标准 一线城市”,返回 6 个片段;第二轮模型直接输出答案,每个结论后带[1]、[3]这样的编号。如果看到第一轮 query 被改写得莫名其妙,问题在 Prompt 或工具描述;如果 query 合理但返回片段不相关,问题在切分或 Embedding;如果片段相关但答案仍编造,问题在生成 Prompt 或num_ctx。按这个顺序排查,能省下大量瞎试的时间。三、踩坑与优化建议以下每一条都是我在真实项目里付出过时间代价换来的。1. Ollama 默认上下文窗口太小。旧版本默认num_ctx=2048,6 个 700 字的 chunk 加上系统提示早就溢出了,模型会静默截断前面的内容,表现为“明明检索到了却答非所问”。务必在ChatOllama里显式设置num_ctx=8192甚至16384,并用ollama show qwen2.5:7b-instruct确认模型支持的最大上下文。调大后要观察显存,如果出现 OOM,优先减k而不是减num_ctx——上下文完整性比多召回一段更重要。2. 换 Embedding 模型必须重建索引。向量空间是由具体模型定义的,bge-m3 的 1024 维向量和 nomic-embed-text 的 768 维向量不在同一个空间,混用会导致检索结果完全随机。更隐蔽的情况是同一模型换了量化版本或升级了版本号,向量分布也可能轻微漂移。我的做法是把模型名和版本写进 collection 名,例如internal_kb_bgem3_v1,换模型就换 collection,旧索引保留用于回滚。3. 检索结果要设相关性阈值。Top-K 检索永远会返回 K 条,哪怕知识库里根本没有相关内容。不加过滤的话,模型会拿着不相关的片段硬答。可以在检索后按相似度分数过滤(Chroma 的similarity_search_with_score会返回距离),距离超过阈值的直接丢弃;如果全部被丢弃,就返回“未找到”。阈值需要在你的数据上标定,一般取召回集距离分布的 70% 分位附近。4. 加一层重排序能显著提升精度。向量检索是粗排,召回快但精度有限。引入 bge-reranker 这类交叉编码器,对 Top-20 候选逐对计算 query 与片段的相关性,再取前 5 个送进生成,召回率通常能提升十几个百分点。代价是每次查询多几十到上百毫秒,对交互式场景可以接受。Ollama 不直接提供 rerank 接口,可以用sentence-transformers本地加载 reranker 模型,或走独立的推理服务。5. 中文分隔符要单独处理。RecursiveCharacterTextSplitter的默认分隔符是英文标点,中文文档如果只靠\n\n切,容易切出超长 chunk。把。、;、!、?加进separators列表,并放在空格之前,能让切分更贴合中文语义。另外,中文没有空格,按词切分需要分词器,纯字符切分对中文反而更稳。6. PDF 和表格是解析重灾区。双栏 PDF 会被读成左右交错,表格会被拆成错位的行。对这类文档,建议先转成 Markdown(用 marker、pymupdf4llm 等工具),人工抽检后再入库;表格单独抽成结构化数据,问答时走“文本检索 + 表格查询”两条路。解析质量差是很多 RAG 项目“看起来能跑、实际不能用”的根因。7. 小模型的工具调用要加护栏。除了max_iterations,还要设handle_parsing_errors=True,并在 Prompt 里明确工具调用的格式要求。如果模型频繁输出非法 JSON,可以换用支持原生 function calling 的模型版本,或者退化为“固定检索 + 单轮生成”的朴素链路,牺牲灵活性换稳定性。8. 温度和重复惩罚要按任务调。知识库问答要的是忠实复述,temperature设 0~0.2 即可;调高会增加措辞的多样性,但也增加编造风险。如果发现模型反复输出同一句话,可以适度调repeat_penalty(Ollama 通过repeat_penalty参数控制),但不要超过 1.2,否则会损伤正常表达。9. 并发请求要控制。Ollama 默认对同一模型串行处理请求,并发上来后排队延迟会很难看。可以通过环境变量OLLAMA_NUM_PARALLEL提高并行度,但要按显存算清楚:每个并发上下文都占显存,7B Q4 加 8K 上下文,单路大约 6~7GB,16GB 卡最多两路。生产环境高并发建议换 vLLM,它用 PagedAttention 管理 KV cache,吞吐高一个量级。10. 索引和配置都要版本化。切分参数、Embedding 模型、Prompt 模板任何一项改动都会影响效果。把这三者连同 collection 名一起写进配置文件并打版本号,每次效果回归都能定位到是哪次改动引入的。我见过团队改了 chunk_size 却没重建索引,排查了两天才发现检索用的还是旧库。11. 建立最小评估集。不要靠“感觉”判断效果。从真实问题里抽 50~100 条,人工标注标准答案和应召回的文档片段,每次改动后跑一遍,统计召回率(正确片段是否在 Top-K 里)和忠实度(答案是否都能在片段里找到依据)。这两个指标比任何主观感受都可靠,也是向业务方证明系统可用的依据。12. 权限过滤要在检索层做,不能只在展示层做。如果不同部门的知识库有隔离要求,务必在向量库查询时用 metadata filter 限定acl字段,而不是检索完再在应用层过滤——后者一旦日志或中间结果泄露,等于没做隔离。**13.更多硬核网安与AI工具包,请扫码获取完整源码!流式输出改善体感延迟。** Agent 多轮工具调用的总延迟可能到十几秒,用户容易以为卡死。用 `astream
引言:本地 RAG 的真实驱动力过去一年,我参与过几个企业内部知识库项目,几乎每一次技术选型会议都会出现同一个分歧:用云端大模型 API,还是全部本地化部署?云端方案的优势很明确——模型能力强、开箱即用。但真正卡住项目的往往不是能力,而是三件事:数据出域合规(合同、代码、财务单据不能上传第三方)、成本不可控(长文档 RAG 每次检索要塞几千 token 上下文,费用随调用量线性上涨)、版本漂移(API 背后的模型悄悄升级,昨天调好的 Prompt 今天效果变了)。先说成本这件事,很多人低估了。假设每次问答检索 6 个 chunk、每个 chunk 约 700 字,加上系统提示和历史对话,单次请求的输入 token 很容易到 4000~6000。如果按每天 1000 次调用计算,一个月就是 1.2 亿~1.8 亿输入 token。云端 API 即便按较低价位算,每月也是一笔不小的固定支出,而且随着用户养成使用习惯,调用量只会涨不会跌。本地部署则是另一套账本:一台 4090 或 4090D 的工作站一次性投入,之后边际成本几乎只有电费。对于知识库这种“高频、低复杂度、答案有明确依据”的场景,本地小模型的性价比优势非常明显。Ollama + LangChain 的组合正好覆盖这三点:Ollama 基于 llama.cpp,把 GGUF 量化模型的下载、加载、推理封装成一个 HTTP 服务,一条ollama pull就能跑起 7B/14B 模型;LangChain 提供文档加载、切分、向量化、检索、Agent 编排的标准抽象。两者拼起来,一台带 16GB 显存的机器就能撑起一个可用的问答系统。这里稍微展开一下 Ollama 的底层机制,理解它有助于后面调参。Ollama 的推理后端是 llama.cpp,模型以 GGUF 格式分发,GGUF 里除了权重,还包含分词器词表、量化配置、对话模板等元信息。所谓 Q4_K_M,指的是 4bit 混合量化:大部分权重压到 4bit,少数敏感层保留更高精度,从而在体积和效果之间取平衡。一个 7B 模型 Q4 量化后约 4.5GB,14B 约 9GB,32B 约 20GB。Ollama 加载模型时会用内存映射(mmap)把权重文件映射进地址空间,所以首次加载稍慢,之后常驻显存/内存,重复请求的冷启动成本很低。它还内置了模型常驻时间(默认 5 分钟)、并发队列、上下文窗口等运行时参数,这些参数直接决定了 RAG 场景下的表现,后文会反复提到。但我要先泼一盆冷水:“跑通 demo”和“业务可用”之间隔着一条鸿沟。默认参数下的本地 RAG,召回率经常只有 50% 出头,模型还特别爱编。我见过最典型的情况是:检索明明返回了正确段落,模型却因为上下文被截断而答了另一段的内容;或者检索没召回到,模型凭先验知识一本正经地编了一个数字。这篇文章讲清楚原理、给出可运行的代码,重点放在那些只有踩过才知道的坑上。一、核心原理:RAG 是上下文工程,Agent 是决策层1.1 RAG 的三个阶段RAG(Retrieval-Augmented Generation)的本质不是“让模型记住知识”,而是在推理时把正确的上下文塞进窗口。它分为离线索引和在线检索生成两段:索引阶段:文档 → 加载解析 → 切分为 chunk → 用 Embedding 模型编码为向量 → 写入向量库,同时保留来源、页码、标题层级等元数据。检索阶段:用户 query → 同样编码为向量 → 在向量库做近似最近邻搜索(ANN)→ 取 Top-K 片段。生成阶段:把片段拼进 Prompt 模板,约束模型“只依据资料回答”,输出答案并附引用。关键认知:生成模型的能力上限决定了答案的表达质量,但检索质量决定了答案对不对。90% 的 RAG 效果问题出在检索侧,而不是模型侧。把这三个阶段再拆细一点,能看清每个环节的失败模式。索引阶段的第一道关是文档解析。Markdown、TXT 这类纯文本最好处理;PDF 就麻烦得多,尤其是带双栏排版、表格、页眉页脚的扫描件。unstructured会尝试用布局分析还原阅读顺序,但表格往往被拆成一行行错位的文本,数字和表头对不上。我的做法是:对表格密集的文档单独走一条解析路径,先转成 Markdown 表格或 HTML 表格,再切分;对扫描件先做 OCR,并人工抽检 20 页确认质量。解析错了,后面所有环节都是在错误的地基上盖楼。第二道关是切分。chunk 太小,一个完整结论被劈成两半,检索到上半句却答不出下半句;chunk 太大,一段里混了多个主题,向量被“平均”得没有区分度,还会挤占生成窗口。经验值是中文 500~800 字、英文 300~500 词,overlap 取 chunk 的 10%~15%。但比数值更重要的是分隔符的优先级:优先按标题、段落切,最后才按句号切,保证每个 chunk 尽量是语义完整的单元。第三道关是向量化。Embedding 模型通常是双塔结构,用对比学习训练,把语义相近的文本映射到向量空间里相近的位置。检索时用余弦相似度或内积衡量距离。这里有个容易被忽略的点:Embedding 模型是有“领域偏好”的。bge-m3 在通用中文和多语言上表现很好,但如果你的知识库全是法律条文或医疗术语,通用模型的距离度量未必准,这时候要么换领域微调过的 Embedding,要么在检索后加一层重排序(rerank)来兜底。检索阶段用的是近似最近邻(ANN)而不是暴力全量比对,因为十万级向量做精确搜索延迟太高。Chroma 默认用 HNSW(分层可导航小世界图),通过多层图结构把搜索复杂度降到对数级;FAISS 则提供 IVF、PQ 等多种索引。ANN 的代价是可能漏掉少量真正最近的邻居,所以fetch_k要设得比k大,给后续的重排和筛选留余量。生成阶段的关键是 Prompt 约束。片段拼进上下文后,要明确告诉模型“只依据资料回答”“每个结论标注来源”“资料不足就说不知道”。这三条规则看起来简单,但缺一条,幻觉率就会明显上升。另外,片段之间要有清晰的分隔符和编号,否则模型分不清哪句话出自哪份文档,引用就会张冠李戴。1.2 从固定流水线到 Agentic RAG朴素 RAG 是一条写死的链:一次检索、一次生成。真实提问往往不是这样——“对比 A 产品和 B 产品的退款政策”需要两次检索,“上个季度提到的那个方案后来怎么定的”需要先改写 query 消解指代。Agentic RAG 把检索封装成工具(Tool),由模型自主决定调用几次、用什么 query 调用、要不要基于中间结果再检索一轮。LangChain 的create_tool_calling_agent提供了这个能力:模型输出结构化的工具调用请求,执行器运行工具并把结果回填到agent_scratchpad,循环直到模型给出最终答案。它的循环大致是这样的:第一轮,模型看到用户问题和工具描述,判断需要检索,输出一个 JSON,形如{"name": "search_kb", "arguments": {"query": "差旅报销 住宿标准 一线城市"}};执行器调用工具,把返回的文本追加到 scratchpad;第二轮,模型基于检索结果决定是直接回答,还是再换关键词检索一次;直到模型输出不含工具调用的普通文本,循环结束。max_iterations就是给这个循环设的硬上限,防止模型反复检索不收敛。代价是延迟和 token 消耗上升,且小模型的工具调用稳定性不如 GPT-4 级别。我的经验是:7B 模型做单轮工具调用够用,超过 3 轮迭代就容易跑偏,所以max_iterations必须设上限。小模型跑偏的典型表现有三种:一是把工具名拼错,或者参数 JSON 少个引号,导致解析失败;二是反复用几乎相同的 query 检索,陷入死循环;三是检索到内容后不回答,继续调用工具。前两种靠max_iterations和handle_parsing_errors=True兜底,第三种则要在 Prompt 里明确写“若已获得足够信息,请直接给出最终答案,不要再次调用工具”。还有一个工程上的权衡:Agentic RAG 的每次工具调用都是一次完整的 LLM 推理,延迟是朴素 RAG 的两到三倍。对交互式问答,这个延迟可以接受;对批量离线任务,我通常还是用固定流水线加查询改写,把多跳问题拆成预定义的子查询,稳定性和吞吐都更好。1.3 本地栈的组件选型组件推荐说明推理框架Ollama开发调试极快;生产高并发可换 vLLM生成模型qwen2.5:7b-instruct / llama3.1:8b中文场景优先 Qwen 系列Embeddingbge-m3 / nomic-embed-textbge-m3 中文与多语言更强,1024 维向量库Chroma / FAISS单机十万级文档 Chroma 足够编排LangChain注意 0.2+ 版本已拆包选型表背后有几个判断依据,值得展开说。生成模型方面,7B 是本地部署的“甜点区”:Q4 量化后约 4.5GB,16GB 显存可以留出充足空间给上下文缓存,推理速度在消费级显卡上能到每秒二三十个 token。14B 效果更好,但显存占用翻倍,上下文一长就容易爆。中文场景优先 Qwen 系列,是因为它的中文分词和指令遵循在开源模型里比较扎实,工具调用的 JSON 格式也相对稳定。llama3.1:8b 的英文和通用推理更强,中文长句偶尔会出现表达生硬的问题。Embedding 方面,bge-m3 输出 1024 维,支持稠密、稀疏、多向量三种检索模式,中文语义匹配明显优于早期的 all-MiniLM 系列;nomic-embed-text 只有 768 维,体积小、速度快,适合对延迟敏感或显存紧张的场景。维度越高,向量库占用越大,检索也略慢,但对十万级文档来说差异可以忽略。向量库方面,Chroma 的优点是零配置、自带持久化、支持元数据过滤,Python 接口友好;FAISS 更底层、更快,但需要自己管理索引文件和元数据映射。单机十万级 chunk 以内,Chroma 完全够用;上到百万级再考虑 Milvus、Qdrant 这类专业向量库。编排层要特别注意版本。LangChain 0.2 之后做了大规模拆包,langchain-community、langchain-ollama、langchain-chroma、langchain-text-splitters都是独立包,from langchain.vectorstores import Chroma这种老写法会直接报 ImportError。装依赖时最好锁定版本,避免不同包之间 API 不兼容。一个必须强调的细节:Embedding 模型和生成模型是两套独立模型,都要通过 Ollama 拉取。很多人只 pull 了对话模型,结果代码一跑就报model not found。验证方法是分别调用两个接口:/api/chat测生成,/api/embeddings测向量化,两个都返回正常再往下写代码。二、实战:从零搭建一个带引用的知识库问答 Agent2.1 环境准备# 安装 Ollama(Linux)curl-fsSLhttps://ollama.com/install.sh|sh# 拉取生成模型与嵌入模型ollama pull qwen2.5:7b-instruct ollama pull bge-m3# 验证服务与嵌入接口curlhttp://localhost:11434/api/tagscurlhttp://localhost:11434/api/embeddings\-d'{"model":"bge-m3","prompt":"测试向量化"}'# Python 依赖pipinstalllangchain langchain-community langchain-ollama\langchain-chroma langchain-text-splitters unstructured这段命令看似简单,实际执行时容易卡在几个地方。第一,Ollama 安装脚本会注册 systemd 服务并监听127.0.0.1:11434。如果服务没起来,curl会直接 connection refused。排查顺序是:systemctl status ollama看服务状态,journalctl -u ollama -n 50看日志,确认端口没被其他进程占用。第二,ollama pull下载的是 GGUF 权重,7B 模型约 4~5GB,网络不稳时容易中断,重跑即可断点续传。第三,unstructured解析 PDF 需要额外的系统依赖(poppler、tesseract),只装 Python 包会在解析时报“缺少 libmagic”之类的错误,Debian/Ubuntu 上补一句apt install libmagic1 poppler-utils tesseract-ocr即可。Python 版本建议 3.10 以上,3.9 在部分 LangChain 子包上会有类型注解兼容问题。装完后可以用下面几行代码做一次端到端自检,确认生成和嵌入两条链路都通:fromlangchain_ollamaimportChatOllama,OllamaEmbeddings# 生成链路自检llm=ChatOllama(model="qwen2.5:7b-instruct",temperature=0)print(llm.invoke("用一句话说明什么是RAG").content)# 嵌入链路自检,同时确认维度emb=OllamaEmbeddings(model="bge-m3")vec=emb.embed_query("测试向量化")print(len(vec))# bge-m3 应输出 1024如果len(vec)不是 1024,说明模型没拉对,或者请求打到了别的服务上。2.2 索引构建:切分策略比什么都重要fromlangchain_community.document_loadersimportDirectoryLoader,UnstructuredMarkdownLoaderfromlangchain_text_splittersimportRecursiveCharacterTextSplitterfromlangchain_ollamaimportOllamaEmbeddingsfromlangchain_chromaimportChroma# 1. 加载:保留 source 元数据,后续引用要用loader=DirectoryLoader("./docs",glob="**/*.md",loader_cls=UnstructuredMarkdownLoader,show_progress=True)docs=loader.load()# 2. 切分:优先按 Markdown 标题切,保证语义完整splitter=RecursiveCharacterTextSplitter(chunk_size=700,chunk_overlap=100,separators=["\n## ","\n### ","\n\n","\n","。"," "],)chunks=splitter.split_documents(docs)fori,cinenumerate(chunks):c.metadata["chunk_id"]=f"chunk-{i}"# 便于溯源与反馈闭环# 3. 向量化入库(bge-m3 输出 1024 维)embeddings=OllamaEmbeddings(model="bge-m3",base_url="http://localhost:11434")vectordb=Chroma.from_documents(documents=chunks,embedding=embeddings,persist_directory="./chroma_db",collection_name="internal_kb",)print(f"已索引{len(chunks)}个片段")这里用RecursiveCharacterTextSplitter并把 Markdown 标题作为第一优先级分隔符,目的是避免把一个完整章节从中间劈开——语义断裂的 chunk 是召回噪声的主要来源。具体解释一下separators的顺序:切分器会先尝试用\n##把文档切成二级标题块,如果某块仍超过chunk_size,再用\n###切三级标题,然后退到空行分段,最后才用句号和空格。这个“逐级降级”的策略保证了大结构优先、句子兜底,不会出现一个 chunk 从标题中间开始的情况。chunk_overlap=100是为了让相邻 chunk 共享一段上下文,避免答案刚好卡在边界上时两边都只拿到半句。元数据的设计同样重要。除了加载器自动写入的source,我通常还会补三类字段:title(章节标题,从 Markdown 文本里正则提取)、chunk_id(唯一标识,用于反馈闭环和去重)、acl(权限标签,检索时按用户角色过滤)。后两个在 demo 里看不出价值,但一旦要做“引用可点击跳转”和“不同部门看到不同文档”,没有它们就得重建整个索引。索引构建还有两个实战中绕不开的问题。一是增量更新:全量重建在文档上千后要跑几十分钟,正确做法是对每个文件算内容 hash,只重新索引 hash 变化的文件,并删除旧 chunk。Chroma 支持按 metadata 过滤删除,配合chunk_id里的文件前缀就能实现。二是编码问题:批量加载时偶尔会遇到 GBK 编码的 txt 导致整个 loader 抛异常,建议在loader_cls外层包一层 try/except,把失败文件记进日志单独处理,不要让一个坏文件阻断整批索引。2.3 封装检索工具并构建 Agentfromlangchain_ollamaimportChatOllamafromlangchain_core.toolsimporttoolfromlangchain_core.promptsimportChatPromptTemplatefromlangchain.agentsimportcreate_tool_calling_agent,AgentExecutor# MMR 检索:兼顾相关性与多样性,避免返回 6 段几乎重复的内容retriever=vectordb.as_retriever(search_type="mmr",search_kwargs={"k":6,"fetch_k":20,"lambda_mult":0.5},)@tooldefsearch_kb(query:str)->str:"""在本地企业知识库中检索相关文档片段。输入应为一句精炼的检索语句。"""docs=retriever.invoke(query)ifnotdocs:return"知识库中未检索到相关内容。"return"\n\n".join(f"[{i+1}] 来源:{d.metadata.get('source')}\n{d.page_content}"fori,dinenumerate(docs))# num_ctx 必须显式调大,默认窗口装不下 6 个 chunkllm=ChatOllama(model="qwen2.5:7b-instruct",temperature=0.1,num_ctx=8192)prompt=ChatPromptTemplate.from_messages([("system","你是企业内部知识库助手。规则:\n""1. 回答必须完全基于检索工具返回的资料,禁止使用自身先验知识补充;\n""2. 每个结论句末用 [编号] 标注来源;\n""3. 若资料不足以回答,直接回复“知识库中未找到相关信息”,不要猜测;\n""4. 若一次检索不足,可换用不同关键词再次调用检索工具。"),("human","{input}"),("placeholder","{agent_scratchpad}"),])agent=create_tool_calling_agent(llm,[search_kb],prompt)executor=AgentExecutor(agent=agent,tools=[search_kb],max_iterations=4,# 防止小模型陷入检索死循环return_intermediate_steps=True,verbose=True,)resp=executor.invoke({"input":"我们公司差旅报销的住宿标准是多少?一线城市和其他城市有区别吗?"})print(resp["output"])这套代码跑起来后,return_intermediate_steps会暴露每一轮的工具调用参数——这是调试 RAG 最有价值的信息,你能直接看到模型把 query 改写成了什么,从而判断问题出在改写还是召回。几个参数值得单独说明。search_type="mmr"是最大边际相关(Maximal Marginal Relevance),它在“与 query 相关”和“与已选结果不重复”之间做加权平衡,公式可以粗略理解为score = λ·sim(q, d) - (1-λ)·max sim(d, d_selected)。lambda_mult=0.5表示相关性和多样性各占一半;如果发现返回的片段太发散、答非所问,就调高到 0.7;如果返回的全是同一段落的重复表述,就调低到 0.3。fetch_k=20是先召回 20 个候选再从中挑 6 个,给 MMR 留出选择空间。@tool装饰器的函数名和 docstring 就是给模型看的“工具说明书”,模型据此判断什么时候调用、参数怎么填。docstring 里写“输入应为一句精炼的检索语句”,是在引导模型做查询改写,而不是把用户的整句口语原样传进来。实测这句话能明显减少“把‘你好,我想问一下……’整句当 query”的情况。num_ctx=8192是这套配置的命门。Ollama 旧版本默认上下文只有 2048,6 个 700 字 chunk 加上系统提示、工具返回格式、scratchpad,很容易到五六千 token,超出部分会被静默截断——模型看到的是残缺的上下文,表现就是“检索到了却答非所问”。改成 8192 后,显存占用会增加,7B Q4 模型在 16GB 卡上仍然宽裕。Prompt 里的四条规则各有用处:第 1 条压制先验知识,第 2 条保证可追溯,第 3 条给模型一个“体面的退路”(否则它倾向于硬编),第 4 条允许有限的多跳检索。规则不是越多越好,超过六条小模型就开始顾此失彼,建议按业务逐步加、每加一条都回归测试。跑通后,一次典型的intermediate_steps长这样:第一轮工具调用 query 是“差旅报销 住宿标准 一线城市”,返回 6 个片段;第二轮模型直接输出答案,每个结论后带[1]、[3]这样的编号。如果看到第一轮 query 被改写得莫名其妙,问题在 Prompt 或工具描述;如果 query 合理但返回片段不相关,问题在切分或 Embedding;如果片段相关但答案仍编造,问题在生成 Prompt 或num_ctx。按这个顺序排查,能省下大量瞎试的时间。三、踩坑与优化建议以下每一条都是我在真实项目里付出过时间代价换来的。1. Ollama 默认上下文窗口太小。旧版本默认num_ctx=2048,6 个 700 字的 chunk 加上系统提示早就溢出了,模型会静默截断前面的内容,表现为“明明检索到了却答非所问”。务必在ChatOllama里显式设置num_ctx=8192甚至16384,并用ollama show qwen2.5:7b-instruct确认模型支持的最大上下文。调大后要观察显存,如果出现 OOM,优先减k而不是减num_ctx——上下文完整性比多召回一段更重要。2. 换 Embedding 模型必须重建索引。向量空间是由具体模型定义的,bge-m3 的 1024 维向量和 nomic-embed-text 的 768 维向量不在同一个空间,混用会导致检索结果完全随机。更隐蔽的情况是同一模型换了量化版本或升级了版本号,向量分布也可能轻微漂移。我的做法是把模型名和版本写进 collection 名,例如internal_kb_bgem3_v1,换模型就换 collection,旧索引保留用于回滚。3. 检索结果要设相关性阈值。Top-K 检索永远会返回 K 条,哪怕知识库里根本没有相关内容。不加过滤的话,模型会拿着不相关的片段硬答。可以在检索后按相似度分数过滤(Chroma 的similarity_search_with_score会返回距离),距离超过阈值的直接丢弃;如果全部被丢弃,就返回“未找到”。阈值需要在你的数据上标定,一般取召回集距离分布的 70% 分位附近。4. 加一层重排序能显著提升精度。向量检索是粗排,召回快但精度有限。引入 bge-reranker 这类交叉编码器,对 Top-20 候选逐对计算 query 与片段的相关性,再取前 5 个送进生成,召回率通常能提升十几个百分点。代价是每次查询多几十到上百毫秒,对交互式场景可以接受。Ollama 不直接提供 rerank 接口,可以用sentence-transformers本地加载 reranker 模型,或走独立的推理服务。5. 中文分隔符要单独处理。RecursiveCharacterTextSplitter的默认分隔符是英文标点,中文文档如果只靠\n\n切,容易切出超长 chunk。把。、;、!、?加进separators列表,并放在空格之前,能让切分更贴合中文语义。另外,中文没有空格,按词切分需要分词器,纯字符切分对中文反而更稳。6. PDF 和表格是解析重灾区。双栏 PDF 会被读成左右交错,表格会被拆成错位的行。对这类文档,建议先转成 Markdown(用 marker、pymupdf4llm 等工具),人工抽检后再入库;表格单独抽成结构化数据,问答时走“文本检索 + 表格查询”两条路。解析质量差是很多 RAG 项目“看起来能跑、实际不能用”的根因。7. 小模型的工具调用要加护栏。除了max_iterations,还要设handle_parsing_errors=True,并在 Prompt 里明确工具调用的格式要求。如果模型频繁输出非法 JSON,可以换用支持原生 function calling 的模型版本,或者退化为“固定检索 + 单轮生成”的朴素链路,牺牲灵活性换稳定性。8. 温度和重复惩罚要按任务调。知识库问答要的是忠实复述,temperature设 0~0.2 即可;调高会增加措辞的多样性,但也增加编造风险。如果发现模型反复输出同一句话,可以适度调repeat_penalty(Ollama 通过repeat_penalty参数控制),但不要超过 1.2,否则会损伤正常表达。9. 并发请求要控制。Ollama 默认对同一模型串行处理请求,并发上来后排队延迟会很难看。可以通过环境变量OLLAMA_NUM_PARALLEL提高并行度,但要按显存算清楚:每个并发上下文都占显存,7B Q4 加 8K 上下文,单路大约 6~7GB,16GB 卡最多两路。生产环境高并发建议换 vLLM,它用 PagedAttention 管理 KV cache,吞吐高一个量级。10. 索引和配置都要版本化。切分参数、Embedding 模型、Prompt 模板任何一项改动都会影响效果。把这三者连同 collection 名一起写进配置文件并打版本号,每次效果回归都能定位到是哪次改动引入的。我见过团队改了 chunk_size 却没重建索引,排查了两天才发现检索用的还是旧库。11. 建立最小评估集。不要靠“感觉”判断效果。从真实问题里抽 50~100 条,人工标注标准答案和应召回的文档片段,每次改动后跑一遍,统计召回率(正确片段是否在 Top-K 里)和忠实度(答案是否都能在片段里找到依据)。这两个指标比任何主观感受都可靠,也是向业务方证明系统可用的依据。12. 权限过滤要在检索层做,不能只在展示层做。如果不同部门的知识库有隔离要求,务必在向量库查询时用 metadata filter 限定acl字段,而不是检索完再在应用层过滤——后者一旦日志或中间结果泄露,等于没做隔离。**13.更多硬核网安与AI工具包,请扫码获取完整源码!流式输出改善体感延迟。** Agent 多轮工具调用的总延迟可能到十几秒,用户容易以为卡死。用 `astream