ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于LangChain的RAG实现:从文档加载到回答生成

基于LangChain的RAG实现:从文档加载到回答生成

咱们平时玩大模型肯定都遇过这么个场景:想让它基于自己手头那份资料回答问题,结果它非要自由发挥,给你编个一本正经的答案出来。做知识库问答、私有化部署助手、甚至写个带资料引用的写作工具,都会被这个问题卡脖子。我最早碰这个事是在做一个内部文档问答系统,拿现成的开源大模型直接问,它连“文档里没写”都区分不出来,满嘴跑火车。后来转向RAG(Retrieval-Augmented Generation,检索增强生成)这个技术路线,配合框架把“先检索、再回答”的流程固化下来,才算把模型拴在了指定范围内。这系列文章的第一篇,就先把整个实现链路跑通,用LangChain把文档加载、切片、向量化、检索、拼装prompt这几步串起来,让模型只在咱们喂给它的上下文中找答案。适合刚接触RAG、想快速搭一个最小可行系统的开发者,看完可以直接把代码落进自己的小项目里。

这篇我计划从整体思路讲起,然后把每个环节拆开说清楚,尤其会提到那些Demo代码里不会告诉你、但实战里必然会踩到的坑。先聊为什么要用RAG而不是直接改模型,再看完整流水线长什么样,最后逐段跑通核心代码。

1. 先想清楚一件事:为什么不直接微调模型

很多刚入门的朋友一上来就问我:要让模型懂我的私有资料,是不是得微调?我的回答通常是:先别。微调适合让模型学习某种表达风格、输出格式、固定的思维链,但你指望它靠微调去记住大几千份文档的细节,成本高、时效差、还容易训歪。文档昨天改了一版,你不可能今天重新训一次。

RAG的思路就完全不一样:我不试图把知识塞进模型脑袋里,而是把外部资料变成可检索的索引,每次提问时先把最相关的几段捞出来,连同一个精心设计的指令一起丢给大模型。模型不需要“记住”你的资料,它只需要基于当前给它的这几段内容,做“阅读理解”。这样一来,三个头疼的问题同时解决:文档更新只需要重建索引;回答可以被引用来源;模型也不会因为过度训练产生灾难性遗忘。

命名上RAG的Retrieval指检索、Augmented指增强、Generation指生成,三个单词拼起来就是这套方案的骨架。LangChain在里面充当的角色更像是一个组装车间,它不生产算法,但把加载、切分、向量化、检索、调用模型这些零件统一了接口,让咱们在几百行代码内就能把整条流水线拼起来。

理解了为什么,再往下看实现就不会被细节绕晕。核心要把握一条主线:一切环节的设计,都是为了让最后喂给模型的上下文更精准、更干净。

2. 整条RAG流水线的环节拆解

一套标准的RAG链路,说透了就五个环节:加载、切分、向量化、检索、生成。这五个词对应着LangChain里五个组件类型,也是后面所有代码的组织主线。

2.1 文档加载:拿得进来

文档加载就是把 PDF、Word、Markdown、TXT 这些乱七八糟的格式统一读成纯文本。LangChain里的文档加载器(Document Loader)非常丰富,PDF 有专门处理文本层和OCR的版本,网页有抓取类加载器,数据库有查询类加载器。咱们做知识库最主要还是跟本地文件打交道,最省事的两个类分别是读取文本类的TextLoader和读取PDF的PyPDFLoader。

这里有个新手经常踩的坑:加载器只是把内容读进来,它不负责处理格式乱码和编码问题。中文文本最常见的是编码不对导致乱码,特指一些老旧的TXT或Word导出的文件,默认不是UTF-8。解决办法是加载时明确指定编码,或者在加载前先做一次编码转换。

还有一点容易被忽略:加载器会把每个文档默认为一个独立的 Document 对象,包含page_content和metadata两个字段。metadata里可以放来源文件名、页码、章节标题这些信息,别小看这个字段,后面想让回答带上引用来源,全靠它。

2.2 文本切分:切得合理

加载进来的原始文本动辄上万字,如果直接整个塞给模型,一是可能超出上下文窗口,二是检索的颗粒度太粗,命中率很差。文本切分(Text Splitter)就是解决这个颗粒度问题。

