跳到主要内容

RAG 最小项目(文档问答 + 引用)

目标:用最小链路跑通“切分 → Embedding → 检索 → 引用回答”。

目录

项目目标与范围

  • 必须有:文档切分、向量检索、引用来源
  • 暂不需要:权限体系、多人协作、长文档版本管理

架构图(最小闭环)

项目结构(最小可跑)

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;
}

详细步骤(可直接照做)

  1. 文档解析:去掉页眉页脚、目录、无关噪音
  2. 切分:按 800 字 + 120 overlap
  3. Embedding:把 chunk 转成向量
  4. 入库:存 vector + text + meta
  5. 检索:问题向量 TopK
  6. 拼 Prompt:把证据编号进 Prompt
  7. 生成:回答 + 引用

后端最小接口(伪代码)

// 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]
  • 右侧或下方展示证据列表(可展开)

运行步骤(最少流程)

  1. ingest:切分 → embedding → 入库
  2. search:问题 → 向量检索 TopK
  3. 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