项目5 · 带引用的本地知识库
从导入、切分、检索到带引用的回答,搭一个不编造资料的本地知识库。
- 设计资料集、文档、片段、回答四层数据模型,并用 node:sqlite 持久化
- 处理 txt/md 导入:10MB 限制、安全文件名、BOM/CRLF 归一化与 pending/indexed/failed 状态机
- 按 Markdown 标题分节,再用 800 字窗口加 100 字重叠切分,并记录片段在原文中的字符偏移
- 实现 FTS5 关键词检索与中文 LIKE 兜底,用统一的 searchFragments 接口返回可引用片段
- 把检索片段编号注入 system prompt,调用 OpenAI 兼容接口;未配置 key 时返回基于片段的 mock 答案
- 用 citations 把回答关联到具体片段,支持点开原文并按偏移高亮定位
- 无命中时明确回答「资料不足」,不调用模型编造答案或来源
- 完成删除级联、固定评估问题集与自动检查,保证 solution 构建和测试全部通过
- 01资料集管理:创建、切换、删除与文档计数
- 02文档导入:拖拽或选择 txt/md、10MB 校验、安全文件名、重复导入提示
- 03索引状态:pending / indexed / failed、失败原因展示、失败文档重建索引
- 04检索:FTS5 MATCH + bm25 排序、中文 LIKE 兜底、按资料集过滤、top-k 与片段预览
- 05问答:问题输入、mock / 真实模型、检索片段注入 system、错误提示
- 06引用面板:编号引用、片段摘录、点开原文弹层与高亮
- 07资料不足:无命中时固定提示与空引用,不生成编造内容
- 08API 与持久化:collections / documents / chunks / answers / answer_citations 与 FTS5 索引表
资料集可创建、切换、删除并显示文档数;导入支持 .txt / .md / .markdown,单文件超过 10MB 被拒绝;服务端生成安全文件名落盘,写入 documents 并置为 pending。
文档状态按 pending → indexed / failed 流转,失败时保留可读错误原因;失败文档可点重建索引,成功后 chunk_count 与 indexed_at 更新,错误清空。
按 Markdown 标题(# 到 ######)分节,节内超过 800 字按 800 字窗口、100 字重叠切分;每个片段记录 heading、order_index、char_start、char_end,BOM 与 CRLF 先归一化。
searchFragments 统一返回 chunkId / documentId / heading / text / score / matchMode;优先查 fts_chunk MATCH 并用 bm25 排序,无命中或中文整词不匹配时退化为 LIKE 子串检索,支持资料集过滤与 limit。
POST /api/ask 先检索再把带编号的片段注入 system;未配置 AI_API_KEY 时返回基于片段的 mock 答案,配置后调用 {AI_BASE_URL}/chat/completions,模型名可由 AI_MODEL 指定。
回答返回 citations(marker / chunkId / documentId / quote / cited),引用面板逐条展示;点击引用打开文档原文,并按 chunk 的 char_start / char_end 高亮对应片段。
检索无命中时回答固定包含「资料不足」并说明没有找到可回答的资料,status 为 insufficient,引用为空;该分支不调用模型,不编造来源。
node:sqlite 建 collections / documents / chunks / answers / answer_citations 与 fts_chunk;删除文档级联清理 chunk、引用与 FTS 行;solution npm run build 通过,node tests/check.mjs 与单测全过,starter 保持缺口。
# 带引用的本地知识库(p5-knowledge-rag)
用 Next.js 16 App Router + React 19 + `node:sqlite` 做一个本地知识库:导入 txt / Markdown,按标题和窗口切分,用 FTS5 + 中文 LIKE 兜底检索,回答问题并把每条结论关联到可点开的原文片段。
这个项目的重点不是「接一个模型聊天」,而是把「资料 → 片段 → 检索 → 有依据的回答 → 引用定位」连成一条可验证的链路,并且在资料不足时明确收口。
## 学习目标
- 设计 `collections` / `documents` / `chunks` / `answers` 四层数据模型,用 `node:sqlite` 持久化。
- 处理真实文件导入:10MB 限制、安全文件名、BOM/CRLF 归一化、`pending → indexed / failed` 状态机。
- 按 Markdown 标题分节,再用 800 字窗口 + 100 字重叠切分,保存 `char_start` / `char_end` 供引用定位。
- 实现统一的 `searchFragments`:FTS5 MATCH + bm25,中文整词不匹配时退化为 LIKE。
- 把检索片段编号注入 system prompt;没有 `AI_API_KEY` 时走基于片段的 mock,配置后调用 OpenAI 兼容接口。
- 用 citations 把回答关联到片段,点击引用打开原文并高亮。
- 检索无命中时返回「资料不足」,不调用模型、不编造来源。
- 删除文档时级联清理 chunk、引用与 FTS 行。
## 环境要求
- Node.js **24+**(`node:sqlite` 在此版本稳定,不需要安装数据库服务)
- npm 10+
- 不需要网络字体;离线也能启动和演示 mock 模式
## 快速开始
### 运行起点(starter)
```bash
cd starter
npm install --no-audit --no-fund
npm run dev
```
打开 `http://localhost:3000`。起点只有导入页静态壳,缺口写在 `starter/TODO.md` 和页面上的 `TODO m1`–`TODO m8`。
### 运行参考实现(solution)
```bash
cd solution
npm install --no-audit --no-fund
npm run seed # 可选:导入 fixtures/docs 下 3 篇示例资料
npm run dev
```
打开 `http://localhost:3000`,在「默认资料集」里提问即可。`npm run seed` 可以重复执行,已存在的同名文档会跳过。
一条命令版本:
```bash
cd solution && npm install --no-audit --no-fund && npm run seed && npm run dev
```
### AI_API_KEY 行为
| 环境 | 行为 |
| --- | --- |
| `AI_API_KEY` 为空(默认) | 本地 mock:直接摘录前三条检索片段,保留 `[n]` 编号,导入/检索/引用/原文定位全部可用 |
| `AI_API_KEY` 已配置 | 请求 `{AI_BASE_URL}/chat/completions`,把编号片段注入 system,返回模型回答 |
| 检索 0 条 | 不调用模型,固定返回「资料不足」,`status = insufficient`,引用为空 |
配置方式:
```bash
cp .env.example .env.local
# 编辑 .env.local,填入 AI_API_KEY / AI_BASE_URL / AI_MODEL
npm run dev
```
常用命令:
| 命令 | 作用 |
| --- | --- |
| `npm run dev` | 启动开发服务器 |
| `npm run build` | 生产构建 |
| `npm run start` | 启动生产服务器 |
| `npm run seed` | 导入 `../fixtures/docs` 下的示例资料 |
| `npm run typecheck` | TypeScript 检查 |
## 自动检查
在项目根目录执行:
```bash
node tests/check.mjs # 结构 + API 特征;solution 全过,starter 至少 8 项失败
node tests/unit-rag.test.mjs # 切分、BOM/CRLF、中文 LIKE、引用完整性、资料不足
```
期望结果:
- `check.mjs` 输出 `ALL CHECKS PASSED`;
- 单测输出 `ALL RAG TESTS PASSED (8)`;
- `solution` 的 `npm run build` 通过。
## 目录结构
```text
p5-knowledge-rag/
├── README.md
├── starter/ # 可启动的静态起点
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx # 导入页静态壳 + TODO
│ │ └── globals.css
│ ├── next.config.ts
│ ├── package.json
│ ├── TODO.md
│ └── tsconfig.json
├── solution/ # 完整参考实现
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx # 挂载客户端工作台
│ │ ├── globals.css
│ │ └── api/
│ │ ├── collections/route.ts # 资料集列表 / 新建 / 删除
│ │ ├── documents/route.ts # 文档列表 / 上传导入
│ │ ├── documents/[id]/route.ts # 详情(原文 + 片段)/ 删除
│ │ ├── documents/[id]/reindex/route.ts
│ │ ├── search/route.ts # searchFragments 调试接口
│ │ ├── ask/route.ts # 检索 + 生成 + 引用落库
│ │ ├── answers/route.ts # 最近问答
│ │ └── health/route.ts
│ ├── components/
│ │ └── knowledge-workbench.tsx # 资料集 / 导入 / 问答 / 引用面板 / 原文弹层
│ ├── lib/
│ │ ├── db.ts # node:sqlite 连接与数据目录
│ │ └── rag/
│ │ ├── schema.ts # 六表 + FTS5 + 外键级联
│ │ ├── text.ts # BOM/CRLF、安全文件名、800/100 切分
│ │ ├── search.ts # FTS5 + LIKE 兜底,统一 searchFragments
│ │ ├── citations.ts # 引用生成、标记、完整性校验
│ │ ├── mock.ts # 无 key 的片段式 mock
│ │ ├── ask.ts # system 注入 + OpenAI 兼容调用
│ │ ├── ingest.ts # pending → indexed / failed
│ │ ├── repo.ts # SQL 读写与级联删除
│ │ ├── serialize.ts # API 字段映射
│ │ └── types.ts # 共享类型
│ ├── scripts/seed.mjs # 导入示例资料
│ ├── .env.example
│ ├── next.config.ts
│ └── package.json
├── tests/
│ ├── check.mjs # 结构 / API 特征检查
│ └── unit-rag.test.mjs # 纯函数与临时 SQLite 单测
├── docs/
│ ├── api.md # 接口、状态机、错误格式
│ └── retrieval.md # 检索策略与固定评估集
└── fixtures/
├── docs/
│ ├── 01-import-and-index.md
│ ├── 02-chunking-and-search.md
│ └── 03-citations-and-answering.md
└── questions.json # 5 个评估问题
```
`node_modules/`、`.next/`、`data/`、`.env.local` 是运行产物,不提交。
## 实现要点
### 导入与状态机
上传接口先校验扩展名和 10MB 上限,再用 `safeStoredName` 生成只含安全字符的文件名,写入 `data/uploads`。文档入库时状态为 `pending`,同步解析成功后变成 `indexed`;空文件、读取失败或切分失败变成 `failed`,错误原因保留在 `documents.error`,可以点「重建索引」重试。
### 切分与偏移
`chunkText` 先 `normalizeText`(去 BOM、CRLF → LF),再按 `#` 到 `######` 分节。节内不超过 800 字整节成片;超过则按 800 字窗口、100 字重叠滑动。每个片段记录 `start` / `end`,入库为 `char_start` / `char_end`。
### 检索
`searchFragments(db, query, { collection, limit })` 统一返回片段。英文/数字查询先查 `fts_chunk MATCH`,用 `bm25` 排序;中文查询先试整串 LIKE,再按 2 字 bigram 覆盖率做模糊兜底,同时匹配正文和标题。中文连续词在 `unicode61` 下不会自动分词,所以中文问题通常走 LIKE 兜底,这是预期行为,不是 bug。`模型`、`数据`、`检索` 这类高频泛词不参与覆盖率打分,避免未覆盖的问题被无关片段带偏。
### 问答与引用
`/api/ask` 先检索 6 条片段。没有命中时直接返回「资料不足」,不调用模型。有命中时把片段按 `[1]…[n]` 编号写进 system prompt:
```text
[1] 文档《02-chunking-and-search.md》 / 小节:800 字窗口与 100 字重叠
节内正文不超过 800 字时……
```
模型回答后,`buildCitations` 生成引用,`markCited` 根据回答里的 `[n]` 标记引用是否被正文使用。引用面板展示文档名、小节和摘录,点击后请求文档详情,按 `char_start` / `char_end` 在原文里高亮片段。
### 删除级联
`documents` 删除时:
```text
documents
├─ chunks(ON DELETE CASCADE)
│ └─ answer_citations(ON DELETE CASCADE)
└─ answer_citations(ON DELETE CASCADE)
```
`fts_chunk` 不是外键表,删除文档或资料集时按 `document_id` / `collection_slug` 显式清理。
## 验收清单(m1–m8)
| 里程碑 | 验收 |
| --- | --- |
| m1 资料集与导入 | 资料集可创建/切换/删除;txt/md 上传成功;>10MB 被拒绝;安全文件名落盘 |
| m2 状态机 | 文档经历 pending → indexed / failed;失败原因可见;失败文档可重建索引 |
| m3 切分 | 标题分节正确;超过 800 字按 800/100 切;char_start/char_end 与原文一致 |
| m4 检索 | FTS5 命中英文;中文「切分窗口」走 LIKE 兜底;统一返回 SearchFragment |
| m5 问答 | 无 key 返回 mock;有 key 调用 OpenAI 兼容接口;片段编号进入 system |
| m6 引用 | 回答带 citations;引用面板可展开;点击引用打开原文并高亮 |
| m7 资料不足 | 无命中时回答包含「资料不足」,status=insufficient,引用为空 |
| m8 持久化与验收 | 删除文档级联清理 chunk/引用/FTS;`npm run build` 通过;`check.mjs` 与单测全过 |
逐题评估步骤见 `docs/retrieval.md`。
## 建议实现顺序
1. 先读 `docs/api.md` 和 `docs/retrieval.md`,把六张表和状态机画出来。
2. 补 `lib/rag/schema.ts` 与 `lib/db.ts`,用 `node tests/unit-rag.test.mjs` 验证切分和检索纯函数。
3. 做 `/api/documents` 与资料集侧栏,先跑通「导入 → 列表 → 状态」。
4. 补 `searchFragments` 和 `/api/search`,用中文问题确认 LIKE 兜底。
5. 做 `/api/ask` 的 mock 分支,再补引用与原文弹层。
6. 最后接真实模型、错误提示、删除级联和固定评估集。
collections(slug, name, description, created_at) 保存资料集;documents(id, collection_slug, name, stored_name, mime, size, status, error, chunk_count, created_at, indexed_at) 保存文档与索引状态,status 取 pending / indexed / failed,失败时 error 保留可读原因;chunks(id, document_id, collection_slug, order_index, heading, text, char_start, char_end) 保存切分片段及在归一化原文中的字符偏移;answers(id, question, answer, status, mode, model, collection_slug, created_at) 保存问答记录,status 取 answered / insufficient / error,mode 取 mock / model / none;answer_citations(answer_id, chunk_id, document_id, marker, quote, cited) 保存回答与片段的引用关系。documents 删除时级联删除 chunks 与 answer_citations,chunks 删除再级联引用;fts_chunk 为独立 FTS5 索引表,删除文档时同步清理对应行。