RAG 实战:用 LangChain + LLM 对私有文档做摘要提取

本次实践是一个完整的 RAG(Retrieval-Augmented Generation,检索增强生成)落地实验:对一份包含 9 项政策的公司内部文档(companyPolicies.txt),走完 加载 → 切分 → 嵌入 → 存储 → 检索 → 生成 的索引与问答全链路。LLM 通过 OpenAI 兼容协议接入,嵌入用 HuggingFace 的 BGE 模型 + Chroma 向量库,链路依次经历三代形态:RetrievalQA 问答链 → 提示词模板压制幻觉 → ConversationalRetrievalChain 加上对话记忆,最终封装成一个可交互的"文档阅读智能体"。实验里每一步都有真实运行输出------包括切分器的超长告警、模型在无提示词时的"编造现场"、以及无记忆时的指代消解翻车,正好把这些原理逐个解剖开。

一、为什么是 RAG:私有文档与 LLM 的知识边界

实验的背景故事很典型:入职第一天,桌上堆着一大摞公司政策文档------手册、指南、技术规范。想用 LLM 帮忙阅读和摘要,但有两个问题绕不过去:

  1. 知识截止:LLM 的知识停留在训练截止时间点,你的公司政策它从没见过;
  2. 隐私边界:这些是私有文档,不能直接上传到公网模型服务去做摘要。

RAG 的思路不是去"微调"模型,而是把知识从模型里搬出来,放到模型外面:把文档切成小块、向量化后存进检索库,用户提问时先检索出最相关的几段,塞进提示词里让 LLM "开卷考试"。LLM 只负责读题和作答,知识本身始终在你自己的基础设施里。

一个典型 RAG 应用由两部分组成,本实验的每一步代码都能对应到这条流水线上:
#mermaid-svg-syzkTjWsKEBpDmz0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-syzkTjWsKEBpDmz0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-syzkTjWsKEBpDmz0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-syzkTjWsKEBpDmz0 .error-icon{fill:#552222;}#mermaid-svg-syzkTjWsKEBpDmz0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-syzkTjWsKEBpDmz0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-syzkTjWsKEBpDmz0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-syzkTjWsKEBpDmz0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-syzkTjWsKEBpDmz0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-syzkTjWsKEBpDmz0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-syzkTjWsKEBpDmz0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-syzkTjWsKEBpDmz0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-syzkTjWsKEBpDmz0 .marker.cross{stroke:#333333;}#mermaid-svg-syzkTjWsKEBpDmz0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-syzkTjWsKEBpDmz0 p{margin:0;}#mermaid-svg-syzkTjWsKEBpDmz0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-syzkTjWsKEBpDmz0 .cluster-label text{fill:#333;}#mermaid-svg-syzkTjWsKEBpDmz0 .cluster-label span{color:#333;}#mermaid-svg-syzkTjWsKEBpDmz0 .cluster-label span p{background-color:transparent;}#mermaid-svg-syzkTjWsKEBpDmz0 .label text,#mermaid-svg-syzkTjWsKEBpDmz0 span{fill:#333;color:#333;}#mermaid-svg-syzkTjWsKEBpDmz0 .node rect,#mermaid-svg-syzkTjWsKEBpDmz0 .node circle,#mermaid-svg-syzkTjWsKEBpDmz0 .node ellipse,#mermaid-svg-syzkTjWsKEBpDmz0 .node polygon,#mermaid-svg-syzkTjWsKEBpDmz0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-syzkTjWsKEBpDmz0 .rough-node .label text,#mermaid-svg-syzkTjWsKEBpDmz0 .node .label text,#mermaid-svg-syzkTjWsKEBpDmz0 .image-shape .label,#mermaid-svg-syzkTjWsKEBpDmz0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-syzkTjWsKEBpDmz0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-syzkTjWsKEBpDmz0 .rough-node .label,#mermaid-svg-syzkTjWsKEBpDmz0 .node .label,#mermaid-svg-syzkTjWsKEBpDmz0 .image-shape .label,#mermaid-svg-syzkTjWsKEBpDmz0 .icon-shape .label{text-align:center;}#mermaid-svg-syzkTjWsKEBpDmz0 .node.clickable{cursor:pointer;}#mermaid-svg-syzkTjWsKEBpDmz0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-syzkTjWsKEBpDmz0 .arrowheadPath{fill:#333333;}#mermaid-svg-syzkTjWsKEBpDmz0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-syzkTjWsKEBpDmz0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-syzkTjWsKEBpDmz0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-syzkTjWsKEBpDmz0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-syzkTjWsKEBpDmz0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-syzkTjWsKEBpDmz0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-syzkTjWsKEBpDmz0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-syzkTjWsKEBpDmz0 .cluster text{fill:#333;}#mermaid-svg-syzkTjWsKEBpDmz0 .cluster span{color:#333;}#mermaid-svg-syzkTjWsKEBpDmz0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-syzkTjWsKEBpDmz0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-syzkTjWsKEBpDmz0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-syzkTjWsKEBpDmz0 .icon-shape,#mermaid-svg-syzkTjWsKEBpDmz0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-syzkTjWsKEBpDmz0 .icon-shape p,#mermaid-svg-syzkTjWsKEBpDmz0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-syzkTjWsKEBpDmz0 .icon-shape .label rect,#mermaid-svg-syzkTjWsKEBpDmz0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-syzkTjWsKEBpDmz0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-syzkTjWsKEBpDmz0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-syzkTjWsKEBpDmz0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 检索与生成 Retrieval & Generation(每次提问)
离线索引 Indexing(启动时一次)
向量索引
加载 Load