LangChain 里有两类切分器:基础款RecursiveCharacterTextSplitter用分隔符递归切分,无论什么文档都能用;进阶款是针对代码、Markdown、HTML 等特殊结构设计的,会保留代码的函数边界和 Markdown 的标题层级。我平常用得最多的还是递归字符切分器,它虽然朴素,但在大多数场景下表现稳定。

切分器有两个关键参数:chunk_size和chunk_overlap。前者控制每个文本块的最大字符长度,后者控制相邻块之间的重叠字符数。为什么需要重叠?因为文本切分是机械的,很可能把一个完整语义段落拦腰截断。适当重叠可以让检索时尽量保留上下文连贯性。

chunk_size怎么定?如果太小,比如 100,检索出来的文本碎片化严重,语义不完整;如果太大,比如 2000,每块的语义虽然完整,但检索精度下降,还可能超出模型上下文。我常用的起始区间是 300 到 500 字符,按中文场景粗略算就是几十到一百多个字左右,之后根据检索效果上下调整。注意,这里的字符数不是严格按词数算,中文一个汉字就是一个字符,英文字母也逐个计数。

2.3 向量化:转得懂

向量化是 RAG 让“语义匹配”成为可能的关键步骤。简单说就是把一段文本变成一串浮点数,语义相近的文本,它们的向量在空间里的距离也近。这一步靠的是嵌入模型(Embedding Model)。

LangChain 对嵌入模型做一个抽象,只暴露embed_query和embed_documents这两个核心方法。市面上常见的选择分成两类:一类是OpenAI、通义千问等厂商提供的商用API,效果好、省事、按量付费;另一类是本地部署的开源模型,数据不出内网,适合私有化场景,但效果和速度要看具体模型和服务器配置。

选嵌入模型时有个大坑特别值得提醒:检索和入库必须用同一个模型。如果建索引时用模型A,查询时用模型B,向量空间的分布完全对不上,检索效果会直接雪崩。另外,别只看维数,有些模型向量维度高不代表迁移到了更高语义精度,实际效果一定要在自己数据上测。

2.4 向量存储与检索:找得准

向量化完成之后,每段文本对应一个向量,接下来要找一个地方存起来,并在查询时快速找到最相似的向量。这就是向量数据库的活。

向量数据库的选择要分场景:数据量小、开发阶段,用 Chroma 这类嵌入式轻量库最方便,代码里一行启动,无需额外服务;数据量大、生产环境,则需要切到 Milvus、Weaviate 或云上的向量数据库,可扩展性和高可用是它们的核心优势。本文用的 Chroma 就是为了让第一次接触这套流程的人少装几个东西。

检索的逻辑也很直白:把用户问题向量化,然后去库里找距离最近的K个向量,返回对应的原始文本块。这个K就是常说的检索数量。K太小,可能漏掉正确答案;K太大,会塞进无关噪声,干扰模型判断。起步阶段从K=4或5开始,再根据回答质量微调。

2.5 生成回答:答得稳

检索到的文本块只是原料,距离一条合格回答还差一道关键工序——把它们组装进一个精心设计的prompt里。这一步在 LangChain 里通过 Prompt Template 实现。

回答有没有被约束在给定上下文中,核心就看这个模板设计得合不合理。我先给一个自己常用的基础模板:

你是一个严谨的问答助手。请基于“提供的上下文”回答问题。 如果上下文中没有相关内容,请直接回复“根据提供的资料无法回答该问题”。 不要添加从外部获得的信息。 提供的上下文: {context} 用户问题: {question}

这个模板最核心的设计是那句“如果上下文中没有相关内容,请直接回复无法回答”。它从机制上逼着模型只使用检索到的材料,拒绝时也给出了明确的兜底动作。不少团队做完RAG发现回答仍然胡编乱造,排查来排查去,最后一查prompt压根没写这条约束,模型可不就自由发挥了。

到这一步,整条链路的五个环节就全通了。下面直接上代码,从头到尾跑一遍。

3. 核心代码:从零搭一个最小RAG系统

为了不让废话干扰阅读,我先把环境说清楚:这里用 Python 3.10 以上版本,LangChain 生态的部分API在后续版本里可能有细微调整,建议跑示例前先把相关库升级到较新的版本。整个示例的目标是,给定一份本地文本文件,问模型其中某个细节,它能只从这份文件里找答案。

