Appearance
前面的 RAG 问答只能在命令行跑。这一篇把它做成一个能上传文档、在网页里问答、答案流式输出的小应用。Python 用 Streamlit(几十行搞定一个 Web 界面),Node 用 Express 后端 + 一个 HTML 页面。核心的 RAG 逻辑复用第七篇,重点在「怎么套上界面 + 怎么把流式推到浏览器」。顶部可切换 Python / Node.js。
两种语言这里会有点分叉:Streamlit 是 Python 专属的快速 Web 框架;Node 侧没有等价物,所以我们老老实实写一个后端接口 + 静态 HTML 页面。思路是一样的:上传建库 → 提问 → 流式返回。
Python 版:Streamlit 一把梭
Streamlit 的理念是「写脚本即得网页」,特别适合快速做数据/AI 小工具。装依赖:
bash
pip install -U streamlit langchain langchain-openai langchain-text-splitters新建 app.py:
python
import os
import streamlit as st
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
st.title("📄 文档问答助手")
def build_chain(text: str):
"""把上传的文本建成一条 RAG 问答链"""
chunks = RecursiveCharacterTextSplitter(
chunk_size=300, chunk_overlap=50
).split_documents([Document(page_content=text)])
embeddings = OpenAIEmbeddings(
model="text-embedding-v3",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
retriever = InMemoryVectorStore.from_documents(chunks, embeddings).as_retriever(
search_kwargs={"k": 3}
)
model = ChatOpenAI(
model="qwen-plus",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
temperature=0,
)
prompt = ChatPromptTemplate.from_messages([
("system", "你是文档助手,只依据下面资料回答,没有就说没查到。\n\n资料:\n{context}"),
("human", "{question}"),
])
return (
{"context": retriever | (lambda ds: "\n\n".join(d.page_content for d in ds)),
"question": RunnablePassthrough()}
| prompt | model | StrOutputParser()
)
# 1) 上传文档 → 建链(存进 session_state,避免每次重建)
uploaded = st.file_uploader("上传一个 .txt 文档", type=["txt"])
if uploaded and "chain" not in st.session_state:
text = uploaded.read().decode("utf-8")
with st.spinner("正在向量化文档…"):
st.session_state.chain = build_chain(text)
st.success("文档已就绪,开始提问吧!")
# 2) 渲染历史消息
if "messages" not in st.session_state:
st.session_state.messages = []
for m in st.session_state.messages:
st.chat_message(m["role"]).write(m["content"])
# 3) 接收提问 → 流式回答
if question := st.chat_input("输入你的问题"):
st.chat_message("user").write(question)
st.session_state.messages.append({"role": "user", "content": question})
if "chain" not in st.session_state:
st.chat_message("assistant").write("请先在上方上传一个文档~")
else:
# write_stream 直接消费链的 stream(),实现打字机效果
answer = st.chat_message("assistant").write_stream(
st.session_state.chain.stream(question)
)
st.session_state.messages.append({"role": "assistant", "content": answer})运行:
bash
streamlit run app.py浏览器会自动打开。上传一个 txt、提问,答案就会一个字一个字蹦出来。st.chat_message、st.chat_input、write_stream 这几个 API 把「聊天界面 + 流式」这些活儿全包了——这就是 Streamlit 快的地方。
Node 版:Express 后端 + HTML 页面
Node 没有 Streamlit,我们拆成两部分:一个后端接口负责建库和流式回答,一个 HTML 页面负责交互。装依赖:
bash
npm install express @langchain/openai @langchain/textsplitters @langchain/core @langchain/classic后端 server.mjs:
javascript
import express from "express";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { Document } from "@langchain/core/documents";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnableSequence, RunnablePassthrough } from "@langchain/core/runnables";
const app = express();
app.use(express.json({ limit: "5mb" }));
app.use(express.static("public")); // 提供 public/index.html
let ragChain = null; // 建好的问答链存在内存里
async function buildChain(text) {
const chunks = await new RecursiveCharacterTextSplitter({
chunkSize: 300, chunkOverlap: 50,
}).splitDocuments([new Document({ pageContent: text })]);
const embeddings = new OpenAIEmbeddings({
model: "text-embedding-v3",
apiKey: process.env.DASHSCOPE_API_KEY,
configuration: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
});
const retriever = (await MemoryVectorStore.fromDocuments(chunks, embeddings)).asRetriever(3);
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 prompt = ChatPromptTemplate.fromMessages([
["system", "你是文档助手,只依据下面资料回答,没有就说没查到。\n\n资料:\n{context}"],
["human", "{question}"],
]);
return RunnableSequence.from([
{ context: retriever.pipe((ds) => ds.map((d) => d.pageContent).join("\n\n")),
question: new RunnablePassthrough() },
prompt, model, new StringOutputParser(),
]);
}
// 上传文档(前端把文件内容读成文本发过来)→ 建链
app.post("/api/ingest", async (req, res) => {
ragChain = await buildChain(req.body.text || "");
res.json({ ok: true });
});
// 提问 → 以流的形式把答案逐块写回
app.post("/api/ask", async (req, res) => {
if (!ragChain) return res.status(400).end("请先上传文档");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
const stream = await ragChain.stream(req.body.question);
for await (const chunk of stream) res.write(chunk);
res.end();
});
app.listen(3000, () => console.log("http://localhost:3000"));前端 public/index.html:
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>文档问答助手</title>
<style>
body { font-family: sans-serif; max-width: 640px; margin: 40px auto; }
#answer { white-space: pre-wrap; border: 1px solid #ddd; padding: 12px; min-height: 60px; border-radius: 8px; }
input, button { padding: 8px; font-size: 14px; }
#q { width: 70%; }
</style>
</head>
<body>
<h2>📄 文档问答助手</h2>
<p>1. 上传一个 .txt 文档:
<input type="file" id="file" accept=".txt" />
<span id="status"></span>
</p>
<p>2. 提问:
<input type="text" id="q" placeholder="输入你的问题" />
<button onclick="ask()">发送</button>
</p>
<div id="answer"></div>
<script>
// 读取本地文件为文本,发给后端建库
document.getElementById("file").addEventListener("change", async (e) => {
const text = await e.target.files[0].text();
document.getElementById("status").textContent = "向量化中…";
await fetch("/api/ingest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
document.getElementById("status").textContent = "✅ 已就绪";
});
// 提问并逐块读取流式答案
async function ask() {
const question = document.getElementById("q").value;
const box = document.getElementById("answer");
box.textContent = "";
const resp = await fetch("/api/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
box.textContent += decoder.decode(value, { stream: true }); // 打字机效果
}
}
</script>
</body>
</html>启动:
bash
node server.mjs
# 打开 http://localhost:3000前端读取文件内容 → /api/ingest 建库 → 提问打到 /api/ask,后端用 res.write 把链的每一块答案写回,前端用 reader.read() 逐块读出来拼到页面——这就是不依赖任何框架、纯手写的流式问答。
两种实现的对照
Streamlit 的优势是极快:几十行、纯 Python、自带聊天组件和流式。代价是它包办了前端,定制样式和交互不如手写自由。Node 这套是经典的前后端分离:后端只管逻辑和流式接口,前端随便你怎么写,灵活但代码更多。选哪个看场景——做内部工具/演示用 Streamlit,做要嵌进现有网站的产品用后端接口那套。
小结与预告
到这里,RAG 这条线就走完了:从加载切分、向量化检索,到问答链,再到今天的完整界面应用。你已经能独立做一个「文档问答产品」了。
但 RAG 的模型始终是被动的——只会「检索+回答」。如果用户问的是「今天天气怎么样」「帮我算一下」「生成一份报告」,光靠检索资料就不够了。下一篇进入 Agent:让模型自己判断「该调用哪个工具」,从「会答」升级到「会做」。