Skip to content

从零到进阶做 Agent(七)手搓 RAG 问答链

🕒 Published at:

上一篇我们把文档变成了可检索的向量库。这一篇把最后一环接上:检索出相关片段 → 拼进提示词 → 交给模型回答,用 LCEL 串成一条完整的 RAG 问答链。做完这篇,你就有了第一个「带着你的资料回答」的问答机器人。顶部可切换 Python / Node.js。

RAG 问答的完整流程

一次 RAG 问答,数据是这样流动的:

用户问题 →(检索器)找出最相关的 N 块资料 →(格式化)拼成一段 context 文本 →(提示词模板)把 context 和问题组装成完整 prompt →(模型)参考资料作答 →(解析器)取出纯文本。

我们要做的,就是把这几步用 LCEL 串起来。关键角色有两个新面孔:检索器(Retriever)RunnablePassthrough

用户问题检索器大模型答案向量库(你的知识库)资料+问题→提示词语义检索

检索器:向量库的「查询接口」

上一篇用的是 similarity_search。在链里,我们把向量库转成一个检索器——它本质是个 Runnable,输入问题、输出文档列表,能直接接进 LCEL。

python
# 假设 vectorstore 已按上一篇建好
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})

docs = retriever.invoke("滤网多久清洗一次?")   # 输入问题,输出文档列表
print(len(docs), "篇相关文档")
javascript
// 假设 vectorstore 已按上一篇建好
const retriever = vectorstore.asRetriever(3);

const docs = await retriever.invoke("滤网多久清洗一次?");   // 输入问题,输出文档列表
console.log(docs.length, "篇相关文档");

组装完整的 RAG 链

核心技巧:链的第一步是一个并行结构——同时准备两个值,context(问题经检索、再格式化成文本)和 question(原样透传)。RunnablePassthrough 的作用就是「把输入原封不动传下去」,这样问题既能拿去检索,也能保留下来填进提示词。

python
import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

model = ChatOpenAI(
    model="qwen-plus",
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
    temperature=0,
)

# 把检索到的文档列表拼成一段纯文本
def format_docs(docs):
    return "\n\n".join(d.page_content for d in docs)

prompt = ChatPromptTemplate.from_messages([
    ("system",
     "你是产品客服。只依据下面的资料回答用户问题;"
     "如果资料里没有相关信息,就直说“抱歉,我没有查到相关信息”,不要编造。\n\n"
     "资料:\n{context}"),
    ("human", "{question}"),
])

rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | model
    | StrOutputParser()
)

print(rag_chain.invoke("滤网多久清洗一次?"))
javascript
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnableSequence, RunnablePassthrough } from "@langchain/core/runnables";

const model = new ChatOpenAI({
  model: "qwen-plus",
  apiKey: process.env.DASHSCOPE_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
  },
});

// 把检索到的文档列表拼成一段纯文本
const formatDocs = (docs) => docs.map((d) => d.pageContent).join("\n\n");

const prompt = ChatPromptTemplate.fromMessages([
  ["system",
   "你是产品客服。只依据下面的资料回答用户问题;" +
   "如果资料里没有相关信息,就直说“抱歉,我没有查到相关信息”,不要编造。\n\n" +
   "资料:\n{context}"],
  ["human", "{question}"],
]);

const ragChain = RunnableSequence.from([
  { context: retriever.pipe(formatDocs), question: new RunnablePassthrough() },
  prompt,
  model,
  new StringOutputParser(),
]);

console.log(await ragChain.invoke("滤网多久清洗一次?"));

跑一下,模型会基于你库里的资料回答,而不是凭空发挥。试着问一个资料里根本没有的问题,它会按我们的指令回答「没查到」——这就是 RAG 减少「一本正经胡说八道」的关键:把答案约束在检索到的材料里

让它「有据可查」:带出处

客服/知识库场景常要求给出处,方便用户核对。因为检索器返回的文档对象里带着元数据(比如来源文件),我们可以把答案和引用的片段一起返回。这里改成先检索、再分别构造答案与出处:

python
def answer_with_sources(question: str):
    docs = retriever.invoke(question)
    context = format_docs(docs)
    answer = (prompt | model | StrOutputParser()).invoke(
        {"context": context, "question": question}
    )
    sources = [d.page_content[:40] + "..." for d in docs]
    return {"answer": answer, "sources": sources}

result = answer_with_sources("滤网多久清洗一次?")
print("答案:", result["answer"])
print("依据:", result["sources"])
javascript
async function answerWithSources(question) {
  const docs = await retriever.invoke(question);
  const context = formatDocs(docs);
  const answer = await prompt.pipe(model).pipe(new StringOutputParser()).invoke(
    { context, question }
  );
  const sources = docs.map((d) => d.pageContent.slice(0, 40) + "...");
  return { answer, sources };
}

const result = await answerWithSources("滤网多久清洗一次?");
console.log("答案:", result.answer);
console.log("依据:", result.sources);

流式版本

前面几篇讲过,链天生支持流式。RAG 链也一样,把 invoke 换成 stream,就能边检索边把答案一个字一个字吐出来,前端体验更好。做 Web 界面时我们就用这个。

python
for chunk in rag_chain.stream("滤网多久清洗一次?"):
    print(chunk, end="", flush=True)
print()
javascript
const stream = await ragChain.stream("滤网多久清洗一次?");
for await (const chunk of stream) {
  process.stdout.write(chunk);
}
process.stdout.write("\n");

小结与预告

你已经拼出了一条完整的 RAG 问答链:检索器 + RunnablePassthrough + 提示词 + 模型 + 解析器,并学会了带出处、做流式。这就是绝大多数「文档问答 / 知识库客服」产品的核心。

不过现在还只能在命令行里跑。下一篇《RAG 项目实战》,我们给它套上界面——Python 用 Streamlit、Node 用一个 HTML 页面配后端接口,做成一个能上传文件、在网页里问答的完整小应用。