TextLoader 读入 companyPolicies.txt
拆分 Split

CharacterTextSplitter 切成 chunks
嵌入 Embed

BAAI/bge-base-zh-v1.5
存储 Store

Chroma 向量数据库
用户 Query
检索 Retrieve

相似度 Top-K
组装 Prompt

context + question
生成 Generate

LLM 开卷作答

RAG 的本质是把 LLM 从"闭卷考生"变成"开卷考生"------它不需要记住你的文档,只需要在答题时能翻到正确的那一页。

二、索引管线:从一份 txt 到 16 个向量

2.1 加载与切分

源文档是纯文本格式,TextLoader 一步读入。真正值得深挖的是切分环节:

python 复制代码
loader = TextLoader(filename)
documents = loader.load()
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
texts = text_splitter.split_documents(documents)
print(len(texts))

运行输出很"热闹":

text 复制代码
Created a chunk of size 1624, which is longer than the specified 1000
Created a chunk of size 1885, which is longer than the specified 1000
Created a chunk of size 1903, which is longer than the specified 1000
Created a chunk of size 1729, which is longer than the specified 1000
Created a chunk of size 1678, which is longer than the specified 1000
Created a chunk of size 2032, which is longer than the specified 1000
Created a chunk of size 1894, which is longer than the specified 1000
16

设定 chunk_size=1000,结果 16 个块里有 7 个超过了 1000 字符,最大的达 2032------超规率接近一半 。这不是玄学,而是 CharacterTextSplitter 的 fallback 行为:它先按分隔符(默认 \n\n)把文本切成自然段落,再逐段累积凑到 chunk_size。当单个段落本身超过 1000 字符、且段落内部没有 \n\n 可供二次切分 时,切分器宁可整段保留也不硬切,于是发出告警。这份政策文档每项政策都是一整个大段落(段落内只有单换行),所以前 7 项政策全部超长。教程里说"拆分位置是随机发生的,这是 LangChain 的一个问题",更准确的说法是:分隔符失效时的整段保留,才是超长的真正来源 。补救手段也明确------显式指定更细的分隔符(如 separator="\n"),或改用递归切分器 RecursiveCharacterTextSplitter。

另外注意 chunk_overlap=0:块与块之间没有重叠。重叠的本意是防止关键信息恰好被切在边界上"一半在块 A、一半在块 B",代价是引入冗余。本文档以政策为单位切分、语义边界还算完整,设 0 可以接受;但在生产场景中通常会给 10%~20% 的重叠。

2.2 嵌入与存储

python 复制代码
embeddings = HuggingFaceEmbeddings(
    model_name="BAAI/bge-base-zh-v1.5",  # 自动从 HuggingFace 下载
    model_kwargs={"device": "mps"},      # 使用 GPU;CPU 用户改为 "cpu"
    encode_kwargs={"normalize_embeddings": True}  # BGE 模型必须开启归一化
)

docsearch = Chroma.from_documents(texts, embeddings)
print('document ingested')