我的习惯是边写边跑,所以下面的内容严格按实际操作顺序来。

3.1 初始化与依赖安装

先创建项目目录,准备一个虚拟环境,然后安装依赖。这里的核心依赖有四个:langchain、langchain-community、langchain-openai、langchain-chroma。如果后续要用PDF,再额外加一个 pypdf。

pip install langchain langchain-community langchain-openai langchain-chroma

安装完成后先做一个最基础的连通性验证:确认能成功调用嵌入模型并生成向量。这样能第一时间排查网络和API密钥问题,别等到整条链路跑完才发现第一步就是错的。

from langchain_openai import OpenAIEmbeddings embeddings = OpenAIEmbeddings(model="text-embedding-3-small") vec = embeddings.embed_query("测试一下") print(len(vec), vec[:3])

这里用text-embedding-3-small是因为它在效果和成本之间比较平衡。如果输出里出现一个数字列表,就说明嵌入这一步通了。在实际的API配置中,你需要在创建客户端的地方填入你的API密钥和接口地址。以常见的兼容OpenAI格式的接口为例,可以在环境变量或代码里给对应字段赋值:

from langchain_openai import OpenAIEmbeddings embeddings = OpenAIEmbeddings( model="text-embedding-3-small", api_key="你的API密钥", base_url="你的接口地址" )

如果对应模型来自其他厂商或本地服务,只需替换model与base_url字段。

3.2 文档加载与切分实现

这一步我们用TextLoader读入一篇自己的文档。为了演示效果,我自己准备了一份模拟项目X的产品说明文档,里面包含功能介绍、参数规格和常见问题几个小节。

from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader = TextLoader("模拟项目X产品说明.txt", encoding="utf-8") documents = loader.load() text_splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=80) docs = text_splitter.split_documents(documents) print(f"切分后文档块数量: {len(docs)}")

这里要注意一个细节:TextLoader默认只创造一个 Document 对象,即使文件本身很大。真正影响后续检索粒度的,是split_documents这个动作。它会按参数将长文本切成多个小 Document,每个Document都会保留从父级继承下来的metadata。

chunk_size=400和chunk_overlap=80是一组我比较常用的起步参数。80个字符的重叠量大概保证前后两个块之间有部分重复,语义衔接不会因为切割而完全断裂。等接入真实数据跑通了流程,再按回答质量调整这两个值。

3.3 写入向量库

有了文档块和嵌入模型,建库这一步本身非常快。LangChain 对 Chroma 的封装比较完善,一句代码就能完成“生成向量”和“写入存储”两个动作。

from langchain_chroma import Chroma vectorstore = Chroma.from_documents(documents=docs, embedding=embeddings) print("向量库构建完成")

默认情况下,Chroma 会在本地目录创建一个持久化存储。如果你不指定目录名,它以随机名存放在缓存目录,重启程序就丢了。好习惯是显式传入persist_directory参数:

vectorstore = Chroma.from_documents(documents=docs, embedding=embeddings, persist_directory="./chroma_db")

之后再次运行程序,不用重新切分和入库,直接加载已有库即可:

vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)

这个环节最值得提醒的是:from_documents不仅做向量化,还会顺带做索引写入。如果数据量大,比如几十万条文本块,这一步会有明显耗时,但这是正常现象。需要更快的话,就要考虑批量写入和异步处理了,那是生产阶段的事,本篇不展开。

3.4 检索测试先行,再接入生成

建完库,先别急着调生成接口。我习惯在那之前先单独测试一下检索环节,确保“查得准”。这一步能帮你把问题定位在检索还是生成上。

retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) query = "模拟项目X的并发数上限是多少?" results = retriever.invoke(query) for i, doc in enumerate(results): print(f"第{i+1}个结果,来源: {doc.metadata}") print(doc.page_content[:100])

如果打印出来的结果和问题相关,说明整个检索链路是通的。如果结果完全不搭边,那么问题出在嵌入模型、切分参数、或者是数据本身三个地方之一,继续往后做生成环节只会更糟。

检索测试没问题了,再接入生成。这里我需要一个对话模型,以OpenAI格式的API为例:

