知识点思维导图
42 个知识节点
项目实战(04) - 项目:从最小 RAG 到可上线知识库
读完后,你应能完成以下任务:
- 绘制“项目实战(04) - 项目:从最小 RAG 到可上线知识库 / 为什么先做这个项目”的关键对象与数据流,解释“面试高频:企业落地最多的就是知识库问答,面试官几乎一定会问 RAG。”,并用源码位置、日志或 Trace 标注证据。
- 为“项目实战(04) - 项目:从最小 RAG 到可上线知识库 / 完整闭环长什么样”设计正常与异常输入,验证“这个项目的灵魂是最后那条分支:检索不到资料就老实说不知道。”,输出首个偏差位置与回归测试结果。
- 实现“项目实战(04) - 项目:从最小 RAG 到可上线知识库 / 可运行 Demo:先跑通最小闭环”的最小代码或配置,检验“把下面代码保存为 main.py,执行 python main.py。”,输出命令、结果与 Diff,并说明不适用边界。
一、与进阶篇的分工
本篇保留为企业知识库 RAG 的基础项目:适合做第一个可演示闭环。进阶项目请读 91《企业级知识库项目》,那里会升级到多模态解析、对象存储、混合检索、权限过滤、引用回跳和量化评测。
二、为什么先做这个项目
如果你只能做一个 AI 项目放简历,选它。原因很实在:
- 闭环清晰:上传文档 → 检索 → 回答 → 引用来源,每一步都看得见、讲得清。
- 面试高频:企业落地最多的就是知识库问答,面试官几乎一定会问 RAG。
- 能体现全栈:前端(聊天 UI、引用展示)、后端(接口、检索)、RAG(切分、检索、grounding)、工程化(日志、评测)全覆盖。
- 可信度好讲:引用来源 + 资料不足时拒答,是体现工程深度的天然抓手。
三、完整闭环长什么样
flowchart TD
D[文档] --> P[解析与 Chunk]
P --> E[Embedding 与索引]
Q[用户问题与 ACL] --> R[Top K 检索]
E --> R
R --> H{有可信证据?}
H -->|是| G[基于证据生成]
H -->|否| N[拒答或追问]
G --> C[引用来源]
离线入库(一次):
文档 → 解析文本 → 切分 chunk → 生成 embedding → 存向量库(带元数据)
在线问答(每次提问):
用户问题 → 检索 topK chunk → 拼进 prompt → 模型基于资料回答 → 返回引用来源
↓ 检索为空
明确拒答,不编造
这个项目的灵魂是最后那条分支:检索不到资料就老实说不知道。一个会编造的知识库,企业不敢用。
四、可运行 Demo:先跑通最小闭环
# requirements.txt
# 示例仅使用 Python 3.10+ 标准库,无第三方依赖。
把下面代码保存为 main.py,执行 python main.py。这里用词元重合模拟检索,只验证“召回、引用、拒答”契约;生产环境再替换成真实 Embedding、BM25 与模型生成。
跑完应看到两条带来源证据和一条资料不足拒答。把这三条路径讲清楚,项目的核心闭环才成立。
五、MVP 功能拆解(按这个顺序做)
| 模块 | 先做(MVP) | 再做(进阶) |
|---|---|---|
| 文档管理 | 上传 Markdown/TXT,查看解析状态 | PDF/Word、批量导入 |
| 切分 | 按标题/段落切 chunk | 表格切分、chunk 预览、overlap |
| 检索问答 | topK 检索 + 基于资料回答 | 混合检索、rerank、query 改写 |
| 引用来源 | 展示文件名 + 片段 | 点击定位原文 |
| 评测 | 维护 20 条问题集 | 命中率、正确率、坏 case 标签 |
| 工程化 | 日志、错误态、测试 | 权限隔离、限流、成本统计 |
先把这个最小闭环接上前端聊天框并保存 Trace,再逐项增加解析器、真实检索、流式输出和评测;每增加一层都保留可独立验收的输入与输出。
六、企业级版本怎么升级
MVP 跑通后,企业级版本重点补 5 块:
| 升级点 | 为什么要做 |
|---|---|
| 多格式解析 | PDF、Word、Excel、图片、表格都要进知识库 |
| 父子分块 | 小块负责召回,大块负责回答上下文 |
| 混合检索 | 编号/术语靠 BM25,口语问法靠向量 |
| 权限过滤 | 检索阶段按部门、角色、密级过滤 |
| 原文定位 | 引用能回跳到文件、页码、表格或图片 |
如果要继续做进阶版,可阅读“工程基础”模块的《进阶:企业级 RAG 项目拆解》。该文会把文档解析、图文表资产、权限、评测和上线指标串成完整项目。
七、接口设计参考
POST /api/knowledge/upload 上传文件
GET /api/knowledge/documents 文档列表
POST /api/rag/chat 知识库问答
GET /api/rag/logs/:requestId 查看单次检索和生成日志
八、验收标准(也是演示脚本)
演示时按这个顺序走,最有说服力:
- 上传一份制度文档 → 展示解析状态
- 问一个能命中的问题("报销几天内提交")→ 回答 + 引用来源
- 问一个知识库没有的问题("年假几天")→ 明确说资料不足
- 打开日志 → 展示这次检索命中了哪些 chunk、得分多少
第 3 步是亮点:主动展示"我不会瞎编",比第 2 步答对更能打动面试官。
九、工程上真正会踩的坑
- chunk 切太大:一段塞几百字,检索命中了但答案被淹没。按句/小段切,配合 overlap。
- 检索为空还硬答:模型会拿不相关资料编一个答案。
if not hits: 拒答是硬性兜底。 - 引用来源对不上正文:回答用了 A 资料却标 B 来源。citation 必须从实际命中的 chunk 元数据生成,不能事后补。
- 没有评测集:改了切分参数不知道是变好还是变坏。哪怕只有 20 条问题,也要能跑个命中率。
十、简历怎么写
独立实现企业知识库 RAG 助手,支持文档入库、chunk 切分、语义检索、引用来源、流式问答和坏 case 评测。前端展示检索来源、生成状态和错误重试;后端记录 requestId、检索命中、模型耗时和 token 成本。引入资料不足拒答机制,将编造率控制在可接受范围。
如果要写成企业级版本,可以这样升级:
设计企业级知识库 RAG 系统,支持多格式文档解析、父子分块、BM25+向量混合检索、rerank 精排、权限过滤和原文定位;回答引用可回跳到文件页码、表格或图片证据。维护包含正负样本的评测集,跟踪 Hit Rate@K、拒答准确率和坏 case 回归,保证知识库不是只跑通 demo,而是可持续调优。
十一、动手实践:43 企业知识库 RAG 项目
一个本地可运行的真实企业 RAG 工作台:文档入库、Markdown 批量导入、chunk 切分、本地 embedding、Qdrant 向量数据库检索、基于资料回答、引用来源和检索 Trace。
当前版本不依赖 OpenAI Key。embedding 使用本地 hashing 向量模型,向量存储使用 Docker 中的 Qdrant,适合先讲清企业 RAG 工程闭环;后续可把 LocalEmbeddingModel 换成 OpenAI / bge / m3e 等真实 embedding 模型。
11.1 环境
- Python 3.10+
- Docker Desktop
- Qdrant:通过
docker compose启动,无需手动安装
11.2 启动
cd /Users/imber/Desktop/ai-lab/worktrees/knowledge-categories/imber-blog/src/content/knowledge/03-AI大模型应用开发/02-企业级知识库/04-企业知识库项目/01-项目-企业知识库RAG/lab
docker compose up -d qdrant
python3 main.py
浏览器打开:
http://127.0.0.1:8043
Qdrant REST:
http://127.0.0.1:6333
11.3 导入 agent 小册
页面点击 导入 AI 应用手册,会从当前仓库位置自动解析并读取完整的 AI 大模型应用开发目录:
03-AI大模型应用开发
导入规则兼容新版目录结构:
**/*.md**/09-附录/*.md- 排除当前项目的
lab/
导入后会重建 Qdrant collection:
agent_manual_chunks
本次验证导入结果:
导入数量会根据当前 `knowledge/Agent` 目录中的文章实时变化。
11.4 测试
python3 -m unittest discover -s tests -v
python3 -m py_compile main.py rag_core.py
node --check static/app.js
11.5 演示问题
导入 agent小册 后可以问:
语义相近的文本为什么向量也相近?
引用来源为什么不能让模型自己报?
RAG 回答为什么要引用来源?
检索与重排 Rerank 有什么区别?
验证结果示例:
语义相近的文本为什么向量也相近?命中22-Embedding向量化.md引用来源为什么不能让模型自己报?命中25-RAG回答生成与引用来源.md
11.6 接口
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /health |
服务健康检查,返回文档数、chunk 数、Qdrant 状态 |
| GET | /api/knowledge/documents |
文档列表 |
| POST | /api/knowledge/documents |
新增文档,JSON 字段:title/source/text |
| POST | /api/knowledge/import-agent-manual |
批量导入本地 AI 应用知识集(路径保留兼容) |
| POST | /api/rag/chat |
知识库问答,JSON 字段:question/topK |
| GET | /api/rag/logs |
最近问答日志 |
| GET | /api/rag/logs/:requestId |
单次问答日志 |
11.7 代码结构
| 文件 | 作用 |
|---|---|
docker-compose.yml |
启动 Qdrant 向量数据库 |
rag_core.py |
RAG 核心:Markdown 清洗、切分、本地 embedding、Qdrant client、检索、拒答、引用、日志 |
main.py |
标准库 HTTP 服务、API 路由、静态页面服务、agent 小册导入 |
static/ |
前端工作台 |
data/sample-docs/ |
首次启动时的样例企业文档 |
tests/ |
核心逻辑、API、向量导入测试 |
11.8 后续升级
- 把
LocalEmbeddingModel换成真实 embedding 模型。 - 给 Markdown 解析增加标题层级、chunk overlap 和 chunk 预览。
- 加评测集,记录命中率、拒答率和坏 case。
- 接入真实 LLM,让
_compose_answer从“拼接资料”升级为“基于资料生成自然语言回答”。
十二、总结
- 与进阶篇的分工:本篇保留为企业知识库 RAG 的基础项目:适合做第一个可演示闭环。
- 为什么先做这个项目:面试高频:企业落地最多的就是知识库问答,面试官几乎一定会问 RAG。
- 完整闭环长什么样:这个项目的灵魂是最后那条分支:检索不到资料就老实说不知道。
- 可运行 Demo:先跑通最小闭环:这里用词元重合模拟检索,只验证“召回、引用、拒答”契约;
- MVP 功能拆解(按这个顺序做):先把这个最小闭环接上前端聊天框并保存 Trace,再逐项增加解析器、真实检索、流式输出和评测;
- 企业级版本怎么升级:| 父子分块 | 小块负责召回,大块负责回答上下文 |
12.1 实现源码与运行边界
data/sample-docs/finance-handbook.md
# 财务报销手册
报销需在费用产生后 30 天内提交,逾期需补充直属主管说明。
差旅报销包含交通、住宿、餐补三类,餐补每天上限 80 元。
单笔金额超过 5000 元的采购报销,需要部门负责人和财务负责人双重审批。
发票抬头必须使用公司全称,电子发票需上传原始 PDF 文件。
data/sample-docs/hr-policy.md
# 人事制度
请假需提前 1 天在 OA 系统提交申请,由直属主管审批。
病假需在 3 个工作日内补交医院证明,无法补交时按事假处理。
试用期员工转正评估在入职满 80 天后发起,由直属主管填写评价。
员工离职需至少提前 30 天提交申请,并完成工作交接清单。
data/sample-docs/it-support.md
# IT 支持手册
VPN 无法连接时,请先确认员工账号未过期,并重启客户端。
企业邮箱首次登录需要绑定手机验证码,验证码 5 分钟内有效。
电脑遗失或怀疑账号泄露时,必须在 1 小时内联系 IT 值班同学冻结账号。
共享文档权限默认按部门开放,跨部门共享需由文档负责人确认。
docker-compose.yml
services:
qdrant:
image: qdrant/qdrant:v1.14.1
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_data:/qdrant/storage
restart: unless-stopped
volumes:
qdrant_data:
docs/IMPLEMENTATION_PLAN.md
# 43 企业知识库 RAG 实施计划
**目标:** 把原来的单文件命令行 demo 升级为可演示的本地 Web 项目,支持文档入库、RAG 问答、引用来源、检索日志和友好的前端界面。
**方案:** 使用 Python 标准库提供 HTTP 服务与静态文件服务,核心 RAG 逻辑拆到 `rag_core.py`。检索用本地 TF-IDF + 余弦相似度实现,避免外部 API Key 和向量库依赖。
**文件结构:**
- `rag_core.py`:文档模型、chunk 切分、TF-IDF 检索、grounded 回答、请求日志。
- `main.py`:HTTP API、静态页面服务、示例数据加载、启动入口。
- `static/index.html`、`static/styles.css`、`static/app.js`:企业知识库工作台前端。
- `data/sample-docs/*.md`:开箱即用的企业制度样例。
- `tests/test_rag_core.py`、`tests/test_api.py`:核心逻辑和 API 测试。
- `README.md`:运行、测试、演示脚本和升级方向。
**验收:**
1. `python3 -m unittest discover -s tests -v` 通过。
2. `python3 main.py` 能启动服务。
3. `GET /health` 返回 ok。
4. `POST /api/rag/chat` 对“报销多久内提交”返回答案、引用和 trace。
5. 前端页面能加载文档列表、提问、展示引用和检索过程。
main.py
rag_core.py
static/app.js
const statusOutput = document.querySelector('#status'); // 健康与导入状态区域。
const answerOutput = document.querySelector('#answer'); // 回答、引用和 Trace 区域。
async function request(path, options = {}) {
const response = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options }); // API 原始响应。
const payload = await response.json(); // JSON 响应对象。
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
return payload;
}
document.querySelector('#health').addEventListener('click', async () => {
try { statusOutput.textContent = JSON.stringify(await request('/health'), null, 2); }
catch (error) { statusOutput.textContent = error.message; }
});
document.querySelector('#import').addEventListener('click', async () => {
statusOutput.textContent = '正在重建 collection 并导入...';
try { statusOutput.textContent = JSON.stringify(await request('/api/knowledge/import-agent-manual', { method: 'POST', body: '{}' }), null, 2); }
catch (error) { statusOutput.textContent = error.message; }
});
document.querySelector('#add').addEventListener('click', async () => {
const payload = { title: document.querySelector('#title').value, source: document.querySelector('#source').value, text: document.querySelector('#text').value }; // 手工文档对象。
try { statusOutput.textContent = JSON.stringify(await request('/api/knowledge/documents', { method: 'POST', body: JSON.stringify(payload) }), null, 2); }
catch (error) { statusOutput.textContent = error.message; }
});
document.querySelector('#ask').addEventListener('click', async () => {
const question = document.querySelector('#question').value.trim(); // 清洗后的问答查询。
answerOutput.textContent = '正在向量检索...';
try { answerOutput.textContent = JSON.stringify(await request('/api/rag/chat', { method: 'POST', body: JSON.stringify({ question, topK: 4 }) }), null, 2); }
catch (error) { answerOutput.textContent = error.message; }
});
static/index.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>企业知识库 RAG 工作台</title>
<style>
body { margin: 0; font: 15px/1.6 system-ui; color: #171717; background: #f4f5f7; }
main { max-width: 980px; margin: 32px auto; padding: 0 20px; }
section { padding: 20px; background: white; border: 1px solid #ddd; margin-bottom: 16px; }
textarea, input { box-sizing: border-box; width: 100%; padding: 10px; margin: 6px 0; }
button { padding: 9px 14px; margin-right: 8px; }
pre { white-space: pre-wrap; background: #111; color: #d9fbe9; padding: 14px; overflow: auto; }
</style>
</head>
<body>
<main>
<h1>企业知识库 RAG 工作台</h1>
<section><button id="health">检查健康</button><button id="import">导入 AI 应用手册</button><pre id="status">尚未检查</pre></section>
<section><h2>新增文档</h2><input id="title" placeholder="标题"><input id="source" placeholder="来源"><textarea id="text" rows="5" placeholder="# 制度标题 ## 章节 正文"></textarea><button id="add">切分并入库</button></section>
<section><h2>在线问答</h2><input id="question" value="RAG 回答为什么要引用来源?"><button id="ask">检索并回答</button><pre id="answer">等待提问</pre></section>
</main>
<script src="/app.js"></script>
</body>
</html>
tests/test_rag_core.py
12.2 可运行实验:RAG ACL 与跨租户泄漏
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AA-06 在线实验</title>
<style>
:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:#0f1211;color:#e7ece9;font-size:13px}.shell{padding:16px}.top{display:flex;justify-content:space-between;gap:16px;margin-bottom:14px}h1{margin:3px 0;font-size:18px}.id,.value{color:#68e0b5;font-family:ui-monospace,monospace}.summary{margin:4px 0;color:#a5afa9}.run{border:0;border-radius:6px;background:#68e0b5;color:#07110d;padding:8px 14px;font-weight:700}.grid{display:grid;grid-template-columns:minmax(220px,.8fr) minmax(0,1.8fr);gap:12px}.panel{border:1px solid #29322e;background:#141817;padding:12px}.control{display:grid;gap:5px;margin-bottom:11px}.head{display:flex;justify-content:space-between;gap:8px}select,input{width:100%;accent-color:#68e0b5;background:#0d100f;color:#e7ece9}.toggle{display:flex;justify-content:space-between;border-top:1px solid #29322e;padding-top:9px}.toggle input{width:18px}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.metric{border:1px solid #29322e;padding:8px}.metric b{display:block;color:#68e0b5;font-size:16px}.stages{display:flex;gap:6px;overflow:auto;margin:10px 0}.stage{border:1px solid #8a6230;padding:7px;min-width:90px}.stage.ok{border-color:#367a61}.stage.fail{border-color:#8b4545}table{width:100%;border-collapse:collapse}td{border-top:1px solid #29322e;padding:7px}.diagnosis{margin-top:9px;border-left:3px solid #68e0b5;background:#101412;padding:9px;line-height:1.5}.danger{border-color:#ef7f7f}@media(max-width:680px){.top,.grid{display:grid;grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
</style>
</head>
<body>
<main class="shell">
<header class="top"><div><div class="id">AA-06 · DETERMINISTIC LAB</div><h1 id="title"></h1><p class="summary" id="summary"></p></div><button class="run" id="run">运行实验</button></header>
<section class="grid"><div class="panel"><div id="controls"></div><label class="toggle"><span>注入典型故障</span><input id="failure" type="checkbox"></label></div><div class="panel"><div class="metrics" id="metrics"></div><div class="stages" id="stages"></div><table><tbody id="rows"></tbody></table><div class="diagnosis" id="diagnosis"></div></div></section>
</main>
<script>
const scenario = { title: 'RAG ACL 与跨租户泄漏', summary: '切换鉴权时机、缓存键和租户,验证候选与引用是否会越权。', controls: [
{ key: 'filterStage', label: 'ACL 执行时机', type: 'select', value: 'before', options: [['none', '无 ACL'], ['after', '召回后'], ['before', '召回前']] },
{ key: 'cacheKey', label: '缓存键组成', type: 'select', value: 'full', options: [['query', '仅 Query'], ['tenant', 'Query + Tenant'], ['full', 'Query + Tenant + ACL 摘要']] },
{ key: 'role', label: '当前角色', type: 'select', value: 'employee', options: [['guest', '访客'], ['employee', '员工'], ['finance', '财务管理员']] }
] };
const controls = document.querySelector('#controls');
const failure = document.querySelector('#failure');
document.querySelector('#title').textContent = scenario.title;
document.querySelector('#summary').textContent = scenario.summary;
function renderControl(control) {
const label = document.createElement('label'); label.className = 'control';
const head = document.createElement('span'); head.className = 'head'; head.innerHTML = '<span>' + control.label + '</span><span class="value" data-value="' + control.key + '"></span>'; label.appendChild(head);
const input = document.createElement(control.type === 'select' ? 'select' : 'input'); input.dataset.key = control.key;
if (control.type === 'select') control.options.forEach(option => { const item = document.createElement('option'); item.value = option[0]; item.textContent = option[1]; item.selected = option[0] === control.value; input.appendChild(item); });
else { input.type = 'range'; input.min = control.min; input.max = control.max; input.step = control.step || 1; input.value = control.value; }
input.addEventListener('input', updateValues); label.appendChild(input); return label;
}
function updateValues() { scenario.controls.forEach(control => { const input = controls.querySelector('[data-key="' + control.key + '"]'); document.querySelector('[data-value="' + control.key + '"]').textContent = control.type === 'select' ? input.options[input.selectedIndex].text : input.value + (control.suffix || ''); }); }
function readValues() { const values = {}; scenario.controls.forEach(control => { const input = controls.querySelector('[data-key="' + control.key + '"]'); values[control.key] = control.type === 'range' ? Number(input.value) : input.value; }); values.failure = failure.checked; return values; }
function stage(name, state, detail) { return { name, state, detail }; }
const aiStage = stage;
function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, value)); }
function simulate(values) { const fail = values.failure;
/** 当前角色可访问的样例文档数量。 */
const visible = values.role === 'finance' ? 12 : values.role === 'employee' ? 8 : 3;
/** ACL 和缓存键不完整造成的泄漏数。 */
const leaks = values.filterStage !== 'before' || values.cacheKey !== 'full' || fail ? (values.role === 'guest' ? 5 : 2) : 0;
return { metrics: [[visible, '合法文档'], [leaks, '泄漏候选'], [values.filterStage.toUpperCase(), 'ACL 时机'], [leaks ? 'DENY' : 'ALLOW', '回答决策']], stages: [aiStage('解析身份', 'ok', values.role), aiStage('生成 ACL 摘要', fail ? 'fail' : 'ok', 'tenant + groups'), aiStage('召回过滤', values.filterStage === 'before' ? 'ok' : 'fail', values.filterStage), aiStage('缓存键', values.cacheKey === 'full' ? 'ok' : 'fail', values.cacheKey), aiStage('引用鉴权', leaks ? 'fail' : 'ok', leaks ? 'blocked' : 'pass')], rows: [['跨租户', values.filterStage === 'before' ? '向量与关键词查询均带 tenant_id' : '候选先进入内存,存在日志与缓存泄漏'], ['缓存隔离', values.cacheKey === 'full' ? '包含 tenant、权限摘要和知识库版本' : '不同权限用户可能复用同一答案'], ['引用接口', leaks ? '二次鉴权阻断返回,记录安全事件' : '下载或预览前再次校验文档权限']], diagnosis: leaks ? '检测到跨权限候选。系统必须拒答并修复召回、缓存和引用三层边界。' : '权限在检索前、缓存键和引用接口三处闭环。', danger: leaks > 0 };
}
function render() { const result = simulate(readValues()); document.querySelector('#metrics').innerHTML = result.metrics.map(item => '<div class="metric"><b>' + item[0] + '</b><span>' + item[1] + '</span></div>').join(''); document.querySelector('#stages').innerHTML = result.stages.map(item => '<div class="stage ' + item.state + '"><b>' + item.name + '</b><div>' + item.detail + '</div></div>').join(''); document.querySelector('#rows').innerHTML = result.rows.map(item => '<tr><td>' + item[0] + '</td><td>' + item[1] + '</td></tr>').join(''); const diagnosis = document.querySelector('#diagnosis'); diagnosis.textContent = result.diagnosis; diagnosis.className = 'diagnosis' + (result.danger ? ' danger' : ''); }
scenario.controls.forEach(control => controls.appendChild(renderControl(control))); updateValues(); document.querySelector('#run').addEventListener('click', render); render();
</script>
</body>
</html>
学完自测
选择所有正确答案;提交后逐项核对判断依据。