简介:面向使用LangChain开发RAG智能问答机器人的开发者,这份资源提供了一个完整可运行的工程项目,整合OpenAI大模型、fastapi后端服务以及HTML/CSS/JavaScript前端界面,并附带问题QA知识库。借助RAG检索增强机制,项目能结合本地知识内容生成相对准确的回答,适用于客服助手、个人助理、教育辅导等场景,也支持自定义QA知识库以便扩展更多业务。资源压缩包共9个文件、约6KB,涵盖Python后端脚本、前端页面与样式、环境变量模板、依赖清单以及Markdown说明文档;目录结构经过合理拆分,便于对照学习和二次开发,同时附有项目说明与环境配置示例,可减少从零搭建的成本。目前已有965人学习下载。通过该项目,读者可以理清从知识库构建、OpenAI接口调用到前后端联调的整体实现路径,获得一个可直接运行和扩展的RAG聊天机器人基础工程。
1. 先搞清楚:RAG聊天机器人到底解决什么问题,为什么不是微调大模型
我见过不少团队拿到"用LangChain实现智能聊天机器人"这个需求时,第一反应是微调大模型。结果数据没整理干净,训了一周,模型把事实改得面目全非,还动不动复读。这里要明确:RAG知识库聊天机器人的核心价值,是把**检索(Retrieval)和生成(Generation)**拼在一起——先在你自己的文档库里找出和用户问题相关的片段,再让大模型基于这些片段组织答案。它不改变模型参数,只改变模型回答时的"参考资料"。适合的场景非常具体:企业内部知识库问答、产品文档客服、私有数据问答。不适合的场景也很明确:需要模型输出全新创意内容的场景、需要复杂多跳推理的场景。个人知识库起步成本很低,一台普通开发机、一个开源Embedding模型、一个开源大模型,就能跑出一个真正有实用价值的聊天机器人。
接下来我不做概念铺陈,直接从工程视角拆:LangChain怎么把这些组件串成一条主线,怎么写出第一版能跑的代码,以及哪些参数最值得花时间调。
2. LangChain里的RAG链条:从文档加载到检索器,先搭骨架
2.1 文档加载与切分:为什么Embedding前必须做分块
第一步是把知识库的原始文件变成LangChain的Document对象。常见做法是使用DirectoryLoader搭配不同的Loader,比如PyPDFLoader处理PDF、TextLoader处理普通文本。这里有个经验:直接加载整个PDF并Embedding,效果几乎必然很差。原因在于Embedding模型对输入长度有限制,而且过长文本的向量会被"平均"掉,丢失局部语义。所以加载后必须切分。
我一般会先用RecursiveCharacterTextSplitter做粗切,它按分隔符优先级递归寻找切点,对中文和英文都能保持段落结构。下面是最常见的用法:
from langchain_community.document_loaders import PyPDFLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader = PyPDFLoader("kb_2025.pdf") docs = loader.load() splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", " ", ""], ) chunks = splitter.split_documents(docs) print(f"原始文档数: {len(docs)}, 切分后片段数: {len(chunks)}")chunk_size=500大约对应中文300到400字,chunk_overlap=80保证相邻片段之间有一定重叠,避免一个完整知识点恰好被切在中间。separators里的中文句号、感叹号、问号是额外加分项——纯按换行切对中文文本不友好,因为中文段落经常一大段下来没有硬换行。我个人的调参习惯是:如果知识库是标准技术手册,先试512;如果问答质量不稳定,把chunk_size降到300再对比。
这里要提醒:切分不是越短越好。太短(比如100字)会让向量缺乏上下文,检索时容易召回语义不完整的排骨碎片;太长(比如1000字)会导致检索结果包含大量无关内容,挤压大模型的上下文窗口。最终目标是让每个片段自带一个完整的知识点。
2.2 向量库选型与检索器:FAISS、Chroma、pgvector怎么选
切分完成后,需要把片段变成向量并存进向量数据库。LangChain里这个动作被抽象成VectorStore。选型上我按场景分三类:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 本地单机原型、学习Demo | FAISS | 纯本地、无服务进程、pip即装即用 |
| 单机应用、需要持久化和管理界面 | Chroma | 默认支持持久化目录,API顺手 |
| 多服务并发、已有PostgreSQL基础设施 | pgvector | 和业务数据同库,事务、备份都复用现有机制 |
FAISS是Meta开源的向量检索库,LangChain封装后接口极其简单。Chroma更像一个"为LLM应用生"的嵌入式数据库。pgvector则是PostgreSQL的扩展,适合不想引入额外组件的人。起步阶段我强烈建议先把FAISS跑通,因为它没有服务端、不用配置网络端口,出问题容易排查。
from langchain_community.vectorstores import FAISS from langchain_huggingface import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-large-zh-v1.5") vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("faiss_index")HuggingFaceEmbeddings支持所有sentence-transformers模型。中文场景下bge系列效果不错,bge-large-zh-v1.5在检索任务里表现比很多通用Embedding好,代价是模型文件较大、首次加载需要下载。如果机器资源紧张,换bge-small-zh-v1.5也能跑,命中率略降但启动快很多。
save_local会把索引和向量存到本地目录。这里有个潜在坑:save_local会把index.faiss和index.pkl写进目录,加载时必须指定allow_dangerous_deserialization=True:
vectorstore = FAISS.load_local( "faiss_index", embeddings, allow_dangerous_deserialization=True, )这个参数是LangChain出于安全考虑加的,因为pickle反序列化存在风险,只有确认索引文件是自己生成的才应该开启。我见过有人卡在这步报错,就是因为没加这个参数。
2.3 组装检索链:用LCEL把四步串成一条可调试的管道
LangChain从0.1.x版本开始主推LCEL(LangChain Expression Language),用|操作符把组件串起来。RAG的最小链路是四步:接收问题 → 检索相关片段 → 组装提示词 → 调用LLM生成答案。用LCEL写出来非常清晰:
from langchain.prompts import ChatPromptTemplate from langchain.schema.output_parser import StrOutputParser from langchain.schema.runnable import RunnablePassthrough from langchain_openai import ChatOpenAI template = """你是知识库问答助手,只能根据以下资料回答问题。 如果资料里没有答案,请直接说“知识库中未找到相关内容”,不要编造。 资料: {context} 问题:{question} 回答:""" prompt = ChatPromptTemplate.from_template(template) llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.2) retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) 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 | StrOutputParser() ) answer = rag_chain.invoke("售后流程是什么?") print(answer)这里的关键是retriever | format_docs。retriever接收用户问题,返回一组Document对象;format_docs把它拼成纯文本。RunnablePassthrough()负责把用户原问题透传给模板里的{question}。StrOutputParser()把LLM返回的AIMessage对象转成字符串。
search_kwargs={"k": 4}表示检索时取最相似的前4个片段。这个值不是越大越好:k太大,无关信息混进来,模型容易被带偏;k太小,可能漏掉关键信息。我一般从4开始测,如果发现答案不完整,加大chunk_size而不是加大k。
3. 写一个能本地跑通的最小RAG聊天机器人:代码、参数与运行效果
3.1 最小工程结构
我习惯用最直白的目录结构,不引入类继承和抽象工厂,先保证功能跑通:
rag_chatbot/ ├── ingest.py # 文档加载、切分、建索引 ├── query.py # 加载索引、组装检索链、问答入口 ├── kb/ # 放原始知识库文件 │ └── product_manual.pdf └── faiss_index/ # 生成的向量索引目录ingest.py负责一次性构建索引,query.py负责读取索引并回答。这个分离的好处是:知识库更新时只跑ingest.py,问答进程不用重启;排查问题时也能清楚看到是索引还是生成阶段出了岔子。
3.2 主程序代码逐段拆解
ingest.py,用于构建知识库索引:
from langchain_community.document_loaders import PyPDFLoader from langchain_community.vectorstores import FAISS from langchain_huggingface import HuggingFaceEmbeddings from langchain_text_splitters import RecursiveCharacterTextSplitter # 1. 加载 loader = PyPDFLoader("kb/product_manual.pdf") docs = loader.load() # 2. 切分 splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", " ", ""], ) chunks = splitter.split_documents(docs) # 3. 向量化并存储 embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-base-zh-v1.5") vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("faiss_index") print(f"完成,共切分 {len(chunks)} 个片段")加载前我建议先打印len(docs)确认PDF没有被重复读取。注意PyPDFLoader会按页生成Document,docs里的每项代表一页,切分时RecursiveCharacterTextSplitter能自动跨页合并文本,不用手动处理。
query.py,回答入口:
from langchain.prompts import ChatPromptTemplate from langchain.schema.output_parser import StrOutputParser from langchain.schema.runnable import RunnablePassthrough from langchain_community.vectorstores import FAISS from langchain_huggingface import HuggingFaceEmbeddings from langchain_openai import ChatOpenAI embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-base-zh-v1.5") vectorstore = FAISS.load_local( "faiss_index", embeddings, allow_dangerous_deserialization=True ) retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) template = """你是知识库问答助手,只能根据以下资料回答问题。 资料里没有的内容,明确说“未找到相关内容”。 资料: {context} 问题:{question} 回答:""" prompt = ChatPromptTemplate.from_template(template) llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.2) 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 | StrOutputParser() ) while True: q = input("请输入问题(输入exit退出):").strip() if q.lower() == "exit": break print(rag_chain.invoke(q))temperature=0.2是在"忠实于资料"和"自然表达"之间折中的选择。事实性问答场景我不建议超过0.4,否则模型可能自由发挥。如果知识库本身是客服话术,希望回答固定,甚至可以调成0。
3.3 参数设置:chunk_size、top_k、temperature怎么定
这三组参数是RAG效果的核心。我总结一套自己固定使用的初调策略:
| 参数 | 起始值 | 调整方向 |
|---|---|---|
| chunk_size | 400~500 | 答案太碎就加大,混入无关内容就减小 |
| chunk_overlap | 50~100 | 文本结构性弱(如PPT导出的PDF)时加大 |
| k | 4 | 回答不全就加到6,回答发散就减到3 |
| temperature | 0.2 | 事实问答调低,客服风格调更低 |
判断chunk_size是否合适,有个笨办法:随机打印切分后的20个片段,看每个片段是否完整说清一个意思。如果一段一半在讲"安装A",一半在讲"配置B",那就要改分隔符或减小chunk_size。这个检查只花一分钟,却能省下后面调检索的大量时间。
4. 让回答不飘:引用溯源、重排序与多轮对话的工程化处理
4.1 回答带上引用来源:SourceDocument在RunnableParallel里的用法
实际使用时,用户最怕AI一本正经地胡说八道。工程师最怕的是出了问题不知道是哪条资料引起的。解决方法是让答案直接携带引用来源。LangChain的LCEL里可以用RunnableParallel并行获取检索文档和生成结果,再单独提取源文档:
from langchain.schema.runnable import RunnableParallel def format_with_source(docs): context_text = "\n\n".join(f"[{i+1}] {doc.page_content}" for i, doc in enumerate(docs)) return context_text, docs retriever_chain = ( retriever | format_with_source ) def extract_docs(result): return result[1] def extract_context(result): return result[0] parallel_chain = RunnableParallel( context_docs=retriever_chain | extract_docs, context_text=retriever_chain | extract_context, question=RunnablePassthrough(), ) final_chain = ( parallel_chain | prompt | llm | StrOutputParser() ) result = final_chain.invoke("退货政策是什么?")这段代码里context_docs保留原始的Document列表,context_text把片段加上序号拼成文本。提示词里的"资料"可以改为带编号的列表,让模型在回答时写"根据资料[2]"。这个做法投入很小,但对信任感的提升非常明显。
4.2 重排序:为什么Retriever给的前5条不一定是最该用的5条
向量检索返回的前k条,是按向量余弦相似度排的。问题在于:相似不代表相关。一个片段可能词汇相近但逻辑无关。尤其当知识库存在大量重复主题的文档时,前5条可能全是同一份文档的不同变体,把真正独特的答案挤到第8位。
处理办法是引入重排序(Rerank)。LangChain里常用CrossEncoderReranker,它把"问题+候选片段"拼对,用交叉编码器重新计算相关性分数,比单纯向量相似度更准。具体做法:
from langchain_community.cross_encoders import HuggingFaceCrossEncoder from langchain.retrievers.document_compressors import CrossEncoderReranker from langchain.retrievers import ContextualCompressionRetriever reranker = CrossEncoderReranker( model=HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base"), top_n=3, ) compression_retriever = ContextualCompressionRetriever( base_compressor=reranker, base_retriever=vectorstore.as_retriever(search_kwargs={"k": 8}), )这里先把向量检索的k提到8,让召回范围更大,然后top_n=3从8条里挑出最相关的3条。重排序模型比Embedding模型更重,推理慢一点,但命中率提升明显。如果知识库超过1万个片段,我建议一定加上重排序环节。
4.3 多轮对话的两种做法:记忆压缩与查询改写
用户带着上下文连续提问时,直接把手头问题丢给检索器会出问题。比如用户先问"售后政策是什么",再问"退货运费谁出",第二个问题如果只按原文字面检索,很难关联到售后文档。常见的做法是查询改写:让大模型结合历史对话,把当前提问改写成一个含完整上下文的独立问题,再做检索。
LangChain的历史消息处理可以用message_history机制,但更简单可靠的是自己维护一个队列:
from langchain.memory import ConversationBufferMemory memory = ConversationBufferMemory(k=4, return_messages=True) def rewrite_query(question, history_text): rewrite_prompt = ChatPromptTemplate.from_template( "根据对话历史,把最新提问改写为可独立检索的问题。\n历史:{history}\n最新提问:{question}\n改写结果:" ) chain = rewrite_prompt | llm | StrOutputParser() return chain.invoke({"history": history_text, "question": question})另一种做法是历史文字拼接:直接把最近两轮"用户问/助手答"拼到当前问题前,再交给检索器。这种方法不用额外调用大模型,速度快,但对非常长的对话,检索噪声会增大。我一般先试拼接法,不够再上改写。注意ConversationBufferMemory只是存储历史消息,并不会自动参与RAG的检索链,必须在调用前手动把历史格式化塞进rewrite_query。
5. 避坑指南:RAG聊天机器人最常见的5个翻车现场
5.1 现象:检索结果明明有答案,模型却答非所问
排查时一旦发现模型回答和检索片段风马牛不相及,我先把链路拆开单独验证。检查第2章的代码是否漏掉了RunnablePassthrough()对问题的传递。如果问题没传进去,模板里的{question}就成了空字符串或历史残留,模型当然乱答。
修复办法是在rag_chain.invoke()前打印模板填充后的内容。LCEL支持在链中插入调试节点:
from langchain.schema.runnable import RunnableLambda def debug_prompt(prompt_value): print("=== 实际发送给模型的提示词 ===") print(prompt_value.to_string()) return prompt_value debug_chain = rag_chain.with_config({})更简单的是直接调用prompt.format(context=..., question=...),但要注意ChatPromptTemplate需要用format_messages再转成字符串。最稳的排查方案是单独跑一次检索器,看返回的page_content里是否真正包含答案。检索不到,回答不可能是对的——这是RAG的铁律。
5.2 现象:知识库更新后,回答还是旧内容
某公司更新了产品手册,但聊天机器人依然回答旧版政策。原因几乎总是:索引文件没重建,或者索引重建了但向量库缓存还留在内存。FAISS的save_local是覆盖式写文件,但生产环境里服务进程如果常驻,旧索引一直被加载在内存里。
解决方式有两条:一是更新知识库后显式重启服务进程,并重新加载索引;二是给索引文件名加上版本号,例如faiss_index_v2,查询端通过环境变量指定加载哪个版本,避免旧索引被误读。我自己的习惯是每次重建索引后打印MD5校验值:
import hashlib with open("faiss_index/index.faiss", "rb") as f: print("索引MD5:", hashlib.md5(f.read()).hexdigest())同时在服务端记录这个值,排查时对账,立刻能知道线上用的是哪份索引。
5.3 现象:中文分词导致检索命中率低
中文没有天然空格,Embedding模型如果词表不覆盖某些领域术语,检索效果会大打折扣。例如"八爪鱼采集器"这种词,通用模型很可能切得乱七八糟。我踩过最典型的坑是用默认sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2跑垂直领域文档,结果检索命中率不到一半。
解决方法是换领域适应性更强的中文Embedding模型,比如bge系列,或者用Jieba分词先对文档做预处理,把复合词用下划线连接,人为制造"词边界"。但要注意:训练和推理时切分/预处理必须完全一致,否则索引和查询向量之间存在偏差。更推荐的做法是直接用中文预训练Embedding模型,不要自己做分词。句子级别Embedding本身不依赖显式分词,效果更好。
5.4 现象:多文档相似度高,检索结果大量重复
很多知识库有几十个版本相近的支持文档,向量检索返回的前5条里可能4条都指向同一件事。这会导致大模型看到了重复信息,却漏掉真正差异化的内容,回答显得很"平"。
除了加重排序,我还习惯在拼接上下文时做去重。用Document对象的metadata记录来源文件名,格式化前按metadata["source"]去重:
seen_sources = set() unique_docs = [] for doc in docs: src = doc.metadata.get("source", "") if src not in seen_sources: seen_sources.add(src) unique_docs.append(doc)这个手段治标不治本。根本解决方法是重新设计分块粒度,把同一主题的内容合并成一个大块,再切分到多个相关子块,还可以用ParentDocumentRetriever实现"检索小块、返回父块",让模型看到的上下文更完整。
5.5 现象:链路跑通但响应越来越慢
刚跑通时响应2秒,用了一个月变成5秒甚至超时。慢的原因通常有三个:一是索引数据量增长,向量检索变慢;二是历史对话不断累积,塞给模型的内容越来越多;三是Embedding模型和LLM都在同一块GPU上推理,互相抢占显存。
解决办法:给检索器加缓存。LangChain有CacheBackedEmbeddings,可以对Embedding结果做持久化缓存,相同问题不用重新算向量。更彻底的做法是为聊天历史单独维护一个滑动窗口,最多保留最近6轮,这样每次请求的token数恒定。
from langchain.embeddings import CacheBackedEmbeddings from langchain.storage import LocalFileStore store = LocalFileStore("./embedding_cache") cached_embeddings = CacheBackedEmbeddings.from_bytes_store( underlying_embeddings=embeddings, document_embedding_cache=store, namespace="v1", )注意namespace在Embedding模型或分块参数改变后必须升级,否则会命中旧缓存,导致回答陈旧。
6. 一个必做的进阶验证:把RAG管线做成可观测的,用回调看每一步
我见到太多人把RAG链写完后只测三个问题,觉得"看起来差不多"就交付。这种未被验证的系统上线后,一旦知识库扩大或问题复杂度上升,很难定位到底是检索失败还是生成失败。所以我会要求自己:必须能看到每一步发生了什么。
LangChain的回调系统是解决这个问题的钥匙。用BaseCallbackHandler监听链执行过程:
from langchain.callbacks.base import BaseCallbackHandler class RagTraceHandler(BaseCallbackHandler): def on_retriever_start(self, query, *, run_id, parent_run_id=None, **kwargs): print(f"[检索] 输入: {query}") def on_retriever_end(self, documents, *, run_id, parent_run_id=None, **kwargs): print(f"[检索] 返回 {len(documents)} 条") for i, doc in enumerate(documents): print(f" - 第{i+1}条来源: {doc.metadata.get('source','未知')}") print(f" 内容前50字: {doc.page_content[:50]}") def on_llm_start(self, serialized, prompts, **kwargs): print(f"[生成] 提示词前100字: {prompts[0][:100]}") handler = RagTraceHandler() answer = rag_chain.invoke("退货政策是什么?", config={"callbacks": [handler]})这个回调能让我在开发阶段直观看到:检索器到底拿了什么给模型、模型收到的提示词是否符合预期。生产环境不方便打印的话,可以把同样的内容写进结构化日志,配合时序库排查线上问题。
除了回调,还要给RAG建立一套简单可行的评估集。我建议用20~30个"问题-标准答案片段"的测试对。评估指标不用太复杂,两条就够:检索命中率(标准答案片段是否出现在检索结果中)和生成答案是否包含标准答案的关键语义。前者可以用代码自动计算,后者可以人工打标。
def hit_rate(qa_pairs, retriever, k=4): hit = 0 for q, gold_doc in qa_pairs: docs = retriever.invoke(q) gold_snippet = gold_doc[:50] if any(gold_snippet in doc.page_content for doc in docs): hit += 1 return hit / len(qa_pairs)我个人的教训是:先做评估再做优化。最初我把所有精力放在调prompt上,结果命中率只有40%,问题根本不在生成,而在检索。把Embedding模型换掉并加上重排序后,命中率到了85%,prompt几乎没动。这个顺序千万别搞反。
最后说一个习惯:所有RAG项目我都会保留一份"检索检查清单",包含检索器返回的前5条原始文本、调试用的prompt、调参记录。有了这套记录,即使两个月后回头改代码,也能立刻知道当时从哪里开始的。希望帮到你。
本文还有配套的精品资源,点击获取