from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate llm = ChatOpenAI(model="gpt-4o-mini", api_key="你的API密钥", base_url="你的接口地址", temperature=0) prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的问答助手。请仅依据“提供的上下文”回答问题。" "如果上下文中没有相关内容,请直接回复“根据提供的资料无法回答该问题”。" "不要添加任何外部信息。"), ("human", "提供的上下文:\n{context}\n\n用户问题:{question}") ])

注意temperature=0,这个参数控制生成的随机性。知识库问答场景下,我们希望答案稳定、可复现,温度越低越好。像这类事实性问答,我基本都是0。

接下来组装一条链。新版LangChain里,传统LLMChain正在被LCEL(LangChain Expression Language)取代。LCEL用竖线连接各个组件,语义清晰、流式调试方便,写起来也很舒服:

from langchain_core.runnables import RunnablePassthrough def format_docs(docs): return "\n\n".join(doc.page_content for doc in docs) rag_chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | llm )

这条链的执行逻辑可以这么理解:先把用户的问题传给检索器,检索到的文档块经过format_docs合并成一段文本作为context;同时问题原样透传给question字段;然后两者一起填进prompt,最后交给llm生成答案。

最后调用一下:

answer = rag_chain.invoke("模拟项目X的并发数上限是多少?") print(answer.content)

完整跑通之后,模型应该能针对文档内容给出准确的回答。如果问一个文档里完全没有的问题,它会明确告诉你资料里没有,而不是去硬编一个答案。到这里,最小可行版本就完成了。

4. 关键技术点与实现细节深挖

核心链路跑通只是第一步,这里面还有几个关键细节,是决定整个系统能不能真正落到业务里的分水岭。我挑四个最常被问到的展开说。

4.1 检索器里藏着两个调节窗口

很多人拿到代码后,发现只看到as_retriever(search_kwargs={"k": 4})一行,就以为检索策略就这一种。其实LangChain的检索器有几个隐藏风格的切换选项,最实用的是search_type二选一:similarity和mmr。

默认的similarity就是普通相似度取TopK,简单高效,但有明显缺陷:当数据集中多个文本块高度相似时,K个结果可能都是同一段内容的变体,信息多样性很差。举个例子,你的文档里十个地方都提到“系统支持高并发”,那检索“并发数上限”时,拿到的四块内容可能都在泛泛介绍高并发,而不是具体数字。mmr(最大边际相关性)就为了解决这个问题,它在相关性与多样性之间取平衡,运行耗时稍高,但结果覆盖更全面。

代码切到MMR很简洁:

retriever = vectorstore.as_retriever( search_type="mmr", search_kwargs={"k": 4, "fetch_k": 20} )

这个fetch_k=20的意思是:先从库里捞出20个候选块,再用MMR算法从中精选4个最终结果。fetch_k要比k大才有意义,一般设为k的5倍左右起步。

很多问题回答质量差,不一定是大模型能力不够,检索策略太单一反而更常见。建议至少把两种都测一下,找适合自己的那条路。

4.2 切分参数不能凭感觉拍脑袋

前面给的chunk_size=400只是起点,具体数值必须结合两块现实来看:一是嵌入模型的理解能力,二是大模型的上下文窗口。

先说模型理解能力。不同嵌入模型对文本长度的敏感程度不同。一些商用模型对中文短文本的语义理解不错,但如果你把chunk_size拉到2000,它未必能在一个向量里编码这么多信息,反而稀释了核心语义。我实测中,很多场景下500到800字符的块长会比1000以上表现更好。

再来看上下文窗口。检索匹配最相关的 K 块文档,最终要拼进整个 prompt 里喂给模型。如果chunk_size大、K 也大,拼出来的文本轻松达到几千甚至上万字符。模型上下文窗口小的话直接报错或者性能骤降。

所以切分参数与检索参数是一对组合拳,调参时要联动着看。建议准备一份10到20个问题的评测集,固定问题,改变参数组合,量一下“回答正确率”和“引用命中率”,用数据决定去留。这一套我后面在专门的调参篇里详细写,这里只需要记住:没有绝对正确的参数,只有适合你数据与模型的参数区间。

4.3 引用来源是RAG系统的加分项