短短几行里有三个值得抠的细节:

  • device="mps" :mps 是 Apple Silicon 的 GPU 后端(Metal Performance Shaders),在 Mac 上把嵌入计算放到 GPU,CPU 用户退回 "cpu" 即可;
  • normalize_embeddings=True :归一化把每个向量缩放成单位长度(模为 1)。这一步之后,向量之间的余弦相似度等于内积------Chroma 默认按内积/L2 做相似度检索,不归一化的话检索分数的几何意义就不纯粹了。BGE 系列模型的官方用法明确要求开启,注释里"必须"两个字不是随便写的;
  • bge-base-zh-v1.5 是中文优化模型 ,而文档是英文的。实验证明它能跑通且效果不错(BGE 有一定跨语言表征能力,加上政策文档结构规整),但严格来说英文文档更应选英文或多语言版本(如 bge-base-en-v1.5 / bge-m3)。这是个"能工作但不够对味"的选择,生产中要按语料语言选模型。

Chroma.from_documents(texts, embeddings) 一行完成"嵌入 + 建库":16 个文本块各自生成 768 维向量,连同原文和 metadata(源文件名)一起写入 Chroma。向量库的价值不在"存",而在"按语义找"------后续检索时比的不是关键词重合,而是向量夹角。

三、接入 LLM:一次 OpenAI 兼容协议的"换头手术"

实验最初的设计是本地 Ollama 跑 ibm/granite4,最终实现改成了 OpenAI 兼容协议接入云端模型:

python 复制代码
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="mimo-v2.6-flash",
    base_url="https://token-plan-cn.xiaomimimo.com/v1",
    api_key="...",                         # 从环境变量注入更安全
    temperature=0.7,
    top_p=0.8,
    max_tokens=512,                        # 等价于原 ChatOllama 的 num_predict
)

被注释掉的原方案留在代码里,恰好构成了一个绝妙的对照:

python 复制代码
# from langchain_ollama import ChatOllama
# llm = ChatOllama(
#     model='ibm/granite4:latest',
#     base_url="http://localhost:11434",
#     temperature=0.7, top_p=0.8, top_k=20,
#     num_predict=512, num_ctx=2048,
# )

对比两段代码:采样参数几乎一一对应,变的只是接入方式和端点 。这就是 OpenAI 兼容协议的价值------/v1/chat/completions 成了事实标准之后,换模型(本地 Ollama、云端推理服务)只需要换 base_url 和 model,上层链路一行不用动。对 RAG 这种"检索好的上下文喂给谁都行"的架构来说,LLM 层是完全可插拔的。

参数层面:temperature=0.7、top_p=0.8 是典型的平衡性采样配置;max_tokens=512 限制了单次回答的长度------这对摘要场景是有意义的约束,逼着模型输出浓缩版而不是复读机。

一个必须提的安全问题:实验里 api_key 是硬编码在 notebook 中的 。可复现实验图省事可以理解,但正确的做法是环境变量注入(os.environ["API_KEY"]),避免密钥随代码仓库一起泄露------尤其这份 key 会随 notebook 一起被导出、分享、上传。

四、RetrievalQA:一条链完成"检索 + 生成"

索引和 LLM 都就位后,LangChain 把它们装配成一条问答链:

python 复制代码
qa = RetrievalQA.from_chain_type(llm=llm,
                                 chain_type="stuff",
                                 retriever=docsearch.as_retriever(),
                                 return_source_documents=False)
query = "what is mobile policy?,用中文回答"
qa.invoke(query)

这一行 from_chain_type 背后发生了三件事,每一件都值得拆开看:

  1. docsearch.as_retriever() :把向量库包装成检索器。默认配置是相似度检索、Top-K=4------每次提问,取与问题向量最相近的 4 个文本块;
  2. chain_type="stuff" :stuff 是最朴素的合并策略------把检索到的 4 个块直接"塞进"(stuff)同一个提示词 。4 个块约 4~6K 字符,远未触顶 512 之外的上下文窗口,所以这里 stuff 是最优解。LangChain 还提供 refine(逐块滚动精炼答案)和 map_reduce(逐块摘要再归并)两种策略,用于块多到塞不下、或需要全局视野的场景------代价是成倍的 LLM 调用次数。块少用 stuff,块多用 map_reduce,需要增量精炼用 refine;
  3. return_source_documents=False :默认只返回答案。练习 2 中把它改成 True,就能拿到命中的原文块:
python 复制代码
results = qa.invoke("Can I smoke in company vehicles?")
print(results['source_documents'][0])

输出里能看到命中块带着完整的 metadata(source: companyPolicies.txt)和原文------来源可追溯是 RAG 相对纯 LLM 的一大工程优势,答案对不对可以回查到具体段落。

看真实问答效果。问"什么是手机政策",模型返回了一段结构完整的中文摘要(7 个要点 + 总结),内容全部来自文档第 9 项政策,没有一句是模型自己编的。再让它做整篇文档的摘要,模型输出了行为准则、招聘政策、健康与安全政策等多个章节的浓缩,其中有一个细节尤其体现 RAG 的忠实性:

text 复制代码
### 4. 反歧视和骚扰政策(Anti-discrimination and Harassment Policy)
(文档中仅提及标题,内容未完整提供)

检索没能召回该章节的实质内容(该块在文档中本就缺内容),模型没有硬编一段"反歧视政策"的套话来补全,而是如实标注"内容未完整提供"。检索决定了模型能"看到"什么,模型只对看到的部分负责------这就是 RAG 的知识边界。

五、提示词模板:给幻觉上一道闸

上一节是"检索到的都有依据",这一节看反面------问一个文档里根本不存在的问题:

python 复制代码
query = "Can I eat in company vehicles?"
qa.invoke(query)

文档里没有关于在公司车辆内进食的规定。不加约束时,模型的回答开始"即兴发挥":围绕吸烟政策展开推测,还建议你"向组织确认"------看上去头头是道,但已经是在用语言模型的先验知识填补文档空白了。教程对这一幕的定性很准确:你不想这种情况发生,所以必须加提示词。

解法是用 PromptTemplate 定制提示词模板:

python 复制代码
prompt_template = """Use the information from the document to answer the question at the end. 
If you don't know the answer, just say that you don't know, definitely do not try to make up an answer.

{context}

Question: {question}
"""

PROMPT = PromptTemplate(
    template=prompt_template, input_variables=["context", "question"]
)

chain_type_kwargs = {"prompt": PROMPT}

qa = RetrievalQA.from_chain_type(llm=llm,
                                 chain_type="stuff",
                                 retriever=docsearch.as_retriever(),
                                 chain_type_kwargs=chain_type_kwargs,
                                 return_source_documents=False)

两个机制要点:

  • {context} 和 {question} 是约定关键字 。RetrievalQA 内部会把检索结果填进 context、把用户查询填进 question,只要变量名对上,链会自动完成"检索结果 → 提示词"的装配,你不需要手动传值;
  • 模板的核心是那句 "definitely do not try to make up an answer"。它给模型立了一条优先级:文档依据 > 说"不知道" > 编造。

加上模板后重问同一个问题,回答变成:

text 复制代码
根据提供的文档内容,文档中没有明确说明是否可以在公司车辆内进食。
文档中唯一涉及公司车辆的内容出现在吸烟政策中,规定:公司车辆内禁止吸烟......
建议您向公司管理层或人力资源部门进一步确认此项规定。

同一个问题、同一个模型、同一个检索结果,唯一的变化是提示词里多了一句"不知道就说不知道"------模型从"推测者"变成了"诚实的查阅者"。提示工程在这里不是玄学,而是通过明确的行为约束改变了模型输出的概率分布。

六、对话记忆:让 "it" 知道自己在说什么

6.1 无记忆的翻车现场

先看一个经典翻车。上一轮刚问完"能不能在公司车辆里吃东西",接着问:

python 复制代码
query = "What I cannot do in it?请用中文回答"
qa.invoke(query)

这里的 "it" 显然指"公司车辆",正确答案应该是"车内禁止吸烟"。但 RetrievalQA 每次调用都是独立的------它看不到上一轮的问答,于是把 "it" 当成了字面上匹配到的"反歧视和骚扰政策",答非所问。

问题不在模型的理解力,而在架构:单轮问答链里,每轮的上下文窗口都是空白的。

6.2 ConversationalRetrievalChain 的两阶段机制

引入记忆部件:

python 复制代码
memory = ConversationBufferMemory(memory_key="chat_history", return_message=True)

qa = ConversationalRetrievalChain.from_llm(llm=llm,
                                           chain_type="stuff",
                                           retriever=docsearch.as_retriever(),
                                           memory=memory,
                                           get_chat_history=lambda h: h,
                                           return_source_documents=False)

