Skip to content

从零到进阶做 Agent(八)RAG 项目实战:带界面的文档问答

🕒 Published at:

前面的 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_messagest.chat_inputwrite_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:让模型自己判断「该调用哪个工具」,从「会答」升级到「会做」。