简介:这份PDF面向大模型与人工智能方向的学习者及面试准备者,聚焦基于langchain框架的RAG问答应用实战,帮助读者理解检索增强生成从数据准备到问答落地的完整链路。资源包内仅含1个PDF文件,约390KB,内容以图文与代码示例为主,便于在面试前快速梳理RAG核心流程与关键实现细节。目前已有281人学习。文档以百度百科藜麦数据模拟私域语料,依次覆盖环境搭建、本地数据加载、文档分割、向量化入库等环节,并给出CUDA、Python、pytorch等版本要求与依赖安装命令。读者可从中掌握TextLoader加载、CharacterTextSplitter分割、m3e-base嵌入及Chroma入库等实操要点,同时了解OCR识别误差处理与藜麦背景知识,适合作为大模型八股文面试中RAG方向的实战参考。
1. 从一份 PDF 说起:LangChain RAG 问答应用到底在面试里考什么
面试官甩过来一句「讲讲你怎么用 LangChain 搭一个 RAG 问答应用」,很多人第一反应是背概念:文档加载、切分、向量化、检索、生成。背完面试官接着问「切分 chunk_size 设多少」「召回率上不去怎么办」「多轮对话里历史怎么塞」,就卡住了。这份《基于 LangChain RAG 问答应用实战》的标题,考的从来不是你会不会调 API,而是你有没有真正把一个 PDF 知识库问答从零跑通、调过参、踩过坑。
RAG(Retrieval-Augmented Generation,检索增强生成)解决的是一个很朴素的问题:大模型不知道你私有的东西,硬问它就编。把 PDF、文档切块存进向量库,用户提问时先检索出相关片段,再连同问题一起喂给大模型,让它「看着材料回答」。LangChain 在这里的角色是胶水层,把加载器、切分器、Embedding、向量库、检索器、LLM 串成一条链。面试里真正拉开差距的,是你能不能讲清楚每一环的参数为什么这么设、哪一环最容易翻车。这篇就按「搭起来 → 调得动 → 排得掉」的顺序,把这条链拆开讲透,新手能照着复现,熟手能对照自己的参数找边界。
2. 把 PDF 灌进向量库:LangChain RAG 索引链路的最小可跑通实现
RAG 分两大阶段:索引(离线,把文档变成可检索的向量)和检索生成(在线,用户提问时召回并回答)。这一章先把索引链路跑通,这是所有后续调优的地基。索引链路的标准动作是:加载 → 切分 → 向量化 → 入库,LangChain 对每一步都有对应抽象。
2.1 文档加载与切分:chunk_size 和 overlap 怎么定
PDF 加载最常见的坑是版面解析。PyPDFLoader 按页抽文本,遇到双栏排版、表格、扫描件会乱序或抽空。常见做法是先用 PyPDFLoader 快速验证,正式项目换 pdfplumber 或 unstructured 做版面还原。加载完得到的是 Document 对象列表,每个带 page_content 和 metadata,metadata 里的 source、page 后面做引用溯源要用。
切分是索引链路里最影响召回质量的一步。RecursiveCharacterTextSplitter 会按分隔符优先级(段落 → 换行 → 句号 → 空格)递归切,尽量保持语义完整。chunk_size 和 chunk_overlap 是必调的两个参数。
from langchain_community.document_loaders import PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter # 1. 加载 PDF,每页一个 Document loader = PyPDFLoader("./handbook.pdf") pages = loader.load() print(f"共加载 {len(pages)} 页") # 2. 递归切分:中文场景分隔符要补全角标点 splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 每块目标字符数 chunk_overlap=80, # 相邻块重叠,防止语义被切断 separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], length_function=len, ) chunks = splitter.split_documents(pages) print(f"切出 {len(chunks)} 个 chunk")逻辑说明:chunk_size=500 是中文技术文档的经验起点,对应大约 300~400 个 token,能塞进大多数 Embedding 模型的上下文窗口,又不至于太碎。chunk_overlap=80 保证跨块的句子不被腰斩,代价是存储和检索时会有冗余。separators 里补了中文全角标点,默认分隔符只认英文标点,中文长句会被硬切。
参数怎么改:chunk_size 太小(如 200),单块信息不完整,检索出来答不全;太大(如 1500),一块里混了多个主题,向量被平均掉,检索精度下降。判断标准是看你的文档粒度——FAQ 类短问答用 200~300,技术手册用 500~800,法律合同这种长条款用 800~1200。overlap 一般取 chunk_size 的 10%~20%,超过 30% 就是浪费。
2.2 Embedding 与向量库选型:本地跑还是调 API
切完块要转成向量。Embedding 模型的选择直接决定检索质量,面试里常被追问「你用的什么 Embedding,为什么」。两条路线:调云端 API(OpenAI text-embedding-3、通义、智谱等),省事但要走网络、按量计费;本地跑开源模型(bge-large-zh、m3e、gte),数据不出内网、零调用成本,代价是要有 GPU 或忍受 CPU 慢。
中文场景我一般推荐 bge-large-zh-v1.5 或 bge-m3,后者支持多语言和长文本,检索效果在中文榜单上稳定靠前。向量库选型看规模:几万块以内用 FAISS 本地索引足够,纯内存、零依赖;要持久化、要元数据过滤、要多用户用 Chroma 或 Milvus。LangChain 对这几家都有统一接口,切换成本低。
from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS # 本地 Embedding,首次运行会下载模型权重 embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-large-zh-v1.5", model_kwargs={"device": "cuda"}, # 没 GPU 改 "cpu" encode_kwargs={"normalize_embeddings": True}, # bge 系列必须归一化 ) # 建库并持久化到本地目录 vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("./faiss_index") print("索引已保存")逻辑说明:normalize_embeddings=True 对 bge 系列是硬要求,它用余弦相似度训练,不归一化内积和余弦会不一致,检索结果会飘。device 按机器实际情况选,CPU 上建几万块的库大概几分钟,能接受。
参数说明:model_kwargs 里还能传 trust_remote_code、max_length 等;encode_kwargs 里 batch_size 影响建库速度,GPU 上可以调到 64 或 128。向量库这边 FAISS 的 index 类型默认是扁平索引(精确检索),数据量上百万再考虑 IVF、HNSW 这类近似索引,否则精度损失不划算。
2.3 检索器配置:similarity 还是 MMR
建完库,检索器决定「怎么把相关块捞出来」。最基础的是相似度检索(similarity),返回和 query 向量最接近的 k 个块。但纯相似度有个问题:如果文档里有大量重复表述,返回的 k 个块可能高度雷同,信息冗余。MMR(最大边际相关)在相似度和多样性之间做权衡,先选最相关的,再选和已选块差异大的,适合「一个问题需要多个角度材料」的场景。
# 相似度检索:直接取 top-k retriever_sim = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4}, ) # MMR 检索:兼顾相关性与多样性 retriever_mmr = vectorstore.as_retriever( search_type="mmr", search_kwargs={"k": 4, "fetch_k": 20, "lambda_mult": 0.5}, )逻辑说明:fetch_k=20 表示先从库里取 20 个候选,再从中挑 4 个多样性最好的;lambda_mult 控制相关性和多样性的权重,1 等于纯相似度,0 等于纯多样性,0.5 是常用折中。k 设多少取决于 LLM 的上下文窗口和你的 chunk 大小,一般 3~6 个块,塞太多反而引入噪声、拉高成本。
到这里索引链路就跑通了。面试里如果只让你讲「怎么搭」,讲到这一步已经及格;但真正区分水平的是下一章的调优和排错。
3. 从检索到回答:LangChain RAG 问答链的组装与多轮对话处理
索引建好只是把材料备齐,用户提问到拿到答案这条在线链路才是 RAG 应用的门面。这一章讲怎么把检索器、Prompt、LLM 串成一条能用的链,以及多轮对话这个高频面试点怎么处理。
3.1 用 LCEL 组装检索问答链
LangChain 现在主推 LCEL(LangChain Expression Language),用管道符把组件串起来,写法简洁、支持流式、天然异步。一条标准的 RAG 链是:问题 → 检索器拿上下文 → 拼 Prompt → 喂 LLM → 输出。
from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough from langchain_community.chat_models import ChatOpenAI llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) prompt = ChatPromptTemplate.from_template( "你是文档问答助手。只根据下面的上下文回答,上下文没有的信息就说不知道,不要编造。\n\n" "上下文:\n{context}\n\n问题:{question}" ) def format_docs(docs): return "\n\n".join(d.page_content for d in docs) rag_chain = ( {"context": retriever_sim | format_docs, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() ) answer = rag_chain.invoke("这份手册里报销流程是怎样的?") print(answer)逻辑说明:字典那一步并行处理两个输入,context 走检索器再格式化,question 原样透传。Prompt 里明确「只根据上下文回答、没有就说不知道」是抑制幻觉的关键,不写这句模型会拿自己的先验知识补。temperature=0 让输出稳定,问答场景不需要创造性。
参数说明:ChatOpenAI 的 model 换成你实际用的模型;如果走本地 Ollama,换成 ChatOllama(model="qwen2.5:7b") 即可,接口一致。StrOutputParser 把消息对象转成纯字符串,方便后续处理。
3.2 多轮对话:历史怎么塞、检索 query 怎么改写
单轮问答跑通后,面试官大概率会问「多轮对话怎么办」。直接的做法是把历史消息拼进 Prompt,但有两个坑:一是历史越长 token 越贵,二是用户第二句「那它的截止日期呢」这种指代,直接拿去检索会召回一堆无关内容。
第一个坑用窗口或摘要解决,只保留最近 N 轮。第二个坑是重点:检索前要先做 query 改写(condense question),把「那它的截止日期呢」结合历史改写成「报销流程的截止日期是什么」,再拿去检索。
from langchain_core.prompts import MessagesPlaceholder from langchain.chains import create_history_aware_retriever, create_retrieval_chain from langchain.chains.combine_documents import create_stuff_documents_chain # 1. 历史感知检索器:先改写 query 再检索 condense_prompt = ChatPromptTemplate.from_messages([ MessagesPlaceholder("chat_history"), ("human", "{input}"), ("human", "结合上面的对话,把最新问题改写成独立完整的检索问题,只输出改写后的问题。"), ]) history_aware_retriever = create_history_aware_retriever(llm, retriever_sim, condense_prompt) # 2. 问答链:带历史 qa_prompt = ChatPromptTemplate.from_messages([ ("system", "只根据上下文回答:\n\n{context}"), MessagesPlaceholder("chat_history"), ("human", "{input}"), ]) qa_chain = create_stuff_documents_chain(llm, qa_prompt) rag_chain = create_retrieval_chain(history_aware_retriever, qa_chain) # 3. 调用时传入历史 history = [] resp = rag_chain.invoke({"input": "报销流程是怎样的?", "chat_history": history}) history.extend([("human", "报销流程是怎样的?"), ("ai", resp["answer"])]) resp2 = rag_chain.invoke({"input": "那它的截止日期呢?", "chat_history": history})逻辑说明:create_history_aware_retriever 内部先调一次 LLM 做 query 改写,再走检索,多花一次调用但换来检索准确率。create_stuff_documents_chain 负责把检索到的文档「塞」进 Prompt,create_retrieval_chain 把两者串起来。chat_history 用 (role, content) 元组列表维护,生产环境一般存 Redis 或数据库。
参数说明:历史窗口建议保留最近 5~10 轮,再长就做摘要压缩。改写那步的 Prompt 要强调「只输出改写后的问题」,否则模型会啰嗦地解释一通,污染检索 query。
3.3 引用溯源:让答案能指回原文
企业场景里,光给答案不够,用户要能点回原文核对。做法是在 Prompt 里要求模型标注来源,或者干脆在返回结构里带上检索到的文档。create_retrieval_chain 的返回里就有 context 字段,包含所有召回的 Document,前端拿 metadata 里的 source 和 page 渲染引用即可。
resp = rag_chain.invoke({"input": "报销流程是怎样的?", "chat_history": []}) for doc in resp["context"]: print(doc.metadata.get("source"), doc.metadata.get("page"))逻辑说明:metadata 是加载阶段带进来的,PyPDFLoader 会自动填 source(文件路径)和 page(页码)。如果切分后 metadata 丢了,检查 split_documents 是否保留了原 Document 的 metadata——它默认是保留的。引用溯源是 RAG 相对纯生成的核心优势之一,面试里主动提这点是加分项。
4. RAG 效果上不去的排查清单:召回、切分、Prompt 三个高发区
RAG 应用最让人头疼的不是搭不起来,而是「搭起来了但答不准」。这一章按现象 → 原因 → 解决的结构,列几个我踩过的坑,基本覆盖了 RAG 效果问题的高发区。
4.1 现象:答案答非所问,检索回来的块根本不相关
原因通常有三个层次。第一层是 Embedding 模型和语料语言不匹配,用英文模型跑中文语料,向量空间对不上。第二层是切分粒度不对,块太大导致一个向量混了多个主题,相似度被稀释。第三层是 query 和文档表述差异大,用户问「怎么报销」,文档写「费用核销流程」,字面不重合,纯向量检索召不回。
解决:先换中文 Embedding(bge-large-zh 或 bge-m3)验证第一层;把 chunk_size 调小到 300~500 试第二层;第三层引入混合检索(BM25 关键词 + 向量),LangChain 的 EnsembleRetriever 可以把两路结果加权融合,关键词路能兜住字面匹配,向量路能兜住语义匹配。
from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever bm25 = BM25Retriever.from_documents(chunks) bm25.k = 4 ensemble = EnsembleRetriever( retrievers=[bm25, retriever_sim], weights=[0.4, 0.6], # 关键词 0.4,向量 0.6 )逻辑说明:weights 按语料特点调,术语多、缩写多的场景给 BM25 更高权重;口语化提问多的场景给向量更高权重。混合检索是 RAG 提召回最立竿见影的手段之一。
4.2 现象:检索到了正确块,但模型还是答错或答不全
原因多半在 Prompt 和上下文组织。一是检索到的块顺序乱,最相关的块被埋在中间,模型注意力被稀释——有研究说 LLM 对上下文首尾更敏感,把最相关的块放前面或后面。二是 Prompt 没约束「只用上下文」,模型拿先验知识覆盖了材料。三是 k 太小,答案分散在多个块里没凑齐。
解决:对召回块按相似度重排,最相关的放首尾;Prompt 里加「如果上下文不足以回答,明确说不知道」;k 从 4 提到 6~8 试,但要观察是否引入噪声。更进阶的做法是加一个 rerank 模型(如 bge-reranker),先粗召回 20 个,再用 rerank 精排出 top 4,精度提升明显。
4.3 现象:PDF 里的表格和图片内容检索不到
原因:PyPDFLoader 只抽文本层,表格会被拍平成乱序文本,图片里的文字直接丢失。这是 PDF 类 RAG 的经典翻车点。
解决:表格用 pdfplumber 或 camelot 单独抽成结构化文本再入库;扫描件和图片走 OCR(PaddleOCR、Tesseract)转文字;复杂版面用 unstructured 的 hi_res 策略做版面分析。多模态场景可以把图片单独存,检索时返回图片链接让用户自己看。这块没有银弹,按文档类型选工具。
4.4 现象:多轮对话里检索 query 被历史污染
原因:把带指代的原始问题直接拿去检索,或者改写 Prompt 写得太松,模型把历史里的无关内容也揉进 query。
解决:改写 Prompt 明确「只输出独立完整的检索问题,不要解释」;历史窗口别开太大,超过 10 轮就做摘要;如果指代特别复杂,可以在改写前先做一次指代消解。这个坑在多轮场景里出现频率极高,面试里能主动讲出来是明显加分。
4.5 现象:本地 Embedding 建库慢到怀疑人生
原因:CPU 上跑 bge-large,几万块要跑很久;或者 batch_size 没调,默认 32 没吃满 GPU。
解决:有 GPU 一定用 GPU,device 设 cuda;encode_kwargs 里 batch_size 调到 64~128;模型换 bge-small 或 bge-base,精度损失有限但速度快几倍。建库是一次性成本,但开发阶段反复重建很折磨,建议把向量库持久化,改代码时直接 load_local 别重建。
5. 把 RAG 问答做成能交付的东西:评估、缓存与一个提效技巧
跑通和调优之后,最后一个问题是「怎么证明它好用、怎么让它扛住真实流量」。这一章讲评估方法和两个工程化技巧,都是面试里能体现工程素养的点。
5.1 用 RAGAS 量化检索和生成质量
凭感觉说「效果还行」在面试里站不住脚。RAG 评估有两个核心指标:检索的命中率(context recall / precision)和生成的忠实度(faithfulness,答案是否只基于上下文)。RAGAS 是常用的评估框架,喂进问题、答案、上下文、标准答案,它自动算分。
from ragas import evaluate from ragas.metrics import faithfulness, answer_relevancy, context_precision from datasets import Dataset data = Dataset.from_dict({ "question": ["报销流程是怎样的?"], "answer": ["先提交申请,主管审批后财务打款。"], "contexts": [["报销需先提交申请...", "审批通过后财务打款..."]], "ground_truth": ["提交申请,主管审批,财务打款。"], }) result = evaluate(data, metrics=[faithfulness, answer_relevancy, context_precision]) print(result)逻辑说明:faithfulness 低说明模型在编,要收紧 Prompt;context_precision 低说明检索噪声大,要调切分或加 rerank;answer_relevancy 低说明答非所问,要查 query 改写。评估集不用大,几十条覆盖典型场景就能看出问题。参数上,评估本身要调 LLM,成本要算进去,一般离线跑。
5.2 缓存与流式:两个让体验和成本都变好的技巧
缓存分两层。Embedding 缓存:相同文本不重复算向量,LangChain 的 CacheBackedEmbeddings 可以接本地或 Redis。答案缓存:相同问题直接返回历史答案,用 GPTCache 或自己拿 query 向量做近似匹配。高频重复问题多的场景,缓存能省一大半调用成本。
流式输出是体验层面的刚需。LCEL 链天然支持 stream,前端逐字渲染,用户感知的响应时间从「等 5 秒」变成「立刻开始出字」。
for chunk in rag_chain.stream({"input": "报销流程是怎样的?", "chat_history": []}): print(chunk, end="", flush=True)逻辑说明:stream 返回的是增量片段,注意 create_retrieval_chain 的流式输出结构里 answer 字段是逐步拼出来的,前端要按字段解析。流式不影响答案质量,纯粹是体验优化,但用户满意度提升很明显。
5.3 一个我常用的提效习惯:先建小评估集再调参
调 RAG 参数最容易陷入「改一个参数、手动问几个问题、感觉好像好了」的玄学循环。我的习惯是动手调参前先攒一个 20~30 条的小评估集,覆盖典型问题、边界问题、指代问题,每次改完参数跑一遍 RAGAS,看指标是涨是跌。这样调参有依据,面试里也能讲出「我用数据驱动调优」而不是「我凭经验试」。
血泪经验是:不要一上来就上复杂方案(GraphRAG、Agentic RAG、多路召回),先把最朴素的「切分 + 向量检索 + Prompt 约束」调到及格线,再按评估结果决定要不要加组件。很多团队一上来堆架构,结果基础检索都没调好,加了 rerank 也救不回来。RAG 的瓶颈往往不在架构复杂度,而在切分粒度和 Prompt 这两件最基础的事上。
希望这些能帮你在面试里把 RAG 讲得比背八股的人扎实一点。
本文还有配套的精品资源,点击获取