ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零手搓个人知识库问答机器人:RAG全链路实战与调优

从零手搓个人知识库问答机器人:RAG全链路实战与调优 1. 为什么我要从零手搓一个个人知识库问答机器人我手头攒了大概七八年的技术笔记散落在各种 Markdown 文件、PDF 论文、网页剪藏和微信收藏里。每次想找某个具体问题的答案要么靠记忆里的关键词去全文搜索要么翻半天找不到。通用大模型虽然能回答很多问题但它不知道我笔记里写了什么问它我上次记录的那个数据库连接池参数怎么配的它只能瞎编。这就是我要做个人知识库问答机器人的直接动机让模型基于我自己的资料回答问题而不是基于它训练时的通用知识。这个方向在圈子里叫 RAG检索增强生成核心思路是把我的文档切片、向量化、存进向量库用户提问时先检索出最相关的片段再把这些片段作为上下文喂给大模型让它看着材料答题。为什么不用现成的产品我试过几个在线知识库工具问题在于第一我的笔记里有不少内部项目代号和半成品思路不想上传到别人的服务器第二现成产品的切片策略、检索逻辑都是黑盒出了问题没法调第三我想借这个项目把 Agent 的完整链路跑通一遍包括文档加载、切分、嵌入、检索、生成、工具调用这一整套。自己写一遍每个环节的坑都踩过以后遇到线上问题才知道往哪查。这个项目适合谁如果你有大量个人文档想做成问答系统或者你正在学 LangChain、想找一个能跑通的完整项目练手再或者你已经在用 Dify 这类平台但想搞清楚底层到底发生了什么那这篇内容会对你有帮助。我会把选型理由、代码结构、参数计算、踩坑过程全部摊开讲你照着做能跑起来跑起来之后还能知道怎么调优。需要提前说明的是我用的技术栈是 Python LangChain 本地嵌入模型 本地向量库 在线大模型 API。选这个组合的原因是嵌入模型跑在本地文档不出机器向量库用本地的不依赖外部服务大模型用 API因为本地跑生成模型对显存要求太高个人机器扛不住。如果你机器够强生成模型也能换成本地的接口是兼容的。2. 文档加载与切分决定问答质量的第一道关卡2.1 加载器选型与编码陷阱LangChain 提供了几十种 Document Loader覆盖 Markdown、PDF、Word、网页、Notion 等格式。我的资料主要是三类Markdown 笔记、PDF 论文、网页剪藏HTML。对应使用UnstructuredMarkdownLoader、PyPDFLoader和UnstructuredHTMLLoader。这里第一个坑是编码问题。我的 Markdown 笔记有些是早年用 Windows 记事本存的编码是 GBK直接读会报UnicodeDecodeError。解决办法是在加载时显式指定编码from langchain_community.document_loaders import UnstructuredMarkdownLoader loader UnstructuredMarkdownLoader( file_pathnotes/database.md, encodingutf-8, # 如果是 GBK 就改成 gbk modesingle # 整个文件作为一个 Document ) docs loader.load()mode参数有两个值single把整个文件当一个 Documentelements按标题、段落拆成多个 Document。我建议先用single因为后续还要统一切分让加载器先拆一遍反而打乱了结构。第二个坑是PDF 的表格和公式。PyPDFLoader提取纯文本时表格会变成一堆错位的文字公式直接丢失。如果你的 PDF 里有大量表格建议换成UnstructuredPDFLoader并开启strategyhi_res它会调用 OCR 和版面分析模型效果好很多但速度慢。我的做法是论文类 PDF 用hi_res普通文档用默认的fast策略。第三个坑是网页剪藏的噪声。HTML 里混着导航栏、广告、评论区直接提取会污染知识库。我用的方案是先用BeautifulSoup做一轮清洗只保留article或main标签内的内容再交给加载器。2.2 切分策略为什么 500 字符是个危险的默认值文档切分Text Splitting是 RAG 里最容易被忽视、但对效果影响最大的环节。LangChain 默认的RecursiveCharacterTextSplitter参数是chunk_size4000, chunk_overlap200这个值对英文还行对中文明显偏大。为什么因为中文的信息密度比英文高。同样 4000 个字符英文可能只讲了两三个要点中文可能塞了十几个要点。chunk 太大检索出来的片段里混着大量无关内容模型容易被干扰chunk 太小一个完整的逻辑被切断模型看不到上下文。我实测下来中文技术笔记的合理区间是300 到 600 字符overlap 设为 chunk_size 的 15% 到 20%。具体怎么定我的方法是拿几篇典型笔记用不同参数切一遍然后人工看切出来的片段是否语义完整。一个判断标准是单独看这个片段能不能回答一个具体问题。如果能说明切分合理如果片段开头是因此我们得出结尾是综上所述那明显是被切断了。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n\n, \n, 。, , , , , , ], length_functionlen, ) chunks splitter.split_documents(docs)注意separators的顺序很关键。RecursiveCharacterTextSplitter会按顺序尝试用分隔符切先用\n\n段落切不动再用\n换行再不行用中文句号。这个顺序保证了优先在段落边界切其次在句子边界切最后才在词边界切。中文场景下一定要把中文标点加进去否则它会按空格切而中文句子中间没有空格就会硬切。2.3 元数据保留让检索结果可溯源切分时有个细节容易被忽略保留元数据。每个 chunk 应该带上来源文件名、原始页码、所属标题等信息。这样检索出来之后你能知道这段话是从哪篇笔记的哪一页来的方便核对。for i, chunk in enumerate(chunks): chunk.metadata[chunk_id] i chunk.metadata[source] chunk.metadata.get(source, unknown)我在实际使用中发现加上source之后问答机器人可以在回答末尾附上参考来源database.md 第 3 节这对建立信任感很重要。用户看到来源就知道这个答案不是模型瞎编的。3. 嵌入模型与向量库本地化方案怎么选3.1 嵌入模型中文场景别直接用 OpenAI 的嵌入模型Embedding Model负责把文本转成向量。选型时主要看三个指标中文效果、向量维度、推理速度。OpenAI 的text-embedding-ada-002英文效果很好但中文效果一般而且要把文档传到它的服务器不符合我文档不出机器的要求。所以我转向本地模型。本地中文嵌入模型里我对比过几个模型维度中文效果显存占用备注text2vec-base-chinese768中等约 1GB老牌稳定bge-large-zh-v1.51024优秀约 2.5GB目前中文首选bge-small-zh-v1.5512良好约 0.5GB轻量速度快m3e-base768良好约 1GB中文优化我最终选了bge-large-zh-v1.5因为我的笔记里有不少专业术语和缩写需要模型对中文语义有较强的理解。如果你的机器显存紧张bge-small-zh-v1.5是很好的折中效果差距在可接受范围内。用 LangChain 接入本地嵌入模型from langchain_community.embeddings import HuggingFaceEmbeddings embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-large-zh-v1.5, model_kwargs{device: cuda}, # 没显卡就改成 cpu encode_kwargs{normalize_embeddings: True} # 关键归一化 )normalize_embeddingsTrue这个参数很重要。向量归一化之后余弦相似度等价于内积检索时计算更快而且不同长度的文本向量可比性更好。bge 系列的官方文档也建议开启归一化。3.2 向量库Chroma 够用但要知道它的边界向量库我选了 Chroma理由是纯 Python 实现pip 装完就能用支持持久化到本地磁盘API 简单。对于个人知识库这种几万到几十万 chunk 的规模Chroma 完全够用。from langchain_community.vectorstores import Chroma vectorstore Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db, collection_namemy_knowledge ) vectorstore.persist()但 Chroma 有几个边界要知道第一它默认用暴力检索brute force也就是把查询向量和库里所有向量算一遍相似度。几万条没问题上百万条就慢了。如果你的库很大需要换成支持 HNSW 索引的库比如 FAISS 或 Milvus。第二它的持久化是追加式的。如果你重复运行from_documents它会重复写入导致库里有重复数据。正确做法是先判断 collection 是否存在存在就加载不存在才创建import os if os.path.exists(./chroma_db): vectorstore Chroma( persist_directory./chroma_db, embedding_functionembeddings, collection_namemy_knowledge ) else: vectorstore Chroma.from_documents(...) vectorstore.persist()第三删除文档不方便。Chroma 支持按 ID 删除但如果你不知道 ID就得重建整个库。我的做法是给每个 chunk 的 ID 设成文件名_序号这样要删某个文件的所有 chunk可以用前缀匹配批量删。3.3 检索参数top_k 和相似度阈值怎么定检索时有两个关键参数k返回几个片段和score_threshold相似度阈值。k太小可能漏掉关键信息k太大噪声多还会撑爆模型的上下文窗口。我的经验值是k4 到 6。为什么因为一个问题的答案通常集中在 1 到 2 个片段里返回 4 到 6 个是给模型留冗余同时不至于太吵。score_threshold用来过滤掉明显不相关的片段。bge 模型的相似度分数范围大概是 0.3 到 0.9低于 0.4 的基本可以认为是无关的。但阈值不能设太高否则容易把相关但表述不同的片段也过滤掉。我一般设 0.35 到 0.45 之间具体看实测。retriever vectorstore.as_retriever( search_typesimilarity_score_threshold, search_kwargs{k: 5, score_threshold: 0.4} )这里有个坑search_type用similarity时score_threshold不生效必须用similarity_score_threshold。我第一次配的时候没注意阈值设了等于没设检索出来一堆无关内容。4. 从检索到生成Prompt 设计与上下文组装4.1 Prompt 模板把不许瞎编写进指令检索出来的片段怎么喂给模型直接决定了回答质量。我见过很多人直接把片段拼接起来丢给模型结果模型要么忽略材料自己编要么把材料原封不动抄一遍。我的 Prompt 模板是这样的from langchain.prompts import PromptTemplate template 你是一个基于个人知识库的问答助手。请严格根据下面提供的参考资料回答问题。 规则 1. 如果参考资料中有明确答案直接回答并在末尾标注来源。 2. 如果参考资料中没有相关信息明确说根据现有资料无法回答不要编造。 3. 回答要简洁不要重复参考资料中的原文用你自己的话组织。 4. 如果多个资料片段有冲突指出冲突并说明各自的出处。 参考资料 {context} 问题{question} 回答 prompt PromptTemplate( templatetemplate, input_variables[context, question] )这个模板里有几个设计点值得说第一明确无法回答的出口。如果不写这条模型遇到资料里没有的问题会倾向于用通用知识硬答这就违背了知识库问答的初衷。给它一个说不知道的选项它反而更诚实。第二要求标注来源。这倒逼模型真的去看资料而不是凭记忆回答。而且用户能核对建立信任。第三要求用自己的话组织。不写这条模型经常把资料原文复制一遍读起来很生硬。4.2 上下文组装怎么拼接多个片段检索出来的是多个 Document 对象需要拼成一个字符串。拼接时要注意每个片段前面加上来源标记方便模型引用片段之间用分隔符隔开避免模型把两个片段的内容混在一起总长度要控制不能超过模型的上下文窗口def format_docs(docs): formatted [] for i, doc in enumerate(docs): source doc.metadata.get(source, 未知来源) formatted.append(f[片段{i1} 来源{source}]\n{doc.page_content}) return \n\n---\n\n.join(formatted)这里有个细节片段的顺序。我试过按相似度从高到低排也试过按原始文档顺序排。实测下来按相似度排效果更好因为模型对开头的内容注意力更集中把最相关的放前面回答质量更高。4.3 大模型选型与调用参数生成模型我用的是在线 API。选型时主要看中文能力、上下文窗口、价格、响应速度。我的笔记里有不少长文档所以上下文窗口至少要 32K。调用参数里temperature我设成 0.1 到 0.3。为什么这么低因为知识库问答要的是准确不是创意。温度高了模型容易自由发挥把资料里没有的内容编进去。max_tokens设成 1024 到 2048够回答大部分问题了。from langchain_openai import ChatOpenAI llm ChatOpenAI( modelgpt-4o-mini, # 或换成其他兼容 OpenAI 接口的模型 temperature0.2, max_tokens1500, timeout30, max_retries2 )timeout和max_retries这两个参数很多人不设结果网络抖动时程序直接崩。设上之后偶发的超时会自动重试稳定性好很多。5. 用 LangChain 把链路串起来Chain 还是 Agent5.1 先跑通 Chain再考虑 Agent很多人一上来就想做 Agent让模型自己决定调什么工具。我的建议是先用 Chain 把基础问答跑通再逐步加 Agent 能力。原因很简单Chain 是确定性的输入经过固定的检索、组装、生成流程出问题容易定位。Agent 是模型自主决策它可能不检索就直接回答也可能反复调工具陷入循环调试成本高得多。基础 Chain 用 LCELLangChain Expression Language写起来很简洁from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser def build_qa_chain(retriever, llm, prompt): def retrieve_and_format(question): docs retriever.invoke(question) return format_docs(docs) chain ( {context: retrieve_and_format, question: RunnablePassthrough()} | prompt | llm | StrOutputParser() ) return chain这个 Chain 的逻辑是输入问题先检索并格式化上下文然后和问题一起填入 Prompt交给模型最后解析成字符串输出。整条链路清晰每一步都能单独测试。5.2 什么时候该上 Agent基础 Chain 能回答资料里写了什么这类问题但有些场景它搞不定需要多轮检索比如对比 A 和 B 两个方案的优缺点需要分别检索 A 和 B 的资料需要计算比如我记录的这几个数字加起来是多少需要调计算器需要查外部信息比如这个库的最新版本是多少需要联网查这些场景就需要 Agent 出场。Agent 的核心是给模型一组工具让它自己决定调哪个、调几次。from langchain.agents import create_react_agent, AgentExecutor from langchain.tools import Tool tools [ Tool( nameknowledge_search, funclambda q: format_docs(retriever.invoke(q)), description搜索个人知识库。输入是搜索关键词返回相关文档片段。 ), Tool( namecalculator, funclambda x: str(eval(x)), description执行数学计算。输入是数学表达式如 23*4。 ) ] agent create_react_agent(llm, tools, prompt) agent_executor AgentExecutor( agentagent, toolstools, max_iterations5, # 防止无限循环 verboseTrue, handle_parsing_errorsTrue )max_iterations这个参数一定要设。我踩过的坑是模型有时候会反复调同一个工具不设上限就会一直转下去烧 token 还不出结果。设成 5 次超过就强制停止并返回已有信息。handle_parsing_errorsTrue也很重要。Agent 的输出格式要求严格模型偶尔会输出不符合格式的内容开启这个参数后LangChain 会把错误信息反馈给模型让它重新生成而不是直接抛异常。5.3 工具描述怎么写才有效Agent 能不能正确选工具很大程度上取决于工具描述写得好不好。我总结了几条经验第一描述要说明什么时候用。不要只写搜索知识库要写当问题涉及个人笔记、技术方案、项目记录时使用。第二说明输入格式。模型需要知道传什么参数进来。写清楚输入是搜索关键词还是输入是数学表达式。第三说明返回什么。模型需要知道调用后能拿到什么才能决定下一步。写清楚返回相关文档片段。我试过把描述写得很简略结果模型经常该调工具的时候不调或者调错工具。把描述写详细之后工具选择的准确率明显提升。6. 实测中暴露的问题与调优过程6.1 检索不准问题出在切分还是嵌入项目跑通之后我拿了几十个真实问题测试发现有些问题检索不到正确的片段。排查过程是这样的第一步确认是检索问题还是生成问题。我把检索出来的片段打印出来看发现有些问题检索出来的片段确实不相关说明问题出在检索环节不是模型不会答。第二步确认是切分问题还是嵌入问题。我把正确片段单独拿出来算它和问题的相似度发现相似度很高说明嵌入模型没问题是检索时没把它排到前面。再一看正确片段被切成了两半关键信息分散在两个 chunk 里每个 chunk 单独看都不够相关。第三步调整切分参数。我把 chunk_size 从 500 调到 800overlap 从 80 调到 150让片段更完整。重新索引后检索准确率明显提升。这个排查过程说明一个道理RAG 效果不好先查切分再查嵌入最后查检索参数。切分是最容易出问题、也最容易调整的环节。6.2 回答太啰嗦Prompt 和模型参数双管齐下另一个问题是模型回答太啰嗦明明一句话能说清非要写一大段。我做了两件事第一在 Prompt 里加约束。明确写回答控制在 200 字以内、不要重复资料原文。第二调低 max_tokens。从 2048 降到 1024物理上限制长度。两个措施一起上回答明显简洁了。但要注意max_tokens 不能设太低否则回答会被截断。我试过设 512结果复杂问题的答案说到一半就没了。6.3 多轮对话怎么让机器人记住上下文基础 Chain 是无状态的每次问答都是独立的。但实际使用中用户会追问比如那这个方案的缺点呢这时候需要机器人记得上一轮聊的是什么。LangChain 提供了ConversationBufferMemory来保存对话历史。但直接全量保存会有问题对话长了之后历史占满上下文窗口而且早期的不相关内容会干扰当前回答。我的方案是用ConversationSummaryBufferMemory它会在历史超过一定长度时自动摘要保留关键信息丢弃细节from langchain.memory import ConversationSummaryBufferMemory memory ConversationSummaryBufferMemory( llmllm, max_token_limit1000, memory_keychat_history, return_messagesTrue )max_token_limit设成 1000意思是历史超过 1000 token 就触发摘要。这个值要根据你的模型上下文窗口来定一般占窗口的 10% 到 20%。6.4 并发问题个人用和多人用的差别个人用的时候一次问一个问题没什么并发压力。但如果想分享给团队用就要考虑并发。我实测下来瓶颈主要在嵌入模型和向量库检索。嵌入模型如果跑在 CPU 上一次编码要几百毫秒并发请求会排队。解决办法是把嵌入模型也放到 GPU 上或者用批处理。LangChain 的embed_documents支持批量传入一次编码多个文本比循环单条编码快很多。向量库检索本身很快Chroma 在几万条规模下单次检索在毫秒级。但如果并发量上来Python 的 GIL 会成为瓶颈。这时候可以考虑用 FastAPI 起服务配合多进程部署。from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class Query(BaseModel): question: str app.post(/ask) async def ask(query: Query): answer chain.invoke(query.question) return {answer: answer}用 FastAPI 的好处是它原生支持异步IO 等待时能处理其他请求。但要注意LangChain 的很多组件是同步的在异步函数里直接调会阻塞事件循环。解决办法是用run_in_executor包一层或者用 LangChain 的异步接口ainvoke。7. 几个我踩过的坑和对应的解法7.1 中文标点导致的切分异常前面提过 separators 要加中文标点但这里还有个细节中文的省略号和破折号。……和——在有些文本里是连续的两个字符如果 separators 里只写了一个切分时会漏掉。我的做法是把常见的连续标点都列进去separators[\n\n, \n, 。, , , , ……, ——, , , ]另外中英文混排的文本里英文句号.后面通常跟空格中文句号。后面不跟空格。如果 separators 里同时有.和。要注意顺序把更常用的放前面。7.2 向量库持久化后加载失败Chroma 持久化之后下次加载时如果collection_name和之前不一致会创建一个新的空 collection而不是加载已有的。我踩过一次索引了半天的数据重启后检索不到排查半天才发现是 collection 名字写错了。解决办法是把 collection 名字写成常量创建和加载都用同一个COLLECTION_NAME my_knowledge_v1 # 创建时 vectorstore Chroma.from_documents(..., collection_nameCOLLECTION_NAME) # 加载时 vectorstore Chroma(..., collection_nameCOLLECTION_NAME)另外如果你改了嵌入模型必须重建整个库。因为不同模型生成的向量空间不一样用 A 模型索引的库用 B 模型检索结果完全是乱的。我建议在 collection 名字里带上模型标识比如my_knowledge_bge_large_v1这样换模型时自然就用了新的 collection不会混淆。7.3 Agent 陷入循环的排查Agent 陷入循环的表现是反复调同一个工具或者两个工具来回调就是不出最终答案。我遇到过几次排查下来原因有两个第一工具返回的结果模型看不懂。比如知识库检索返回了一堆片段但模型不知道这些片段够不够回答问题就反复检索。解决办法是在工具描述里写清楚如果返回结果为空或明显不相关说明知识库中没有相关信息应直接告知用户。第二Prompt 里的格式说明不清楚。ReAct Agent 要求模型按Thought/Action/Action Input/Observation的格式输出如果 Prompt 里没写清楚模型会输出自由格式解析失败后重试看起来像循环。解决办法是用 LangChain 提供的标准 ReAct Prompt 模板不要自己瞎写。from langchain.agents import hub prompt hub.pull(hwchase17/react)这个模板是经过验证的格式说明清晰模型遵循度高。7.4 大文件索引超时我有一本 500 多页的 PDF索引时跑了十几分钟还没完。排查发现瓶颈在嵌入模型CPU 上单条编码要 200 毫秒几千个 chunk 就是十几分钟。解决办法有三个第一用 GPU。有显卡的话编码速度能快 10 倍以上。第二批处理。把 chunk 攒成一批一次编码多个batch_size 32 for i in range(0, len(chunks), batch_size): batch chunks[i:ibatch_size] vectorstore.add_documents(batch)第三先切分再索引不要一次性加载整个文件。大文件加载本身就慢可以流式读取边读边切边索引。我最后是三个措施一起上500 页 PDF 的索引时间从十几分钟降到两分钟以内。8. 这个项目还能往哪些方向扩展基础版本跑通之后我陆续加了一些扩展这里挑几个实用的说说。第一个扩展是混合检索。纯向量检索对语义相似但用词不同的情况效果好但对精确匹配比如查某个具体的错误码效果差。混合检索是把向量检索和关键词检索BM25的结果融合取长补短。LangChain 里有EnsembleRetriever可以直接用from langchain.retrievers import EnsembleRetriever, BM25Retriever bm25_retriever BM25Retriever.from_documents(chunks) bm25_retriever.k 5 ensemble_retriever EnsembleRetriever( retrievers[vectorstore.as_retriever(), bm25_retriever], weights[0.7, 0.3] )权重我设的是向量 0.7、关键词 0.3因为我的问题大多是语义查询关键词查询是补充。第二个扩展是重排序Rerank。检索出来 top 10 个片段用一个专门的重排序模型对它们重新打分把最相关的排到前面。这一步能显著提升精度代价是多一次模型推理。常用的重排序模型有bge-reranker-large用法和嵌入模型类似。第三个扩展是查询改写。用户的问题有时候表述模糊直接检索效果不好。可以让模型先把问题改写成几个更具体的查询分别检索后合并结果。这个技术在 LangChain 里叫MultiQueryRetrieverfrom langchain.retrievers.multi_query import MultiQueryRetriever multi_retriever MultiQueryRetriever.from_llm( retrievervectorstore.as_retriever(), llmllm )它会自动生成 3 个不同角度的查询分别检索去重后返回。实测对模糊问题的召回率提升明显。第四个扩展是加图片处理。我的笔记里有些截图和流程图纯文本检索覆盖不到。目前的方案是用多模态模型给图片生成文字描述把描述存进向量库检索时匹配描述。这样虽然不能直接返回图片但至少能告诉用户某篇笔记里有一张相关的图。这些扩展不用一次全上按需加就行。我的建议是先把基础版本用起来用一段时间发现哪个环节不够用再针对性优化。一上来就堆功能调试成本高而且很多优化在个人使用场景下收益有限。最后分享一个我自己的使用习惯我会定期把问答机器人的回答质量做个抽查随机抽 20 个问题人工判断回答是否正确、来源是否准确。如果准确率低于 80%就回头查是切分问题还是检索问题。这个习惯帮我发现了好几次索引数据过期的问题——笔记更新了但向量库没重建导致机器人还在用旧内容回答。
返回列表