ConversationalRetrievalChain 与 RetrievalQA 的本质区别是多了一步问题改写(condense question):先把"对话历史 + 新问题"交给 LLM,改写成一个不依赖上下文、语义完整的独立问题,再拿去检索。完整流程如下:
LLM Chroma 向量库 ConversationBufferMemory ConversationalRetrievalChain 用户 LLM Chroma 向量库 ConversationBufferMemory ConversationalRetrievalChain 用户 #mermaid-svg-LjC28p5dnmL8HyRh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LjC28p5dnmL8HyRh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LjC28p5dnmL8HyRh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LjC28p5dnmL8HyRh .error-icon{fill:#552222;}#mermaid-svg-LjC28p5dnmL8HyRh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LjC28p5dnmL8HyRh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LjC28p5dnmL8HyRh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LjC28p5dnmL8HyRh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LjC28p5dnmL8HyRh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LjC28p5dnmL8HyRh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LjC28p5dnmL8HyRh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LjC28p5dnmL8HyRh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LjC28p5dnmL8HyRh .marker.cross{stroke:#333333;}#mermaid-svg-LjC28p5dnmL8HyRh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LjC28p5dnmL8HyRh p{margin:0;}#mermaid-svg-LjC28p5dnmL8HyRh .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LjC28p5dnmL8HyRh text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-LjC28p5dnmL8HyRh .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-LjC28p5dnmL8HyRh .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-LjC28p5dnmL8HyRh .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-LjC28p5dnmL8HyRh .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-LjC28p5dnmL8HyRh #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-LjC28p5dnmL8HyRh .sequenceNumber{fill:white;}#mermaid-svg-LjC28p5dnmL8HyRh #sequencenumber{fill:#333;}#mermaid-svg-LjC28p5dnmL8HyRh #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-LjC28p5dnmL8HyRh .messageText{fill:#333;stroke:none;}#mermaid-svg-LjC28p5dnmL8HyRh .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LjC28p5dnmL8HyRh .labelText,#mermaid-svg-LjC28p5dnmL8HyRh .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-LjC28p5dnmL8HyRh .loopText,#mermaid-svg-LjC28p5dnmL8HyRh .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-LjC28p5dnmL8HyRh .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-LjC28p5dnmL8HyRh .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-LjC28p5dnmL8HyRh .noteText,#mermaid-svg-LjC28p5dnmL8HyRh .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-LjC28p5dnmL8HyRh .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LjC28p5dnmL8HyRh .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LjC28p5dnmL8HyRh .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LjC28p5dnmL8HyRh .actorPopupMenu{position:absolute;}#mermaid-svg-LjC28p5dnmL8HyRh .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-LjC28p5dnmL8HyRh .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LjC28p5dnmL8HyRh .actor-man circle,#mermaid-svg-LjC28p5dnmL8HyRh line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-LjC28p5dnmL8HyRh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} "What I cannot do in it?"读取 chat_historymobile policy 问答记录...阶段一:历史 + 新问题 → 改写为独立问题"公司车辆内禁止做什么?"用改写后的问题做相似度检索 Top-4相关文本块阶段二:改写问题 + 检索块 → 生成答案"不能在车内吸烟......"保存本轮问答到 chat_history

"改写"这一步就是指代消解的发生地------"it" 会在阶段一被历史上下文填充成具体对象。加了记忆之后重跑同款问题:

python 复制代码
query = "List points in it?请用中文回答"   # 上一轮刚问过 mobile policy
result = qa({"question": query}, {"chat_history": history})

模型准确列出了移动电话政策的全部 7 个要点;接着问 "What is the aim of it?",模型回答"移动电话政策的目的是确保员工以符合公司价值观和法律合规的方式使用移动设备......"。两轮代词 "it" 都被正确解析为移动电话政策------记忆链路工作正常。

再看 get_chat_history=lambda h: h:它控制从 memory 中取出历史的格式(原样返回),保证历史以原始形态进入提示词。

6.3 一个静默失效的拼写错误

注意 memory 那一行的 return_message=True------它其实拼错了,正确参数名是复数形式 return_messages。我用同版本 LangChain 实测对比了两种写法:

text 复制代码
return_message(单数) -> load_memory_variables 返回 str   (字符串拼接)
return_messages(复数) -> load_memory_variables 返回 list  (消息对象列表)

