2024前端RAG文档检索接入聊天页十大推荐方案
今天咱们聊聊前端怎么接入 RAG。RAG 这词儿吧,全称Retrieval-Augmented Generation,听起来挺唬人,但本质其实特别简单:就是在问大模型问题之前,先把相关的资料翻出来,一块儿塞进 Prompt 里。这篇不讲虚的理论,直接给出一份前端开发者能立刻跑起来的最小可行版本——从数据流到代码实现,每一步都拆开来看。
你能从这篇里摸清楚三件事:一个真正能跑的 RAG 数据流到底需要几步;哪些步骤可以放心地交给前端、哪些必须得放在服务端;以及,引用来源的 UI 到底怎么设计才能既不丑又不碍事。
一、最小数据流
先画个图,把流程理清爽。
[Indexing 阶段,离线/上传时跑一次] 文档 ── 切片 ─→ Embedding ─→ 存向量库
[Query 阶段,用户每次提问跑] 用户问题 ── Embedding ─→ 向量检索 ─→ Top-K 切片 ─→ 拼进 Prompt ── 调 LLM ── 流式吐回前端 ─→ 引用来源 UI
整个流程的核心动作只有四个:切片、嵌入、检索、引用。别被那些花里胡哨的术语绕晕了,抓住这四步就够了。
二、前端能做哪些步骤
既然聊前端,最直接的问题就是:这些活儿到底能放多少到浏览器里跑?
| 步骤 | 推荐放哪 | 原因 |
|---|---|---|
| 切片 (chunking) | 服务端或上传时 | 算法稳定,不需要每次跑 |
| 文档 Embedding | 服务端 | API Key 不能暴露在浏览器 |
| 查询 Embedding | 可以放前端(用 transformers.js) | 节省服务端调用,且支持纯客户端场景 |
| 向量检索 | 服务端(pgvector / Qdrant / Milvus) | 数据规模大时必须 |
| LLM 调用 | 服务端 | 同上,Key 安全 |
| 引用来源 UI | 前端 | 显然 |
总结一下:切片和文档嵌入属于一次性的准备工作,服务端干更靠谱。查询嵌入可以借助 transformers.js 在浏览器里执行,节省一次服务端调用。向量检索和 LLM 调用因为有 Key 安全和数据规模的问题,还是乖乖放服务端。最后的引用 UI 展示,那就全看前端的了。
三、最小服务端接口(伪代码)
服务端只需要一个接口,用来接收问题、做检索、拼 Prompt、最后流式返回给前端。
// POST /api/rag/query
app.post('/api/rag/query', async (req, res) => {
const { question } = req.body;
// 1. 嵌入问题
const qVec = await embed(question);
// 2. 检索 top-5
const hits = await vectorStore.search(qVec, { topK: 5 });
// 3. 拼 prompt
const context = hits.map((h, i) => `[${i + 1}] ${h.text}`).join('\n\n');
const prompt = `请基于以下资料回答问题。引用资料时用 [1][2] 标记。\n\n资料:\n${context}\n\n问题:${question}`;
// 4. 流式调 LLM,把 hits 元信息也通过 SSE 发给前端
res.setHeader('Content-Type', 'text/event-stream');
res.write(`event: sources\ndata: ${JSON.stringify(hits)}\n\n`);
for await (const chunk of llm.stream(prompt)) {
res.write(`event: token\ndata: ${JSON.stringify({ delta: chunk })}\n\n`);
}
res.write('event: done\ndata: {}\n\n');
res.end();
});
这里最关键的是第四步:把检索结果 sources 先于 LLM 的输出推到前端。这样一来,引用 UI 可以提前占好位置,等 LLM 吐出 [1] 这样的标记时,前端直接高亮对应的卡片——体验比 ChatGPT 那种机灵小角标好多了。
四、前端展示引用来源
前端这边,代码结构其实挺清晰。
<script setup lang="ts">
import { ref } from 'vue';
interface Source { id: string; title: string; url: string; text: string; }
const sources = ref
const answer = ref('');
const isStreaming = ref(false);
async function ask(question: string) {
sources.value = [];
answer.value = '';
isStreaming.value = true;
const res = await fetch('/api/rag/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question }) });
// 简化的 SSE parser,参考流式渲染那篇
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const events = buffer.split('\n\n');
buffer = events.pop() ?? '';
for (const ev of events) {
const lines = ev.split('\n');
const event = lines.find((l) => l.startsWith('event:'))?.slice(7);
const data = lines.find((l) => l.startsWith('data:'))?.slice(6);
if (!data) continue;
if (event === 'sources') { sources.value = JSON.parse(data); }
else if (event === 'token') { answer.value += JSON.parse(data).delta; }
}
}
isStreaming.value = false;
}
</script>
renderWithCitations 这个工具函数其实很简单:把答案里的 [1] 替换成 ¹,点击或者悬停时弹出一个浮层,展示对应资料的标题和摘要。相比 ChatGPT 那种藏在角标里的信息,这种设计让用户一眼就能看到引文来源,体验更直接。
五、什么场景你不需要 RAG
当然,RAG 也不是万能的。有些场景下,直接塞 Prompt 反而更简单:
- 数据量极小(几千字以内):把所有内容直接丢进 Prompt 里,省去检索步骤,效果往往更好。
- 用户问的就是 LLM 自己知道的事:如果问题涉及通用知识,RAG 不仅帮不上忙,反而可能限制模型的回答范围。
- 需要“创造”而不是“事实”:RAG 的本质是拉回相关文档拼进 Prompt,这会天然地把模型“拉回”到参考资料附近,让它变得更保守、更不敢发挥。
六、什么场景前端可以纯客户端跑 RAG
虽然大部分场景需要服务端支持,但有一种情况例外:你的文档全是公开内容,或者用户自己上传的文件只在本地处理。比如:
- 用 transformers.js 在浏览器里跑
bge-small-zh做嵌入; - 用 IndexedDB 存向量;
- LLM 部分接 OpenAI 或 DeepSeek 的 API(这一步仍然需要服务端袋里 Key 来保证安全)。
这种方案特别适合做"个人知识库"、"PDF 阅读助手"、"本地代码搜索"这类隐私敏感的产品。用户的数据全程留在本地,不用上传到任何服务端,心理负担直接降到零。
七、下一步
把流程跑通只是入门。RAG 在生产环境里真正的难点是切片策略、召回质量、以及重排(rerank)。切片怎么切才能不丢失语义?检索结果怎么排序才能把最相关的放在前头?这些才是真正拉开差距的地方。这篇先把基础流程跑通,后续再单独成篇深入聊那些进阶话题。
顺便提一下,如果你想深入了解相关内容,可以看看这几篇:流式渲染里关于 SSE 和 WebSocket 在 Chat UI 的实现;transformers.js 如何在浏览器里跑模型;以及 Prompt 工程的工程化最佳实践。