RAG 最小项目(文档问答 + 引用)
目标:用最小链路跑通“切分 → Embedding → 检索 → 引用回答”。
目录
- 项目目标与范围
- 架构图(最小闭环)
- 项目结构(最小可跑)
- 详细步骤(可直接照做)
- 后端最小接口(伪代码)
- 前端展示(引用面板)
- 运行步骤(最少流程)
- 验收标准
- 常见坑排查清单
- 详细实现解析(切分与入库)
- 详细实现解析(检索)
- 证据展示与引用规范
- Mermaid:RAG 端到端流程
- 调试与排障
- 扩展方向
- 示例数据集(可直接使用)
- 向量库 Schema(最小示例)
- 评估表(样例)
- 常见追问与处理
- 运行示例(伪日志)
- 进一步优化清单
- FAQ
- 完整可运行代码(Mock 版)
项目目标与范围
- 必须有:文档切分、向量检索、引用来源
- 暂不需要:权限体系、多人协作、长文档版本管理
架构图(最小闭环)
项目结构(最小可跑)
rag-demo/
ingest/
split.js
embed.js
server/
search.js
answer.js
web/
App.jsx
数据结构(建议最小字段)
type Chunk = {
id: string;
text: string;
vector: number[];
meta: { docId: string; docTitle: string; page?: number };
};
切分示例(伪代码)
向量库入库(伪代码)
const chunks = split(text, 800, 120);
const vectors = await embedText(chunks);
await vectorDB.upsert(
chunks.map((c, i) => ({
id: `doc-001-${i}`,
vector: vectors[i],
text: c,
meta: { docId: "doc-001", docTitle: "员工手册", page: i }
}))
);
function split(text, size = 800, overlap = 120) {
const chunks = [];
for (let i = 0; i < text.length; i += size - overlap) {
chunks.push(text.slice(i, i + size));
}
return chunks;
}
详细步骤(可直接照做)
- 文档解析:去掉页眉页脚、目录、无关噪音
- 切分:按 800 字 + 120 overlap
- Embedding:把 chunk 转成向量
- 入库:存 vector + text + meta
- 检索:问题向量 TopK
- 拼 Prompt:把证据编号进 Prompt
- 生成:回答 + 引用
后端最小接口(伪代码)
// POST /rag/search -> 返回证据
app.post("/rag/search", async (req, res) => {
const { question } = req.body;
const topK = await retrieve(question);
res.json({ evidence: topK });
});
// POST /rag/answer -> 返回答案 + 引用
app.post("/rag/answer", async (req, res) => {
const { question } = req.body;
const topK = await retrieve(question);
const prompt = buildRagPrompt(question, topK);
const answer = await callLLM(prompt);
res.json({ answer, evidence: topK });
});
RAG Prompt(强约束)
你只能基于证据回答。
每个关键结论必须标注 [S1][S2]。
无证据则拒答。
检索与重排(伪代码)
const qVec = (await embedText([question]))[0];
const topK = await vectorDB.search(qVec, { topK: 10 });
const reranked = rerank(question, topK);
const evidence = reranked.slice(0, 5);
前端展示(引用面板)
- 回答正文中带引用编号
[S1][S2] - 右侧或下方展示证据列表(可展开)
运行步骤(最少流程)
- ingest:切分 → embedding → 入库
- search:问题 → 向量检索 TopK
- answer:拼 Prompt → 生成回答 + 引用
前端渲染示例(引用面板)
function CitationPanel({ items }) {
return (
<ul>
{items.map((it) => (
<li key={it.id}>[{it.id}] {it.meta.docTitle} · P{it.meta.page}</li>
))}
</ul>
);
}
验收标准
- 回答带引用编号
- 证据面板能看到原文片段
- 更换问题时引用来源合理变化
常见坑排查清单
- 检索不准:chunkSize/overlap 不合理 → 调参
- 引用缺失:Prompt 未强约束 → 加引用规则
- 证据噪音大:TopK 太大 → 先召回再重排
- 引用不一致:证据顺序变动 → 固定排序并标号
- 答案空洞:证据过短 → 增加 overlap 或合并相邻 chunk
详细实现解析(切分与入库)
切分策略选择
- 标题切分优先:适合 Markdown/结构化文档
- 长度切分 + overlap:适合 PDF/纯文本
overlap 的作用
- 防止“答案跨边界”导致检索漏掉关键信息
- 建议 80~150 字
入库前清洗
- 去掉页眉页脚
- 去掉目录/目录页码
- 去掉过短 chunk(< 50 字)
详细实现解析(检索)
TopK 与重排
- TopK 先大(10~20)
- 重排后取 TopN(3~5)
- 保证上下文不过长
相关性不足的信号
- 回答空洞
- 引用与结论无关
- 用户经常追问“证据在哪”
证据展示与引用规范
推荐引用格式
[S1] 员工手册 · P12-13 · 请假流程
[S2] FAQ · 退款说明
UI 建议
- 引用列表可展开/可复制
- 点击引用跳转原文
Mermaid:RAG 端到端流程
调试与排障
检索不准
- 检查 chunkSize/overlap
- 检查 embed 模型是否一致
- 检查 TopK 是否过小
引用不稳定
- 固定排序规则
- 只保留高置信度证据
成本过高
- 控制 TopN
- 对长文档做摘要
扩展方向
- 文档增量更新
- 访问权限隔离
- 命中率与引用准确率报表
示例数据集(可直接使用)
【员工手册】
第 12 页:年假规则...
第 13 页:请假流程...
向量库 Schema(最小示例)
{
"id": "doc-001-0001",
"vector": [0.1, 0.2, 0.3],
"text": "请假流程...",
"meta": { "docId": "doc-001", "docTitle": "员工手册", "page": 12 }
}
评估表(样例)
| 问题 | 正确证据 | TopK 命中 | 引用准确 |
|---|---|---|---|
| 年假如何计算 | doc-001-0002 | 是 | 是 |
| 请假流程 | doc-001-0005 | 是 | 否 |
常见追问与处理
- 为什么答案不准?
- 检查切分与 embed 模型一致性
- 引用乱跳?
- 固定证据排序,避免随机
运行示例(伪日志)
[ingest] chunks=120
[search] topK=10
[answer] citations=3
进一步优化清单
- 引用可点击跳转原文
- 增加“证据摘要”
- 支持多文档混合检索
FAQ
Q: 文档太长怎么办?
A: 先摘要,再切分;或只检索相关章节。
Q: 向量库太慢怎么办?
A: 降低 TopK,或做缓存。
完整可运行代码(Mock 版)
源码目录:
docs/demos/rag-demo
目录结构
rag-demo/
server/
index.js
package.json
public/
index.html
data/
docs.json
server/index.js(关键逻辑)
import express from "express";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const app = express();
app.use(express.json());
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const docsPath = path.join(__dirname, "../data/docs.json");
const docs = JSON.parse(fs.readFileSync(docsPath, "utf-8"));
function tokenize(text) {
return text
.toLowerCase()
.replace(/[^一-龥a-z0-9]/g, " ")
.split(/\s+/)
.filter(Boolean);
}
function scoreDoc(query, text) {
const q = new Set(tokenize(query));
const t = tokenize(text);
let score = 0;
for (const w of t) if (q.has(w)) score += 1;
return score;
}
function retrieve(query, topK = 3) {
return docs
.map((d) => ({
id: d.id,
title: d.title,
text: d.text,
score: scoreDoc(query, d.text)
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
app.post("/rag/search", (req, res) => {
const { question } = req.body || {};
const evidence = retrieve(question || "", 3);
res.json({ evidence });
});
app.post("/rag/answer", (req, res) => {
const { question } = req.body || {};
const evidence = retrieve(question || "", 3);
if (!evidence.length || evidence[0].score == 0) {
return res.json({ answer: "证据不足,无法回答。", evidence: [] });
}
const cite = evidence.map((e, i) => `[S${i + 1}]`).join("");
const answer = `基于证据:${evidence[0].text} ${cite}`;
res.json({ answer, evidence });
});
app.listen(3002, () => {
console.log("RAG demo at http://localhost:3002");
});
public/index.html(最小前端)
<textarea id="q"></textarea>
<button id="search">检索证据</button>
<button id="answer">生成回答</button>
<pre id="answerBox"></pre>
<ul id="evidence"></ul>
运行方式
cd docs/demos/rag-demo/server
npm i
npm run dev