Appearance
上一篇我们把文档变成了可检索的向量库。这一篇把最后一环接上:检索出相关片段 → 拼进提示词 → 交给模型回答,用 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 页面配后端接口,做成一个能上传文件、在网页里问答的完整小应用。