如果产品回答里带上“这个结论出自第几页/哪个文件名”,用户的信任感和可复核性会完全不同。这也是RAG相对普通对话的最大优势之一。

LangChain实现引用比较简单:检索回来的每个 Document 都有一个metadata字段,我们在加载文档时把来源和页码塞进去,生成回答时同时把这部分信息展示出来。

一种常见的做法是把来源信息连同文本块传入提示词尾部,从而让模型在回答末尾附上来源标注。也可以在拿到回答后,直接把命中的metadata作为附加数据输出。我常用的简洁方案是在format_docs里,把每个文档块的来源描述放在文本之前。

def format_docs(docs): blocks = [] for i, doc in enumerate(docs): source = doc.metadata.get("source", "未知来源") blocks.append(f"[片段{i+1} | 来源: {source}]\n{doc.page_content}") return "\n\n".join(blocks)

配合prompt里加一句“回答末尾列出参考的片段编号”,模型回答时就会自动标注了。有引用,回答越是可信,这是许多企业用户特别看重的点。

4.4 大模型能力在RAG里的真实分量

最后敲个警钟:很多初学RAG的人容易陷入一种迷思,以为“检索到正确的文本,回答就万事大吉”。但模型的能力短板会在这个阶段集中暴露。

只给上下文不给明确指令,模型会把一篇文档里的内容当素材重新组织,容易遗漏关键数据;上下文多而杂时,模型可能抓错重点,把次要内容当成核心回答;当你问的是“对比”或“推理”型问题时,如果上下文中没有完整的逻辑线,模型会自行脑补中间环节,从而悄悄超出指定范围。

所以至少三件事必须做扎实:第一,prompt里明确角色与边界,不能省略;第二,检索结果要精,不要让无关内容浑水摸鱼;第三,把复杂问题拆成更细的多个子问题来分别检索。这三点做到位,模型在RAG里才真正像一个“摘要提取器”,而不是一个“自由创作器”。

5. 完整流程实操与说明

把上面各个步骤串起来,一个可以直接照着敲的完整示例大概长这样。我先贴出完整代码,然后再逐段解说那些容易被忽略的细节。

为了减少依赖和避免环境问题,这里用Python自带的asyncio与aiohttp来模拟一个测试用例的并发请求。这个示例不是为了压测,主要是帮你在本地快速验证整条链路在并发场景下的稳定性。

import asyncio from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_chroma import Chroma from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough # 1. 嵌入模型与LLM embeddings = OpenAIEmbeddings( model="text-embedding-3-small", api_key="你的API密钥", base_url="你的接口地址" ) llm = ChatOpenAI( model="gpt-4o-mini", api_key="你的API密钥", base_url="你的接口地址", temperature=0 ) # 2. 加载与切分 loader = TextLoader("模拟项目X产品说明.txt", encoding="utf-8") documents = loader.load() text_splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=80) docs = text_splitter.split_documents(documents) print(f"切分后文档块数量: {len(docs)}") # 3. 建库 vectorstore = Chroma.from_documents( documents=docs, embedding=embeddings, persist_directory="./chroma_db" ) retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) # 4. Prompt与RAG链 prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的问答助手。请仅依据“提供的上下文”回答问题。" "如果上下文中没有相关内容,请直接回复“根据提供的资料无法回答该问题”。" "不要添加任何外部信息。"), ("human", "提供的上下文:\n{context}\n\n用户问题:{question}") ]) def format_docs(records): return "\n\n".join(rec.page_content for rec in records) rag_chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | llm ) # 5. 测试输出 print(rag_chain.invoke("模拟项目X的并发数上限是多少?").content) print(rag_chain.invoke("这个产品的保修期是多久?").content)

逐段说要点:

第一段初始化里,api_key和base_url如果写死在代码里会有风险,建议改为在项目根目录放.env文件,再用dotenv加载环境变量。这既是习惯的问题,也是防止密钥泄漏到版本仓库里的基本修养。

第二段的编码参数特别重要。中文Windows环境默认编码是GBK,读取UTF-8文件不指定encoding="utf-8"会直接乱码或者抛异常。