单数拼写被 pydantic 静默吞掉(未知参数不报错),实际效果退化为"字符串拼接记忆"。本实验恰好能跑通------因为字符串形式的历史同样能喂给问题改写环节------所以这个笔误全程无感。这正是旧式 Memory API 的典型陷阱:参数写错不报错、行为退化不留痕,直到某天换个场景才突然爆发。这也是它被废弃的原因之一。

七、封装成智能体:一个交互循环

最后把"记忆 + 检索链 + 输入循环"封装成函数:

python 复制代码
def qa():
    memory = ConversationBufferMemory(memory_key="chat_history", return_message=True)
    qa = ConversationalRetrievalChain.from_llm(llm=llm,
                                               chain_type="stuff",
                                               retriever=docsearch.as_retriever(),
                                               memory=memory,
                                               get_chat_history=lambda h: h,
                                               return_source_documents=False)
    history = []
    while True:
        query = input("Question: ")
        if query.lower() in ["quit", "exit", "bye"]:
            print("Answer: Goodbye!")
            break
        result = qa({"question": query + ",请用中文回答"}, {"chat_history": history})
        history.append((query, result["answer"]))
        print("Answer: ", result["answer"])

真实运行实录(连续追问同一话题):

text 复制代码
Q: 什么是吸烟政策?
A: 根据公司的吸烟政策,不能在公司车内吸烟。政策明确规定:
   "No Smoking in Company Vehicles: Smoking is not permitted in company
   vehicles, whether they are owned or leased..."

Q: 能在公司工位上吃饭吗?
A: 没有任何一项政策明确规定是否可以在公司工位上吃饭......建议向直接主管
   或人力资源部门咨询。

Q: bye
A: Goodbye!

第一问答得有据(命中吸烟政策),第二问没有编造(提示词模板的功劳)。虽然从架构上说这还只是"链 + 记忆"而非严格意义上的工具调用型 Agent(没有让 LLM 自主决策调用工具),但"检索、记忆、多轮交互"三件套已经齐了,离真正的智能体只差一层工具调用的外壳。

八、结语

回看整个实验,它用不到百行代码走完了 RAG 的完整生命周期,但真正有价值的不是代码量,而是每个环节暴露出的"设计决策":

  • 切分不是简单的字符串截断,分隔符、块大小、重叠率共同决定了"检索单元"的语义完整性;
  • 嵌入的归一化与模型选型,决定了相似度检索的成色;
  • stuff/refine/map_reduce 的取舍,是在上下文窗口、调用成本、答案质量之间找平衡;
  • 提示词模板用一句"不知道就说不知道"把模型从推测者拉回查阅者;
  • 对话记忆 的本质不是存储历史,而是问题改写------让每一轮的新问题带着上一轮的语境重新出发。

整条链路里,LLM 自始至终只做一件事:根据给定上下文组织语言。知识在向量库里,判断在提示词里,记忆在链的状态里------把这三样东西放在模型外面而不是里面,正是 RAG 的核心价值。

相关推荐
AI开源软件2 小时前
87.4%:OpenAI 让模型写出 20 种语言 SQLite
人工智能·ai·openai·fortran·cobol·技术翻译·银行核心系统
山顶夕景2 小时前
【VideoLLM】Agentic Video Understanding视频理解综述
agent·强化学习·1024程序员节·视频理解·agentic·多模态理解·grpo
去伪存真2 小时前
生产级 RAG 知识库系统架构与核心深度解析
agent
看浪的路人2 小时前
第3讲:工业级共识:Raft 算法
ai
今天背单词了吗9802 小时前
AI 网关:大模型时代的统一接入与安全防线
网络·人工智能·安全·ai·1024程序员节
张彦峰ZYF3 小时前
「能跑」不等于「可上线」:生产级 Agent 的验收标准
人工智能·agent·分层抽样、难例、信息不足、对抗·人工、规则、上一版本三条基线·go 与 no-go 决策·内测、灰度、全量三套标准
在世修行3 小时前
智学文枢:AI 客户端封装
python·http·ai·1024程序员节·pyside
紧跟先前的步伐3 小时前
Go AI Agent 实战 13|大模型流式输出 Streaming 的实现
人工智能·ai·golang·1024程序员节
goehou4 小时前
Agent 卡在第三步,怎么查,一次请求的可观测性
llm·agent·日志·trace·可观测性