第三段建库是最耗时的一步,如果文档很多会有明显等待。如果此时启动目录下已经存在./chroma_db,再次执行会报错或追加数据。要重建索引就先把旧的持久化目录删掉,这一点很坑,说出来能帮不少人省几分钟排查时间。

Prompt里的约束是整个链路的“宪法”,宁可少放背景信息也不能删掉“如果上下文中没有相关内容,请直接回复无法回答”这句。它不是可有可无的礼貌用语,是防止幻觉的关键指令。

测试语句里故意问了文档里没有的“保修期”,就是用来验证拒绝机制的。如果它答出具体保修时间而不是说“资料中没有”,说明prompt约束可能被系统提示词覆盖,或者模型本身对指令遵循较弱,这时候就该换更强的模型或调整提示词权重。

6. 常见问题与排查经验

跑这套流程,问题基本出现在三个环节:加载与切分、检索质量、生成约束。我按实际踩坑频率整理成一张速查表。

现象可能原因解决思路
加载后中文乱码文件编码与加载器指定编码不一致统一转成UTF-8,加载时显式传encoding="utf-8"
检索结果明显无关切分块太大或太小;嵌入模型能力不足调整切分参数;换更强的嵌入模型并重建索引
多个结果内容高度重复默认相似度检索的多样性不足切到mmr检索模式,设置合适的fetch_k
模型仍然编造答案Prompt缺少拒绝约束;模型指令遵循能力弱强化prompt边界;换更大参数模型;降低temperature
回答内容正确但缺关键数字检索K值过小,或切分截断了表格/数字增大K;考虑表格专门走OCR或结构化解析,不依赖纯文本切分
向量库重新执行后结果不一致持久化目录里有旧索引,又写入新数据删除旧目录重建,或每次写入前先清空集合
并发请求时偶发超时外部API限流或网络波动加超时重试;并发数降下来;批量请求改异步
冷启动极慢首次加载嵌入模型或连接向量库耗时预热接口;持久化目录放到本地SSD;生产环境连独立向量库服务

再分享四个排查方向,都是常规文档里不会直说的实战经验。

当回答质量不好,第一件事不是调prompt,而是先看检索结果原文。把检索返回的page_content直接打印出来,如果连人工都无法从这些文本中定位到答案,那模型当然更不可能答对。这能快速区分“检索坏了”还是“生成坏了”。

切分后文档块数与预期严重不符,一般是某个超长段落像一条鲶鱼一样把chunk_size撑爆了。检查原始文本里是否有超长表格、Base64字符串或连续无标点符号的长数字串。把这类特殊段落单独摘出来处理,比强行调整全局切分参数更有效。

向量化失败报错时,先检查请求的模型名是否在接口服务商的白名单里。很多本地开源模型是通过OpenAI兼容协议暴露的,模型名拼写错误会直接导致400错误。别盲目去重装依赖,先把接口连通性测通了。

检索正常但汇报错内容,往往是嵌入了模型的“近义误判”——比如问题问“上限”而文档里写的是“最大容量”,字符串级匹配是匹配不上的。这种场景建议在切分前做一轮同义词扩充,或者检索时多取几个候选块,让模型自己在上下文里做语义关联,而不是强制让嵌入模型做到字面完全对应。

7. 一点个人体会与扩展方向

这套最小RAG链路跑通之后,你已经具备了一个可扩展的骨架,剩下的只是往上面加东西。我个人的体会是,比起纠结哪个工具更好用,先把检索质量作为第一优先级去调。很多团队把prompt调了一整天都没效果,最后发现是检索结果里混进了低质文本块,把整个回答都带偏了。所以我会在项目一开始就准备一份“检索质量测试题”,固定问二十个问题,每次改完参数先跑一遍这二十题,看正确率变化,再做下一步调整。

后续要扩展的方向无非是这几条:一是把单文件加载扩展到整个目录,加上递归遍历和格式过滤;二是在切分阶段针对不同格式差异化处理,比如PDF里的表格单独提取,代码文件按函数边界切分;三是检索时引入多路召回,把关键词检索和向量检索的结果做融合;四是在回答中加入引用片段,这是几乎所有知识库问答产品都需要的功能。每一条都能写一整篇文章,我会在这个系列里继续展开。

先别急着上复杂架构,把这一篇的链路吃透,手头的小项目先跑起来,比什么都重要。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进