This commit is contained in:
2026-08-10 10:55:50 +08:00
parent 54de49a6de
commit 61d4f1be63
13 changed files with 781 additions and 1289 deletions
+86
View File
@@ -0,0 +1,86 @@
# CLAUDE.md
## 项目概览
rag-local 本地知识库:Go + GoFrame v2 + SQLite(modernc 驱动 + sqlite-vec + FTS5),Vue 3 前端(ui-src,生产构建后由 Go 托管),单端口 :8080。
## 目录结构与职责(硬性约束)
> **`biz/` 是泛化占位名,不是固定目录命名**。表格中 `biz/` 代表「业务模块目录」,各项目必须按自身业务命名替换(本项目为 `kb/`),禁止新项目照抄 `biz/`;`ui-src/` 同样为本项目前端目录名,其他项目按自身前端目录替换。
| 目录 | 职责 | 强约束 |
|---|---|---|
| common/ | 通用层:HTTP 服务与鉴权中间件、文件解析(parser + pdf/docx/html/text)、中文分词、向量 JSON、DAO 基类、查询缓存、协程池封装(pool.go) | 不得依赖业务模块包(仅 pool.go 依赖 `biz/consts` 取池默认值,既成事实);新增跨模块通用能力放这里 |
| biz/consts/ | 常量集中地:表名(table_name.go)、状态(status.go)、内容类型、默认参数与各协程池默认大小(consts.go) | 业务常量一律在此集中,禁止散落 magic number;新增池默认大小在此定义 |
| biz/model/ | entity(表结构,与 DAO 一一对应)、dto(请求/响应结构,`g.Meta` 内嵌定义路由)、domain(领域模型,如 RiskSummary / Citation) | entity 只做表映射,不带业务逻辑;dto 是 controller 与 HTTP 的唯一出入口 |
| biz/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 |
| biz/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 |
| biz/controller/ | 接口层:接收参数、调用 service、组装返回值 | 见「分层职责规范」;禁止调用 dao |
| ui-src/ | Vue 3 + Element Plus 前端 | 开发用 vite 代理,生产构建产物 `ui-src/dist` 由 Go 托管 |
| data/ workspace/ | 运行时数据:SQLite 三个库、上传/解析文件 | 不提交 git;删除即丢失数据,改动前先确认 |
## 分层职责规范(硬性要求)
严格分层 `controller → service → dao`,禁止跨层调用(controller 禁止直接调 dao)。
| 层 | 目录 | 职责 | 禁止 |
|---|---|---|---|
| controller | biz/controller | 接收参数(依赖 DTO `v` tag 自动校验)、调用 service、组装返回值 | 直接调用 dao;手写业务规则校验(库表依赖/跨字段,应下沉 service);文件 IO;状态流转;跨表数据组装 |
| service | biz/service | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao | 直接写 HTTP 响应(例外见下) |
| dao | biz/dao | 单表数据访问,每表一个文件 | 业务逻辑 |
**例外**:SSE 流式响应、HTML/文件导出等"直接写响应体"的场景由 controller 完成——这是"值返回"的流式形式,事件序列化、心跳属 HTTP 协议职责,保留在 controller。
## 分层文件对齐与代码模式(硬性要求)
- 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐;虚拟表(向量 vec0 / FTS5)不建独立分层文件,由主表 dao 统一管理
- **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词、向量序列化)平铺在 `common/`;业务编排(分块、检索、工作流)归入对应 service 文件
- entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射
- dao:单例 `var Xxx = &xxxDao{}`,`init()``CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk / DeleteByPk)
- controller:结构体名决定路由前缀(如 `dataset``/dataset`),接口定义在 dto(`g.Meta` 携带 path/method/summary)
- **接口只允许 GET / POST**:写操作传 JSON body(或 multipart),读操作走 query params;无 PUT/DELETE
- dao 查询缓存:查询用 `gdb.CacheOption`(TTL 来自配置),**写操作后必须清对应缓存**,否则出现"库里已改、查询还是旧值"
## 并发规范(grpool 协程池)
- **可并行的场景**:纯 IO 任务——读查询、LLM/Embedding 调用、文件读取。SQLite 写一律回主 goroutine 串行(无 WAL 时并发写会 `database is locked`,锁定风险归零,并发只赢在 IO 等待上)
- **新增并行点的固定三处**:`common/pool.go` 加池变量(grpool 封装)→ `biz/consts` 加默认大小 → `config.yml``pool` 段加 `key: 并发度`(缺失或非法时回退默认值)
- 禁止直接用裸 `go` 启动并行工作负载,一律走 `common` 的池(池清单与默认值见 README 配置说明)
- **防死锁**:等待链单向「主 → A池 → B池」,被等待池的任务内不得再等待任何池(会饿死 worker);池无 Wait 方法,等待用调用方 `sync.WaitGroup`,任务结果经 buffered channel 回主 goroutine
- **裸 `go` 允许的例外**:`go func(){ wg.Wait(); close(ch) }()` 收尾惯用法、SSE 心跳、流式管道(Stream 读写)等长生命周期/非工作负载协程
## 文档职责(三文档体系)
| 文档 | 职责 | 何时补充/更新 |
|---|---|---|
| CLAUDE.md(本文件) | 公司通用开发规范:分层职责、代码模式、并发/事务/缓存约束、流程 | 规范变化时 |
| README.md | 项目功能介绍:架构、数据流、表清单、功能模块、API 清单、使用说明 | 功能增减时 |
| 技术设计.md | 实现细节与技术决策:DDL、检索参数、风险与备选方案 | 关键技术决策/参数变化时 |
## 开发流程(文档驱动,硬性要求)
永远以文档驱动开发:用户提出开发需求 → 先给出实现方案(技术选型、影响面、改动清单) → **用户确认后先补充文档再动手写代码**。补充哪个文档取决于内容性质:规范 → 本文件,功能 → README,实现细节/技术决策 → 技术设计.md。禁止未经确认直接开发,禁止先写代码后补文档。
## 数据访问规范(硬性要求)
- **事务**:涉及多张表的增删改操作必须包数据库事务,禁止逐表裸调用。事务放 dao 层方法内,service 层负责编排;`tx.Begin` 后必须用 `defer` 防护已提交后的二次 Rollback
- **禁止 N+1 查询**:禁止在循环中逐条查库。循环场景一律改为批处理——一次 `ListByXxx` 取回后按外键在内存分组
- **缓存一致性**:DAO 查询走缓存(TTL 来自 `database.cache.ttl`),写操作后必须清对应缓存
- **批处理 SQL**:批量写入用 `InsertAll` 类方法,批量删除用 `IN` 子句,禁止循环单条 INSERT/DELETE
- **配置即使用**:config.yml 中出现 redis / mq 等中间件配置时,代码必须实际接入使用,禁止"配置了但代码不用"或"代码写死但配置缺失"
## 运维部署规范(硬性要求)
- **部署形态**:Docker Compose 单机部署,前后端一体单端口(默认 8080);`data/` `workspace/` 数据目录挂载持久化,容器重建不丢数据。快速开始见 README,部署文件见项目根目录 `Dockerfile` / `docker-compose.yml`
- **数据即文件**:SQLite 文件型存储,备份 = 打包 `data/`(3 个 db,小而关键)+ `workspace/`(上传源文件,大而可重建)两个目录;迁移 = 拷贝到新机器即可
- **运行时数据与代码分离**:`data/` `workspace/` 不提交 git;**删除即丢数据,改动前先确认**
- **配置即文件**:`config.yml` 为唯一配置入口(监听端口、上传上限、协程池并发度等),环境变量覆盖无效
- **访问令牌**:每次启动重新生成并打印在启动日志(`docker compose logs` 查看),重启即换新,旧 JWT 全部失效
## 约定
- controller 方法签名固定为 `(ctx, *dto.XxxReq) (*dto.XxxRes, error)`,实例注册模式 `var Xxx = &xxx{}`
- **参数校验优先用 GoFrame DTO 校验**:请求结构体用 `v` tag(required / regex / in 等)声明,框架自动校验并返回错误,controller 不手写校验;仅 DTO 表达不了的业务规则(跨字段依赖、查库校验如重名、取值范围依赖配置)放 service。JSON 格式解析可留在 controller 或下沉 service,但须保持与调用点一致
- 响应组装(实体 → DTO 字段映射)在 controller 进行
- service 方法签名 ctx 开头,错误统一用 `gerror`
- 编译验证:`go build ./...`
+162 -25
View File
@@ -1,14 +1,16 @@
# rag-local 本地知识库
# rag-local 本地知识库
纯本地的 RAG(检索增强生成)知识库系统,基于 SQLite 全栈文件型存储,无外部数据库依赖。
纯本地的 RAG(检索增强生成)知识库系统,基于 SQLite 全栈文件型存储,无外部数据库依赖。面向 NAS / 小主机等边缘设备单机部署。
## 功能
- **文档流水线**:上传 txt / md / pdf / docx / html → 自动解析 → 标题感知分块 → 向量化 + 全文索引
- **混合检索**sqlite-vec 向量 KNN + FTS5 全文 BM25RRF 融合排序,中文 gse 分词
- **文档流水线**:上传 txt / md / pdf / docx / html → 自动解析 → 四阶段分块(结构识别 → 标题感知 → 语义 → 递归兜底)→ 向量化 + 全文索引,6 态解析状态机全程可见
- **混合检索**sqlite-vec 向量 KNN + FTS5 全文 BM25RRF 融合排序 + LLM 二次重排,中文 gse 分词
- **RAG 问答**:SSE 流式对话,检索引用(含来源与得分)随回答展示,会话历史持久化
- **知识图谱**:解析时 LLM 抽取实体与关系,问答时实体链接 + 一跳邻居注入提示词,图谱页可视化实体/关系
- **单用户门禁**:启动生成访问令牌,登录后 JWT 鉴权
- **知识图谱**:解析时 LLM 抽取实体与关系,问答时实体链接 + 一跳邻居注入提示词,图谱页力导向图可视化
- **合同法律条款标注**:上传合同自动按条款切分,逐条款标注对应法条(0-10 分 + 理由),可导出标注版 HTML(打印/另存 PDF)
- **模型可配置**:对话 / 向量模型均为 OpenAI 兼容 API,可对接 Ollama、DeepSeek、硅基流动、OpenAI 等任意供应商
- **单用户门禁**:启动生成访问令牌(重启即换新),登录后 JWT 鉴权
## 技术栈
@@ -20,14 +22,124 @@
| 前端 | Vue 3 + Element Plus + Vite(前后端不分离,单端口) |
| 部署 | Docker Compose 一键启动 |
## 总体架构
```
┌─────────────────────────── 浏览器 ───────────────────────────┐
│ http://host:8080 │
└──────────────────────────────┬───────────────────────────────┘
│ 统一端口(SPA 静态资源 + REST API + SSE 流式)
┌──────────────────────────────▼───────────────────────────────┐
│ GoFrame HTTP Server (:8080) │
│ ┌───────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ 静态资源 │ │ API 路由 │ │ 访问令牌鉴权 / CORS / │ │
│ │ ui-src/dist│ │ /dataset │ │ panic 恢复 中间件 │ │
│ └───────────┘ │ /document ... │ └────────────────────────┘ │
│ └──────┬───────┘ │
│ ┌──────────────┼──────────────┐ │
│ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼──────┐ │
│ │ controller │→│ service │→│ dao │ │
│ └────────────┘ └──────┬─────┘ └──────┬──────┘ │
│ │ │ │
│ ┌─────────▼──────┐ ┌────▼─────────────────────┐ │
│ │ 业务编排 │ │ SQLite × 3(文件型) │ │
│ │ RAG 问答 │ │ business.db: 业务表+向量+ │ │
│ │ 文档解析流水线 │ │ FTS(vec0 + fts5 │ │
│ │ 知识图谱抽取 │ │ system.db: 全局配置/模型 │ │
│ │ 合同条款标注 │ │ chat.db: 会话/消息 │ │
│ └───────┬───────┘ └───────────────────────────┘ │
│ │ HTTPOpenAI 兼容 API
└──────────────────────┼────────────────────────────────────────┘
┌────────▼────────┐
│ 模型供应商(可配置)│
│ Ollama/OpenAI/ │
│ DeepSeek/硅基流动 │
└─────────────────┘
```
三条数据链路(各自任务轮询驱动):
```
① 语料入库:上传文档 → 解析 → 分块 → Embedding 向量化 + 关键词索引
chunk 表 + vec0 向量 + fts5 全文同库同事务)
② 问答:问题 → 混合检索(向量 + 关键词 + RRF 融合 + LLM 重排)
(+ 知识图谱图增强)→ 流式回答 → SSE(引用 [编号] 定位到分块)
③ 合同标注:上传合同 → 按条款切分 → 逐条款多数据集召回(向量+FTS,宁滥毋缺)
→ LLM 判定(0-10 分 + 理由)→ 标注落库 → 进度递增 → 导出标注版 HTML
```
## 数据存储
三个 SQLite 文件各司其职:
| 库 | 文件 | 内容 |
|---|---|---|
| business | `data/business.db` | 数据集/文档/分块/向量/全文/解析任务/知识图谱 |
| system | `data/system.db` | 系统配置(令牌、默认模型)与模型配置 |
| business | `data/business.db` | 数据集文档分块向量vec0)、全文索引(FTS5)、解析任务知识图谱、合同标注 |
| system | `data/system.db` | 全局设置(app_config)与模型配置 |
| chat | `data/chat.db` | 会话与消息 |
表清单(13 张实体表 + 2 张虚拟表):
| 表名 | 库 | 类型 | 说明 |
|---|---|---|---|
| `app_config` | system | 实体 | 全局键值配置(分块默认参数) |
| `model_config` | system | 实体 | 模型配置(chat / embeddingis_default 标记默认) |
| `kb_dataset` | business | 实体 | 数据集(绑定向量模型、分块参数) |
| `kb_document` | business | 实体 | 文档(6 态状态机) |
| `kb_chunk` | business | 实体 | 分块 |
| `kb_chunk_vec` | business | 虚拟表 vec0 | 分块向量(与 kb_chunk 1:1 |
| `kb_chunk_fts` | business | 虚拟表 FTS5 | 分块全文索引(存 gse 分词) |
| `kb_parse_task` | business | 实体 | 文档解析/向量化任务(parse / reembed |
| `kg_entity` | business | 实体 | 知识图谱实体(按 dataset_id+name 去重) |
| `kg_relation` | business | 实体 | 知识图谱关系(head/relation/tail 文本快照) |
| `kb_contract_task` | business | 实体 | 合同标注任务 |
| `kb_contract_clause` | business | 实体 | 合同条款(断点续跑粒度) |
| `kb_contract_mark` | business | 实体 | 标注结果(法条快照 + 理由 + 0-10 分) |
| `chat_conversation` | chat | 实体 | 问答会话 |
| `chat_message` | chat | 实体 | 问答消息(citations 存引用 JSON |
## 功能模块
### 文档解析流水线
上传后由 `StartParsePoller` 轮询驱动(5 秒间隔,单 goroutine 串行),文档状态 6 态流转:`待处理 → 解析中 → 向量生成中 → 图谱构建中 → 已完成 / 失败`
1. 按扩展名分发解析器(txt/md 直读、pdf、docx、html 均为纯 Go 解析)
2. 四阶段组合分块(SplitAuto):结构识别(条文/章节等 8 种单元模式,命中自动采用)→ 标题感知 → 语义分块 → 递归兜底
3. 批量 Embedding(每批 16 条)→ chunk + vec0 向量 + fts5 全文同事务写入
4. 知识图谱 LLM 抽取(逐 chunk 并发),**失败不阻断**:文档仍置已完成,提示「知识图谱未构建」原因
失败可重试;重跑前先清旧索引(chunk+vec+fts+kg 单事务),幂等重建。
### 混合检索与 RAG 问答
- **混合检索**HybridRetriever):向量 KNNvec0,L2 距离,预取后按数据集过滤)+ 关键词检索(FTS5 BM25,查询端 gse 分词后「引号词组 OR」连接避免空召回)→ RRF 融合(`Σ 1/(60+rank)`)→ LLM 一次调用重排(0-10 分),低于门槛 `max(最高分×50%, 6)` 剔除,取 Top 5
- **问答工作流**:检索与知识图谱图增强并行执行 → 组装引用编号 [1][2] 与提示词 → ChatModel 流式生成
- **SSE 事件顺序**`citations`(引用列表 + conversation_id)→ `delta`(增量文本)→ `done`;异常推 `error`15 秒心跳(`: ping`)保持长连接
- 引用编号与提示词中资料编号一一对应,随助手消息以 JSON 落库
### 知识图谱
- **构建**:解析流水线向量化之后,逐 chunk 调用 LLM 抽取 `{entities, relations}` JSON;实体按 `(dataset_id, name)` 去重(UNIQUE + upsert),关系带来源 chunk_id
- **图增强检索**:问题分词后与实体名匹配取 Top 3 → 一跳邻居(上限 20 条三元组)→ 以「【知识图谱】张明 -任职于-> XX科技」形式注入提示词,增强关系类问题回答
- 图谱页以 eCharts 力导向图可视化(节点大小随出入度,点击高亮一跳邻居)
### 合同法律条款标注
- 上传合同(txt/md/pdf/docx/html)时多选法律语料数据集;`StartAnnotationPoller` 轮询消费
- 条款切分:探测 `第X条` / 数字编号 / 中文数字三种行首模式,命中则按条款切分,否则整篇单条
- 逐条款(并发):每个数据集召回(向量 15 条 + FTS 15 条)→ RRF 融合截 60 候选 → LLM 一次调用判定(0-10 分 + 理由)
- **宁滥毋缺**:与问答检索相反,不做 topK 截断与门槛过滤,全部候选(含 0 分)保留——漏标比多标严重
- 断点续跑:按条款粒度续跑,重启不重复标注;单条款失败不影响任务完成
- 导出标注版 HTML(自包含,score 分级着色,可打印/另存 PDF)
### 鉴权(单用户门禁)
- **访问令牌每次启动重新生成**(32 位 hex),仅存内存、不落库,打印在启动日志;**重启即换新令牌**,旧 JWT 全部失效
- 登录:输入令牌 → 校验 → 签发 JWT(HS25624h 有效,携带令牌 SHA-256 指纹)
- 鉴权中间件比对 JWT 指纹与内存令牌指纹,不一致即 401(前端自动跳登录页)
- 公开路径白名单:`/system-config/login`、SPA 静态资源、`GET /workspace/*`(仅路径穿越防护)
## 快速开始(Docker
```bash
@@ -41,7 +153,7 @@ docker compose up -d --build
2. **数据集** 页新建数据集,绑定向量模型(不绑定则仅全文检索)
3. 进入数据集上传文档,等待解析完成
4. **问答** 页选择知识库开始提问,回答可展开查看引用来源
5. **知识图谱** 页查看解析时抽取的实体与关系
5. **知识图谱** 页查看解析时抽取的实体与关系;**合同标注** 页上传合同按条款标注法条
数据保存在 `./data``./workspace`,删除容器不丢失。
@@ -49,12 +161,13 @@ docker compose up -d --build
```bash
# 后端(Go 1.26+
go get modernc.org/sqlite@v1.47.0 # 关键:向量扩展依赖(GoFrame 驱动链默认锁定过旧版本)
go run . # 监听 :8080,启动日志打印访问令牌
# 前端(开发热更新)
cd ui-src
npm install
npm run dev # Vite 开发服务器,API 代理见 vite.config.js
npm run dev # Vite 开发服务器5173API 代理见 vite.config.js
```
生产构建时前端产物在 `ui-src/dist`,由 Go 直接托管。
@@ -88,32 +201,56 @@ ollama pull nomic-embed-text
| `server.clientMaxBodySize` | 上传文件上限,默认 200MB |
| `vector.dim` | 向量维度,须与向量模型一致 |
| `database.cache.ttl` | DAO 查询缓存秒数 |
| `chat.timeout` / `chat.max_retries` | 对话模型 API 超时(秒)/ 失败重试次数 |
| `pool.*` | 各并行点协程池并发度(缺失或非法回退代码内默认值):`kg_extract`(4) 逐 chunk 图谱抽取、`annotation_clause`(4) 合同逐条款标注、`annotation_dataset`(8) 条款内逐数据集召回、`chat`(4) 问答检索与图增强并行、`chat_retrieve`(4) 检索向量与全文并行 |
## API 概览
所有接口除 `/system-config/login` 外均需 `Authorization: Bearer <JWT>`;接口只使用 GET / POST 两种方法。
| 分组 | 接口 |
|---|---|
| `/system-config` | 登录、设置读写、令牌查看/重新生成 |
| `/model-config` | 模型配置 CRUD、连通性测试 |
| `/dataset` | 数据集 CRUD |
| `/document` | 上传、列表、删除、重新向量 |
| `/chunk` | 分块列表、编辑(改后自动重向量化) |
| `/parse-task` | 解析任务列表、失败重试 |
| `/conversation` `/message` | 会话管理、消息列表、SSE 问答流(`/message/chat` |
| `/kg-entity` `/kg-relation` | 知识图谱实体/关系列表 |
| `/system-config` | `POST /login`(访问令牌登录)、`GET /settings``POST /save-settings`(分块默认值) |
| `/model-config` | `GET /list``POST /save``POST /delete``POST /set-default``POST /test`连通性测试 |
| `/dataset` | `GET /list``POST /save`(新建/编辑)、`POST /delete`(有文档拒绝) |
| `/document` | `POST /upload`multipart)、`GET /list``GET /detail``POST /delete``POST /reembed`(仅重算向量 |
| `/chunk` | `GET /list`(分页 + 关键词搜索)、`POST /update`(改后自动重向量化) |
| `/parse-task` | `GET /list``POST /retry`(重试失败任务) |
| `/kg-entity` `/kg-relation` | `GET /list`(按数据集过滤 + 分页 |
| `/contract` | `POST /upload`multipart + dataset_ids)、`GET /list``GET /detail``GET /summary`(风险汇总报告)、`GET /annotated`(导出标注版 HTML)、`POST /delete` |
| `/conversation` `/message` | `POST /save``GET /list``POST /delete`(级联删消息);`GET /message/list``POST /message/chat`SSE 流式问答) |
| `/workspace/*` | 源文件访问(静态服务,仅路径穿越防护) |
所有接口除 `/system-config/login` 外均需 `Authorization: Bearer <JWT>`
## 前端页面
| 页面 | 功能 |
|---|---|
| 登录页 | 输入访问令牌登录,无注册入口 |
| 数据集列表 | 新建/编辑/删除(绑定向量模型必选、分块参数带出全局默认);变更模型/分块 → 确认弹窗提示重新处理 |
| 数据集详情 | 文档上传(拖拽)、6 态解析状态/分块数/失败重试/「图谱未构建」提示、分块预览与编辑 |
| 图谱页 | 实体/关系列表 + eCharts 力导向图 |
| 合同标注页 | 选语料数据集 → 上传合同 → 任务进度(3s 轮询)→ 条款-标注对照抽屉 → 导出标注版 HTML |
| 问答页 | 会话列表 + 知识库下拉,SSE 流式渲染,引用折叠面板 |
| 设置页 | 分块默认值 + 模型配置 CRUDchat/embedding 两个 tab)、连通性测试、设置默认 |
## 项目结构
```
common/ 通用层:HTTP 服务/鉴权/文件解析/中文分词/向量 JSON
common/ 通用层:HTTP 服务/鉴权/文件解析/中文分词/向量 JSON/协程池
kb/
consts/ 表名、状态、常量
model/ entity / dto / domain
dao/ 数据访问(每表一个文件)
service/ 业务逻辑(每表一个文件 + chat_service 问答编排)
consts/ 表名、状态、默认参数与协程池默认大小
model/ entity(表结构)/ dtoReq/Res + 路由)/ domain(领域模型)
dao/ 数据访问(每表一个文件chunk_dao 兼管 vec0/fts5 虚拟表
service/ 业务逻辑(每表一个文件 + chat_service 问答编排 + annotation_service 合同标注
controller/ 接口层(每表一个文件)
ui-src/ Vue 3 前端
docs/ 实现方案文档
data/ SQLite 三个库(gitignore
workspace/ 上传的文档源文件(gitignore)
技术设计.md 实现细节/技术决策文档(版本要点、表结构约定、检索参数、风险与备选方案)
```
## 文档说明
- **README.md**:项目功能介绍(本文件)
- **CLAUDE.md**:通用开发规范(分层职责、代码模式、事务/并发/缓存约束、文档驱动开发流程)
- **技术设计.md**:实现细节与技术决策(DDL、检索参数、风险与备选方案等)
-1123
View File
File diff suppressed because it is too large Load Diff
+3 -87
View File
@@ -2,18 +2,8 @@ package controller
import (
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"rag-local/common"
"rag-local/kb/dao"
"rag-local/kb/model/dto"
"rag-local/kb/model/entity"
"rag-local/kb/service"
"github.com/gogf/gf/v2/errors/gerror"
@@ -28,56 +18,10 @@ func (c *contract) Upload(ctx context.Context, req *dto.UploadContractReq) (*dto
if req.File == nil {
return nil, gerror.New("请选择合同文件")
}
var dsIds []int64
if req.DatasetIds != "" {
if err := json.Unmarshal([]byte(req.DatasetIds), &dsIds); err != nil {
return nil, gerror.New("dataset_ids 格式错误,应为 JSON 数组")
}
}
if len(dsIds) == 0 {
return nil, gerror.New("请至少选择一个法律语料数据集")
}
f, err := req.File.Open()
id, err := service.AnnotationService.Upload(ctx, req.File, req.DatasetIds)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, gerror.New("文件内容为空")
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(req.File.Filename)), ".")
supported := false
for _, e := range common.SupportedExts() {
if e == ext {
supported = true
break
}
}
if !supported {
return nil, gerror.New("不支持的文件类型,仅支持 txt/md/pdf/docx/doc/html")
}
relDir := filepath.Join("contract", time.Now().Format("20060102"))
relPath := filepath.Join(relDir, common.RandomToken(16)+"."+ext)
absPath := filepath.Join("workspace", relPath)
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(absPath, data, 0o644); err != nil {
return nil, err
}
ids := make([]string, 0, len(dsIds))
for _, id := range dsIds {
ids = append(ids, strconv.FormatInt(id, 10))
}
id, err := dao.ContractTask.Insert(ctx, req.File.Filename, relPath, strings.Join(ids, ","))
if err != nil {
_ = os.Remove(absPath)
return nil, err
}
return &dto.UploadContractRes{Id: id}, nil
}
@@ -95,43 +39,15 @@ func (c *contract) List(ctx context.Context, req *dto.ListContractTaskReq) (*dto
}
func (c *contract) Detail(ctx context.Context, req *dto.GetContractDetailReq) (*dto.GetContractDetailRes, error) {
task, err := dao.ContractTask.GetOne(ctx, req.Id)
task, clauses, marks, risks, err := service.AnnotationService.Detail(ctx, req.Id)
if err != nil {
return nil, err
}
if task == nil {
return nil, gerror.New("任务不存在")
}
clauses, err := dao.ContractClause.ListByTask(ctx, req.Id)
if err != nil {
return nil, err
}
marks := make(map[int64][]*entity.ContractMark)
risks := make(map[int64][]*entity.ContractRisk)
for _, cl := range clauses {
list, err := dao.ContractMark.ListByClause(ctx, cl.Id)
if err != nil {
return nil, err
}
marks[cl.Id] = list
riskList, err := dao.ContractRisk.ListByClause(ctx, cl.Id)
if err != nil {
return nil, err
}
risks[cl.Id] = riskList
}
return &dto.GetContractDetailRes{Task: task, Clauses: clauses, Marks: marks, Risks: risks}, nil
}
func (c *contract) Summary(ctx context.Context, req *dto.SummaryContractReq) (*dto.SummaryContractRes, error) {
task, err := dao.ContractTask.GetOne(ctx, req.Id)
if err != nil {
return nil, err
}
if task == nil {
return nil, gerror.New("任务不存在")
}
sum, err := service.AnnotationService.Summary(ctx, req.Id)
task, sum, err := service.AnnotationService.Summary(ctx, req.Id)
if err != nil {
return nil, err
}
-1
View File
@@ -33,7 +33,6 @@ func (c *dataset) Save(ctx context.Context, req *dto.SaveDatasetReq) (*dto.SaveD
FtsTopK: req.FtsTopK,
RerankTopK: req.RerankTopK,
RecallTopK: req.RecallTopK,
Status: 1,
})
if err != nil {
return nil, err
+1 -16
View File
@@ -2,12 +2,9 @@ package controller
import (
"context"
"io"
"rag-local/kb/model/dto"
"rag-local/kb/service"
"github.com/gogf/gf/v2/errors/gerror"
)
type document struct{}
@@ -15,19 +12,7 @@ type document struct{}
var Document = &document{}
func (c *document) Upload(ctx context.Context, req *dto.UploadDocumentReq) (*dto.UploadDocumentRes, error) {
if req.File == nil {
return nil, gerror.New("请选择文件")
}
f, err := req.File.Open()
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
doc, err := service.DocumentService.Upload(ctx, req.DatasetId, req.File.Filename, data)
doc, err := service.DocumentService.Upload(ctx, req.DatasetId, req.File)
if err != nil {
return nil, err
}
-6
View File
@@ -81,9 +81,3 @@ func (d *contractClauseDao) UpdateStatus(ctx context.Context, id int64, status i
}).Where("id", id).Update()
return err
}
func (d *contractClauseDao) DeleteByTask(ctx context.Context, taskId int64) error {
_, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractClause).Ctx(ctx).
Where("task_id", taskId).Delete()
return err
}
+11 -7
View File
@@ -74,15 +74,19 @@ func (d *contractMarkDao) ListByClause(ctx context.Context, clauseId int64) ([]*
return list, err
}
func (d *contractMarkDao) ListByTask(ctx context.Context, taskId int64) ([]*entity.ContractMark, error) {
var list []*entity.ContractMark
err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractMark).Ctx(ctx).
Where("clause_id IN (SELECT id FROM "+consts.TableNameContractClause+" WHERE task_id = ?)", taskId).
OrderDesc("score").Scan(&list)
if list == nil {
list = make([]*entity.ContractMark, 0)
}
return list, err
}
func (d *contractMarkDao) DeleteByClause(ctx context.Context, clauseId int64) error {
_, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractMark).Ctx(ctx).
Where("clause_id", clauseId).Delete()
return err
}
func (d *contractMarkDao) DeleteByTask(ctx context.Context, taskId int64) error {
_, err := g.DB(consts.DbGroupDefault).Exec(ctx,
"DELETE FROM "+consts.TableNameContractMark+" WHERE clause_id IN (SELECT id FROM "+consts.TableNameContractClause+" WHERE task_id = ?)",
taskId)
return err
}
-7
View File
@@ -86,10 +86,3 @@ func (d *contractRiskDao) DeleteByClause(ctx context.Context, clauseId int64) er
Where("clause_id", clauseId).Delete()
return err
}
func (d *contractRiskDao) DeleteByTask(ctx context.Context, taskId int64) error {
_, err := g.DB(consts.DbGroupDefault).Exec(ctx,
"DELETE FROM "+consts.TableNameContractRisk+" WHERE clause_id IN (SELECT id FROM "+consts.TableNameContractClause+" WHERE task_id = ?)",
taskId)
return err
}
+27
View File
@@ -123,3 +123,30 @@ func (d *contractTaskDao) Delete(ctx context.Context, id int64) error {
Where("id", id).Delete()
return err
}
// DeleteWithRelated 事务删除任务及其条款、标注、风险数据
func (d *contractTaskDao) DeleteWithRelated(ctx context.Context, id int64) error {
tx, err := g.DB(consts.DbGroupDefault).Begin(ctx)
if err != nil {
return err
}
// Commit 成功后 IsClosed 为 true,跳过 Rollback,避免对已提交事务回滚产生报错日志
defer func() {
if !tx.IsClosed() {
_ = tx.Rollback()
}
}()
for _, table := range []string{consts.TableNameContractMark, consts.TableNameContractRisk} {
if _, err := tx.Exec(
"DELETE FROM "+table+" WHERE clause_id IN (SELECT id FROM "+consts.TableNameContractClause+" WHERE task_id = ?)", id); err != nil {
return err
}
}
if _, err := tx.Model(consts.TableNameContractClause).Ctx(ctx).Where("task_id", id).Delete(); err != nil {
return err
}
if _, err := tx.Model(consts.TableNameContractTask).Ctx(ctx).Where("id", id).Delete(); err != nil {
return err
}
return tx.Commit()
}
+91 -16
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"html"
"io"
"os"
"path/filepath"
"regexp"
@@ -23,6 +24,7 @@ import (
"github.com/cloudwego/eino/schema"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtimer"
)
@@ -690,21 +692,21 @@ h1{font-size:20px;text-align:center;margin-bottom:4px}
}
// Summary 生成整份合同的风险汇总报告:按等级分组统计(规则聚合)+ LLM 整体评述
func (s *annotationService) Summary(ctx context.Context, taskId int64) (*domain.RiskSummary, error) {
func (s *annotationService) Summary(ctx context.Context, taskId int64) (*entity.ContractTask, *domain.RiskSummary, error) {
task, err := dao.ContractTask.GetOne(ctx, taskId)
if err != nil {
return nil, err
return nil, nil, err
}
if task == nil {
return nil, gerror.New("任务不存在")
return nil, nil, gerror.New("任务不存在")
}
clauses, err := dao.ContractClause.ListByTask(ctx, taskId)
if err != nil {
return nil, err
return nil, nil, err
}
risks, err := dao.ContractRisk.ListByTask(ctx, taskId)
if err != nil {
return nil, err
return nil, nil, err
}
clauseTitles := make(map[int64]entity.ContractClause, len(clauses))
for _, cl := range clauses {
@@ -752,7 +754,7 @@ func (s *annotationService) Summary(ctx context.Context, taskId int64) (*domain.
g.Log().Warningf(ctx, "risk summary overview failed (task %d): %v", taskId, err)
sum.Overview = ""
}
return sum, nil
return task, sum, nil
}
// fillOverview 用 LLM 生成整体风险评述(≤3 段:概况 / 重点高风险 / 建议),失败不阻断汇总
@@ -797,6 +799,87 @@ func (s *annotationService) fail(ctx context.Context, task *entity.ContractTask,
g.Log().Errorf(ctx, "annotation task %d failed: %s", task.Id, msg)
}
// Upload 保存合同文件到 workspace/contract/{yyyymmdd}/{token}.ext 并创建标注任务
func (s *annotationService) Upload(ctx context.Context, file *ghttp.UploadFile, datasetIdsJSON string) (int64, error) {
var dsIds []int64
if datasetIdsJSON != "" {
if err := json.Unmarshal([]byte(datasetIdsJSON), &dsIds); err != nil {
return 0, gerror.New("dataset_ids 格式错误,应为 JSON 数组")
}
}
if len(dsIds) == 0 {
return 0, gerror.New("请至少选择一个法律语料数据集")
}
f, err := file.Open()
if err != nil {
return 0, err
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(f)
if err != nil {
return 0, err
}
if len(data) == 0 {
return 0, gerror.New("文件内容为空")
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Filename)), ".")
if !isSupportedExt(ext) {
return 0, gerror.New("不支持的文件类型,仅支持 txt/md/pdf/docx/doc/html")
}
relDir := filepath.Join("contract", time.Now().Format("20060102"))
relPath := filepath.Join(relDir, common.RandomToken(16)+"."+ext)
absPath := filepath.Join("workspace", relPath)
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return 0, err
}
if err := os.WriteFile(absPath, data, 0o644); err != nil {
return 0, err
}
ids := make([]string, 0, len(dsIds))
for _, id := range dsIds {
ids = append(ids, strconv.FormatInt(id, 10))
}
id, err := dao.ContractTask.Insert(ctx, file.Filename, relPath, strings.Join(ids, ","))
if err != nil {
_ = os.Remove(absPath)
return 0, err
}
return id, nil
}
// Detail 任务详情:任务 + 条款 + 每条款的标注与风险
func (s *annotationService) Detail(ctx context.Context, taskId int64) (*entity.ContractTask, []*entity.ContractClause, map[int64][]*entity.ContractMark, map[int64][]*entity.ContractRisk, error) {
task, err := dao.ContractTask.GetOne(ctx, taskId)
if err != nil {
return nil, nil, nil, nil, err
}
if task == nil {
return nil, nil, nil, nil, gerror.New("任务不存在")
}
clauses, err := dao.ContractClause.ListByTask(ctx, taskId)
if err != nil {
return nil, nil, nil, nil, err
}
// 一次取回全部标注/风险后按条款分组,避免逐条款查询(N+1)
marks, err := dao.ContractMark.ListByTask(ctx, taskId)
if err != nil {
return nil, nil, nil, nil, err
}
risks, err := dao.ContractRisk.ListByTask(ctx, taskId)
if err != nil {
return nil, nil, nil, nil, err
}
markMap := make(map[int64][]*entity.ContractMark)
for _, m := range marks {
markMap[m.ClauseId] = append(markMap[m.ClauseId], m)
}
riskMap := make(map[int64][]*entity.ContractRisk)
for _, r := range risks {
riskMap[r.ClauseId] = append(riskMap[r.ClauseId], r)
}
return task, clauses, markMap, riskMap, nil
}
// List 任务列表
func (s *annotationService) List(ctx context.Context, page, pageSize int) ([]*entity.ContractTask, int, error) {
return dao.ContractTask.List(ctx, page, pageSize)
@@ -811,16 +894,8 @@ func (s *annotationService) Delete(ctx context.Context, id int64) error {
if task == nil {
return gerror.New("任务不存在")
}
if err := dao.ContractMark.DeleteByTask(ctx, id); err != nil {
return err
}
if err := dao.ContractRisk.DeleteByTask(ctx, id); err != nil {
return err
}
if err := dao.ContractClause.DeleteByTask(ctx, id); err != nil {
return err
}
if err := dao.ContractTask.Delete(ctx, id); err != nil {
// 关联数据删除在 dao 层事务内完成
if err := dao.ContractTask.DeleteWithRelated(ctx, id); err != nil {
return err
}
if task.FilePath != "" {
+16 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -16,6 +17,7 @@ import (
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
var DocumentService = &documentService{}
@@ -23,7 +25,20 @@ var DocumentService = &documentService{}
type documentService struct{}
// Upload 保存上传文件到 workspace/{datasetId}/{yyyymmdd}/{uuid}.ext,落库并提交解析任务
func (s *documentService) Upload(ctx context.Context, datasetId int64, filename string, data []byte) (*entity.Document, error) {
func (s *documentService) Upload(ctx context.Context, datasetId int64, file *ghttp.UploadFile) (*entity.Document, error) {
if file == nil {
return nil, gerror.New("请选择文件")
}
f, err := file.Open()
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
filename := file.Filename
dataset, err := dao.Dataset.GetOne(ctx, datasetId)
if err != nil {
return nil, err
+384
View File
@@ -0,0 +1,384 @@
# rag-local 本地知识库 技术设计
> 文档定位:项目功能与使用见 [README.md](README.md),通用开发规范见 [CLAUDE.md](CLAUDE.md)。
> 本文件只保留实现细节与技术决策(版本要点、表结构约定、检索参数、风险与备选方案等)。
## 1. 关键版本说明(向量扩展)
- sqlite-vec 需要 `modernc.org/sqlite >= v1.47.0`2026-03-17 起内置,免 CGO
- GoFrame v2.10.2 驱动链默认锁定 `modernc.org/sqlite v1.23.1`(过旧),**必须在 go.mod 中显式升级**
```bash
go get modernc.org/sqlite@v1.47.0
```
Go modules 最小版本选择(MVS)会使全链路统一使用 v1.47+glebarez 为薄封装,API 兼容。主程序只需:
```go
import _ "modernc.org/sqlite/vec" // 空导入,init 自动注册 vec0 扩展
```
## 2. 数据库设计(表结构与关键决策)
### 5.3 建表 DDL
表结构以代码为单一事实来源:各 `dao` 文件 `init()` 内 `CREATE TABLE IF NOT EXISTS`(含索引与迁移),字段映射见 `kb/model/entity/` 对应结构体,表清单见 README「数据存储」。本节仅记录建表约定:
- 表名前缀:`kb_`(数据集领域)、`chat_`(问答领域);`consts.TableNameXxx` 常量集中管理
- vec0 向量维度 = 模型配置 `dimension`(默认 1024),切换维度需清库重建(见 §14.4)
- FTS5 存 gse 分词后的 `content_tokens`,原文回表 `kb_chunk`(§5.4 决策 5);虚拟表影子表不可手动操作(§5.5)
### 5.4 关键设计决策
1. **向量与业务同库同事务**`kb_chunk_vec` 与 `kb_chunk` 同在 business.db。删除文档时,`chunk → vec → fts` 在一个事务内删除,无一致性问题。
2. **数据库与源文件分离**`data/` 仅存 3 个 db 文件;上传源文件存 `workspace/`。备份 = 两个目录分别打包(db 小而关键、workspace 大而可重建),清理与迁移互不影响。
3. **向量维度跟随模型**`model_config.dimension` 决定 vec0 建表维度;数据集绑定 embedding 配置(`kb_dataset.embedding_cfg_id`,**保存与解析均强制校验,未绑定不可保存/任务失败**)。切换 embedding 模型时前端确认弹窗提示,触发 `reembed` 任务(**仅重算全部向量**,不重新解析;分块参数变更才触发全量 `parse` 重新分块)。
4. **中文分词在应用层**SQLite 内置 tokenizer 无法正确切分中文。写入 FTS5 时用 gse 分词后以空格连接存入 `content_tokens`;查询时对 query 同样分词。备选:trigram tokenizer(召回差但零依赖)。
5. **FTS5 表自包含**:只存 `chunk_id + dataset_id + title + content_tokens`,原文在 `kb_chunk`,命中后回表 join 取原文与元数据,避免 FTS 表膨胀。
6. **解析任务用表驱动**`kb_parse_task` 轮询模式,单 goroutine 串行消费,避免 SQLite 并发写冲突。
7. **SQLite 并发**:3 个库文件各自独立连接;写操作集中在任务轮询 goroutine 与用户操作。**当前未启用 WAL / busy_timeout**(代码无 PRAGMA),并发写 business.db 偶发 `database is locked (5)`,缓解措施与实际缺口见 §5.6 / §14.3。
8. **文档状态机 6 态**:解析 → 向量生成 → 图谱构建分三段推进(`0 待处理/1 解析中/2 向量生成中/3 图谱构建中/4 已完成/5 失败`)。图谱抽取失败**不阻断**完成:文档仍置 4,`error_msg` 记录「知识图谱未构建」原因,前端以黄色标签提示(不再出现"显示已完成但图谱没建完"的假象)。
9. **合同标注宁滥毋缺**:标注业务的召回策略与问答相反——问答要精(topK=5 + 重排门槛 max(最高分×50%, 6)),标注宁滥毋缺(漏标比多标严重)。召回放宽(每数据集向量+FTS 各 15 条)、不做重排门槛、全部候选交 LLM 判定后保留(含 0 分),见 §7.8。
10. **轮询任务并发模型**`StartParsePoller` 与 `StartAnnotationPoller` 各自**gtimer 单例定时器串行**消费(5 秒间隔,job 未结束不重入),不并发处理多个任务,避免 SQLite 写冲突;任务粒度(kb_parse_task / kb_contract_task+ 子粒度(clause)断点续跑。任务内部热点(kg 逐 chunk 抽取、标注逐条款、多数据集召回、问答双路检索+图增强)用 **grpool 协程池并行化**,池大小 config.yml `pool` 段配置——并行段只做读查询与 LLM/Embedding 调用,SQLite 写全部收敛回主 goroutine 串行(见 §7.9)。
---
### 5.5 数据库表与源文件的强约束关系
系统有两类**文件资产**,数据库记录与磁盘文件严格一一对应(同生共死),任何删除动作必须同时清理两侧:
| 资产 | 记录表(file_path 列) | 磁盘约定 | 写入方 | 读取方 | 删除方 |
|---|---|---|---|---|---|
| 语料文档 | `kb_document.file_path` | `workspace/{datasetId}/{yyyymmdd}/{uuid16}.{ext}` | `DocumentService.Upload`(先落盘成功再插记录,插记录失败回滚删文件) | 解析流水线 `ParseFile`、`GET /workspace/*`(下载/预览) | `DocumentService.Delete` |
| 合同源文件 | `kb_contract_task.file_path` | `workspace/contract/{yyyymmdd}/{uuid16}.{ext}` | `contract_controller.Upload`(同上,插入失败回滚删文件) | 标注流水线 `AnnotationService.processOne` 的 `ParseFile` | `AnnotationService.Delete` |
**强约束规则**
1. **file_path 存相对路径**(不含 `workspace/` 前缀),统一经 `filepath.Join("workspace", FilePath)` 组装绝对路径读写,禁止散落的绝对路径拼接。
2. **文件名不可信**:上传文件一律 `RandomToken(16)` 随机重命名,数据库不存用户原始路径(`filename` 仅作展示名);按日期分子目录,天然按月/日归档。
3. **同生共死顺序**:新增 = 先写文件成功 → 再插记录(插失败删文件);删除 = 先删数据成功 → 再删文件(删文件失败仅记日志,孤儿文件可接受;反之删了文件留着记录会导致解析/导出直接报错)。
4. **删除一致性**
- 删文档:`DocumentService.Delete` 单事务内删 `kg 两表(按 chunk_id)→ chunk → vec → fts → kb_parse_task` → 删 `kb_document` → `os.Remove` 文件(chunk_dao.go 的 `DeleteByDocument` 单事务完成,**无 FTS5 optimize**
- 删合同任务:`AnnotationService.Delete` 删 `markjoin clause 定位)→ clause → task` → `os.Remove` 文件
5. **备份即两目录打包**`data/`3 个 db,小而关键)+ `workspace/`(大而可重建),二者独立迁移互不影响。
**虚拟表影子表约束**(vec0 / FTS5 专属,违反会损坏索引):
- `kb_chunk_vec_*`4 张)与 `kb_chunk_fts_*`(5 张)影子表由 SQLite 自动维护,**不可手动 DELETE/TRUNCATE**——实测手动清空 `kb_chunk_vec_chunks` 等会导致后续插入报 `Error opening vector blob at main.kb_chunk_vec_vector_chunks00.N`。
- 清空/重建虚拟表(含切换向量维度)唯一安全姿势:`DROP TABLE` + `CREATE VIRTUAL TABLE` 重建(工具:`/tmp/dbopt2` 维护程序)。
- FTS5 倒排段合并命令 `INSERT INTO kb_chunk_fts(kb_chunk_fts) VALUES('optimize')` **未挂进任何业务路径**(当前删除流程不做 optimize,由 SQLite 自动合并);如倒排段膨胀可在维护窗口手动执行。
### 5.6 数据关联关系与删除级联
**实体关系图**`--` 为强关联,`..` 为弱引用/快照):
```
kb_dataset --1:N-- kb_document --1:N-- kb_chunk --1:1-- kb_chunk_vec (vec0, chunk_id 主键)
│ │ │ │ --1:1-- kb_chunk_fts (FTS5, chunk_id 唯一)
│ │ └--1:N-- kb_parse_task (document_id)
│ │ └--1:N-- kg_entity (chunk_id 来源, (dataset_id,name) 去重 upsert)
│ │ └--1:N-- kg_relation (chunk_id 来源, head/relation/tail 文本快照)
│ └.. 弱引用: kb_contract_mark.chunk_id(标注命中法条分块)
├--1:N-- kg_entity / kg_relation(按 dataset_id 归属)
├.. 弱引用: kb_contract_task.dataset_ids(逗号分隔字符串 "1,3,5",无外键)
├.. 弱引用: kb_contract_mark.dataset_id(标注时快照 dataset 名到 law_title
├--1:N-- kb_contract_task --1:N-- kb_contract_clause (task_id) --1:N-- kb_contract_mark (clause_id)
├.. 弱引用: kb_dataset.embedding_cfg_id → model_config.id(绑定 embedding 配置)
└.. 弱引用: chat_conversation.dataset_id(问答绑定的数据集,删除后成孤儿引用)
model_config: is_default 标记默认 chat/embedding 配置(GetDefault 取数)
chat_conversation --1:N-- chat_message(删会话级联删消息)
app_config: 无外联(全局键值)
```
**删除级联矩阵**(已实现行为,删除动作均落库失败回滚):
| 删除动作 | 级联行为 |
|---|---|
| 删数据集 | **存在文档记录(`kb_document` 按 dataset_id 计数 >0)时直接拒绝**`DatasetService.Delete` 校验,报「数据集下存在文档,无法删除」);空数据集删除仅删记录,**合同任务/标注保留**dataset_ids/mark 为快照,自洽) |
| 删文档 | 单事务删 kg_entity/kg_relation(按 chunk_id)、chunk+vec+fts、kb_parse_task 记录、源文件;**合同标注保留**(法条快照) |
| 删合同任务 | 删 mark+clause+task 记录 + 源文件 |
| 删会话 | 级联删全部 chat_message |
| 删模型配置 | 仅拦截 `kb_dataset.embedding_cfg_id` 绑定(「数据集仍在引用该向量模型」);**默认标记不拦截**——删除默认 chat 模型后问答组件构建失败(缺口见下) |
**已知弱引用/缺口清单**(数据关联审查结论,按影响排序):
1. `kb_contract_task.dataset_ids` 逗号字符串:无法外键约束,是刻意取舍(任务标注留存快照自洽);改进方向 = 关联表 `kb_contract_task_dataset`(未做)。
2. `chat_conversation.dataset_id`:数据集删除后成孤儿引用,问答时需容错(当前已跳过检索,未报错)。
3. `model_config` 删除校验不完整:已挡 `embedding_cfg_id` 绑定,但 `is_default`(chat 默认模型)未拦截——删除默认 chat 模型后问答组件构建失败,应补。
4. `kg` 无独立删除接口:`DeleteByDataset` 为死代码;数据集删除被「有文档拒绝」约束,实际 kg 清理只发生在删文档路径(`DocumentService.Delete` 与 `chunk_dao.DeleteByDocument` 各有一次 kg 删除,职责重叠)。
5. `DocumentService.Delete` 与 `chunk_dao.DeleteByDocument` 存在**双重 kg 清理**(审计确认两处都会删 kg_entity/kg_relation),重复执行幂等无副作用,重构时收敛到一处。
**SQLITE_BUSY 现状**:两个 poller + 用户操作并发写 business.db 偶发 `database is locked (5)`。当前实际缓解:任务轮询各自单 goroutine 串行消费 + 测试期顺序操作规避(未根治)。改进方向(未实现):`PRAGMA journal_mode=WAL` / `busy_timeout`(如 5000ms)可显著缓解,见 §14.3。
---
## 7. RAG 问答设计
> 本层不再单独建目录:分块/Indexer 归入 `chunk_service.go`Retriever/组件工厂/工作流归入 `chat_service.go`,分词归入 `common/tokenizer.go`。
### 7.1 组件清单
| 组件 | 实现 | 归属 |
|---|---|---|
| ChatModel | 自研 `OpenAIChatModel` | `chat_service.go`OpenAI 兼容 `/chat/completions`Generate/Stream),baseURL 可配,兼容 Ollama/DeepSeek/OpenAI 等 |
| Embedding | 自研 `OpenAIEmbedder` | `chat_service.go`OpenAI 兼容 `/embeddings`;按库绑定的 embedding 配置创建,模型必须与索引端一致 |
| Indexer | `ChunkService.InsertAll` | `chunk_service.go`:批量向量化 + 写入 chunk + vec0 + FTS5(解析流水线非图节点,不单独实现 eino Indexer 接口) |
| Retriever | **自研 `HybridRetriever`** | `chat_service.go`:实现 `eino.Retriever` 接口,vec0 向量检索 + FTS5 关键词检索 + RRF 融合 |
| Splitter | 自研 SplitAuto(四阶段组合) | `chunk_service.go`:结构识别 → 标题感知 → 语义(eino)→ 递归兜底(eino),见 §7.6 |
| Tokenizer | gse 中文分词 | `common/tokenizer.go`:写索引/检索共用 |
### 7.2 写入路径(`ChunkService.InsertAll`
```go
func (s *chunkService) InsertAll(ctx context.Context, datasetId, documentId int64,
chunks []string, embedder embedding.Embedder) error
```
流程:
1. **embedder 由调用方注入且解析路径强制非空**:`parse_task_service` 解析流水线中按数据集绑定的 embedding 配置构建 `OpenAIEmbedder`——`Save` 与解析任务双重校验 `embedding_cfg_id`,未绑定直接任务失败(「数据集未绑定向量模型」),不存在降级路径
2. 按 `EmbedBatchSize`16)批量调用 `Embedder.EmbedStrings` 生成向量
3. 逐 chunk 同一事务:`kb_chunk` 自增 id → `kb_chunk_vec(chunk_id, vec_f32(?))` → gse 分词后 `kb_chunk_fts(chunk_id, dataset_id, title, content_tokens)`
4. 末尾更新 `kb_document.chunk_count``status=2` 由解析流水线在分块完成时先行设置,见 §7.6)
> 解析流水线为轮询任务而非图节点,故不单独实现 eino Indexer 接口,`InsertAll` 承担等价职责。
### 7.3 自研 HybridRetriever(读取路径)
```go
type HybridRetriever struct {
datasetId int64
embedder embedding.Embedder
}
// 实现 eino Retriever 接口
func (h *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) ([]*schema.Document, error)
```
流程(实际常量:`VectorTopK=FtsTopK=20`、`RrfK=60`、`RerankTopK=10`、`HybridTopK=5`):
1. **向量检索**query → Embedding → `vec0` KNNL2 距离)预取 `topK*4` 后 join `kb_chunk` 按 dataset_id 过滤,取 20
2. **关键词检索**query → `TokenizeQuery` 分词(引号词组 OR 语义、过滤 <2 rune 与 FTS 特殊字符)→ FTS5 MATCHbm25 负分升序)取 20
3. **RRF 融合**`score = Σ 1/(60 + rank)` 全局融合,截 `RerankTopK=10`
4. **LLM 重排**`rerankByLLM` 一次调用为 10 个候选打 0-10 分(候选截 `RerankMaxChars=500` 字),**门槛 = max(最高分×`RerankKeepRatio=0.5`, `RerankMinScore=6`)**,低于门槛剔除
5. 按重排分降序截 `HybridTopK=5`,回表 `kb_chunk` 取原文与元数据,组装 `schema.Document` 与引用信息
6. 支持 `retriever.WithTopK` / `WithScoreThreshold` 选项
```go
// RRF 融合
type hit struct{ chunkId int64; ranks []int; score float64 }
score = Σ(1 / (60 + rank)) // RRF 分区间过窄无区分度,仅用于候选截断,最终排序以 LLM 重排分为准
```
### 7.4 问答工作流(chat_service.go 编排)
```
用户问题
┌─────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐
│ Hybrid │──▶│ Prompt │──▶│ ChatModel │──▶│ 流式输出 │
│ Retriever│ │ (上下文+问题) │ │ (OpenAI兼容)│ │ (SSE) │
└─────────┘ └──────────────┘ └────────────┘ └───────────┘
```
- `chat_service.go` 中直接编排 eino 组件:`ChatService.Ask` = HybridRetriever.Retrieve → 组装引用列表 + 系统提示词 → OpenAIChatModel.Stream 流式生成(组件已实现 eino 接口,可随时迁入 graph/chain 拓扑;eino v0.9.13 的 graph 节点类型约束与"中间取引用"需求不匹配,故直接编排)
- **并行化(§7.9**`Ask` 中 `retrieve`common.ChatPool)与 `GraphEnhance`(纯读)并行执行,汇合后再组装提示词;`HybridRetriever.Retrieve` 内向量段与 FTS 段(common.ChatRetrievePool)并行,RRF 融合、LLM 重排仍由主 goroutine 串行
- `message_service.go`:会话解析/创建、用户消息落库、历史消息组装(最近 10 轮)、助手消息 + citations JSON 落库
- Prompt 模板:
```
你是一个本地知识库助手。请仅根据以下资料回答用户问题;若资料不足以回答,请明确说明。
回答引用资料时,在对应位置标注 [编号]。
【资料】
[1] 内容...
[2] 内容...
```
- SSE 事件顺序:`event: citations`(先推,含引用列表与 conversation_id)→ `event: delta``{content}` 增量文本)→ `event: done`;异常推 `event: error`
- **15 秒心跳**controller 空闲时周期发送 `: ping` 注释行,防止代理/浏览器断开长连接;前端 `fetch + ReadableStream` 按 `\n\n` 分块、按 `data: ` 行解析,按字段(citations/content/status/message)识别事件,非 JSON 行(心跳)静默跳过
- 引用列表编号 [1][2] 与提示词中资料编号一一对应,随助手消息以 JSON 落库(chat_message.citations
### 7.5 中文分词
- `github.com/go-ego/gse`(纯 Go,无 CGO),`init()` 加载标准中文词典(`seg.LoadDict()`
- **写索引**`Tokenize`):`gse.Cut(text)` → 去空白 → 空格 join → `content_tokens`(不做停用词/单字过滤)
- **查询**`TokenizeQuery`):分词后过滤 FTS5 特殊字符(`"*:()`)与 <2 rune 单字,**每个词包引号(精确词组)以 ` OR ` 连接**——中文问题分词后与文档重合的词通常很少,AND 全命中会空召回,OR + BM25 排序更稳健
- 词典随 gse 库内置(`LoadDict` 自动加载),无需随镜像额外携带
### 7.6 文档解析流水线
```
POST /document/upload
│ 保存文件到 workspace/{datasetId}/{yyyymmdd}/{uuid16}.{ext}
│ 插入 kb_document(status=0) + kb_parse_task(status=0)
│ (上传不校验向量模型——数据集在 Save 时已强制绑定,见下)
StartParsePollermain.go 启动,gtimer 单例 5 秒轮询,job 未结束不重入)
│ 取 status=0 任务 → 置 status=1(解析中)
│ 1. 校验 embedding 配置 → 构建 OpenAIEmbedder(无配置 → 任务失败「数据集未绑定向量模型」;
│ 数据集 Save 强制绑定向量模型「数据集必须绑定向量模型,请先选择向量模型」,此处为防御性校验)
│ 2. 按扩展名分发解析器(txt/md 直接读;pdf 用 pdfcpu 抽取文本;
│ docx 解 zip 读 word/document.xml 提取段落;html 用 goquery 取 body 文本)
│ 3. 先 DeleteByDocument 清旧索引(chunk+vec+fts+kg 单事务,重解析幂等)
│ 4. SplitAuto 四阶段组合分块(document → status=2 向量生成中):
│ a. 结构识别:8 种单元模式 + 3 种上下文模式(条文/章节/编号等),命中达到阈值
│ 自动采用,unit_pattern/context_pattern 落库;命中则按结构切分(超长单元截断续标)
│ b. 标题感知:SplitText 按标题/段落切
│ c. 语义分块:eino semantic splitterMinChunkSize=chunkSize/2
│ d. 递归兜底:eino recursiveKeepTypeEnd
│ 5. 批量 Embedding → InsertAllchunk+vec0+FTS5 同事务,按 EmbedBatchSize=16 分批)
│ 6. 知识图谱抽取(document → status=3 图谱构建中;逐 chunk LLM 抽取并发执行
kg_extract 池,见 §7.9),失败不阻断,error_msg 记录「知识图谱未构建」原因,
│ 前端黄色标签提示)
│ 7. 完成:更新 document.status=4(已完成)、chunk_count;任务 status=2
│ 失败 → document.status=5 + error_msg,前端可重试
```
### 7.7 知识图谱(LLM 抽取 + 图增强检索)
面向"数据集整体关系"类问题(如"谁与谁合作过""公司有哪些产品线"),在向量/关键词检索之外补充图谱能力。图谱**不是替代 RAG**,而是为问答注入结构化关系上下文。
**构建(LLM 抽取,挂在解析流水线向量化之后、完成之前——对应文档状态机第 3 态「图谱构建中」)**:
```
解析 → 分块 → 向量化 ──▶ 批量抽取(每 chunk 一次 LLM 调用,JSON 输出;经 kg_extract 池并发,
池内只做 LLM 调用,upsert/insert 收敛回主 goroutine 串行,见 §7.9
{entities:[{name, type}], relations:[{head, relation, tail}]}
upsert kg_entity(按 dataset_id+name 去重)→ 写 kg_relation(带来源 chunk_id
```
- 复用 M4 的 chat 组件工厂,抽取 Prompt 要求模型只输出 JSON
```json
{"entities": [{"name": "张明", "type": "person"}], "relations": [{"head": "张明", "relation": "任职于", "tail": "XX科技"}]}
```
- 实体按 `(dataset_id, name)` 去重(同一实体多 chunk 出现只建一次,UNIQUE 约束 + ON CONFLICT upsert),关系带来源 chunk_id;删除文档时按分块 id 级联清理
- 抽取**失败不阻断**:无默认对话模型、或部分分块抽取失败,文档仍置 4 已完成,但 `error_msg` 记「知识图谱未构建:…」(未配置默认对话模型 / N 个分块抽取失败),前端文档列表黄色标签提示(`status===4 && error_msg`
**使用(图增强检索,挂在 HybridRetriever 之后)**
```
用户问题
├─▶ 混合检索(向量+FTS5)──┐
├─▶ 实体链接:问题文本分词后与 kg_entity.name 精确/子串匹配,取 Top 3 命中实体
│ └─▶ 一跳邻居:取这些实体的关系三元组(头/尾任意一端命中即取,上限 20 条)
组装上下文:检索片段 + 三元组列表("【知识图谱】张明 -任职于-> XX科技")→ ChatModel
```
- 实体链接用 `common/tokenizer.go` 分词 + 名称匹配,零模型调用;打分规则:问题含完整实体名 +5,命中 token 按长度加权,Top3 后名称长者优先
- 三元组作为辅助上下文注入 prompt(排序在检索片段之后),增强模型对关系类问题的回答
- `kg_entity` / `kg_relation` 两表归属 business.db,各配独立 entity/dao/service/controller/dto 文件(entity 共 12 个文件)
### 7.8 合同法律条款标注
**业务目标**:上传合同文件(txt/md/pdf/docx/html),按条款切分,每条合同条款自动标注对应的法律条文(法条原文 + 标注理由 + 0-10 相关度),并支持导出标注版文档(HTML,可打印/另存 PDF)。合同类型多样,法律语料可多部(每部法律一个 dataset,标注时多选)。
**与问答检索的本质差异——宁滥毋缺**:问答策略 `HybridRetriever`topK=5 截断 + 重排门槛 max(最高分×50%, 6))不适用于标注——topK 截断会漏标、6 分门槛会杀光「段落对段落」的弱相关匹配。标注业务**漏标比多标严重**:召回放宽(`AnnoRecallTopK=15`/数据集,向量+FTS 各 15 条)、RRF 融合截 60 候选、不做重排门槛、全部候选交 LLM 判定后保留(含 0 分)。
**流水线**`annotation_service.go``StartAnnotationPoller` gtimer 单例 5 秒轮询):
```
上传合同 → 任务落库(pending) → poller 取任务 → ParseFile 解析文本 → 正则切分条款
→ 逐条款并发(annotation_clause 池):多 dataset 各召回(Vec 15 + FTS 15annotation_dataset 池并行)
→ RRF 融合截 60 候选 → LLM 一次调用判定(0-10 分 + 理由, JSON)
→ 主 goroutine 串行落库(清旧标→插 mark→置状态→更新进度)
→ 全部条款完成 → 任务 done(断点:重启后按 clause 状态续跑,已 done 条款跳过)
```
- **条款切分**:按优先级探测三种行首正则(`第X条` / `\d+(.\d+)*[、.]` / `中文数字[、.]`),命中 ≥2 采用,否则整篇单条(title=「全文」);title=标记、content=标记行至下一标记全文
- **召回**:每 dataset 各调 `VecSearch(dsId, vec, 15)` + `FtsSearch(dsId, 分词截 200 字, 15)`,全局按 RRF(`1/(RrfK+rank+1)`) 融合截 `AnnoMaxCandidates=60`embedder 按 dataset 绑定的 embedding 配置构建并缓存;**无候选的条款视为完成**(跳过 LLM 调用,无 mark
- **判定**:单次 LLM 调用,prompt 含条款全文(截 `AnnoMaxClauseChars=2000`+ 编号候选(每条截 `RerankMaxChars=500` 字),输出 `{"marks":[{"cand_id":1,"law_item":"第四十四条","score":9,"reason":"..."}]}`**law_item 由 LLM 判定输出**(法条编号,`第X条` 正则兜底提取);JSON 解析与 rerankByLLM 同套路(```json 提取 + 截首尾花括号);**不做门槛过滤,全部候选(含 0 分)按分降序保留**
- **快照落库**mark 存命中 chunk 的法条快照(law_title=dataset 名、law_item=LLM 判定的法条编号、content=chunk 内容截断 800 字),标注结果不随语料变更失效
- **容错**:单条款 LLM 失败仅该条 failed(记 error_msg)不重试;**任务仍置 Done**,任务 error_msg 记「N 条条款标注失败」(前端可见);未配置默认 chat 模型 → 任务失败
- **断点续跑**:任务按 `status IN (0,1)` 领取,clause 粒度续跑(已 done 不重复产生 mark;重跑任务先 DeleteByClause 幂等重建)
- **导出**`AnnotatedHTML` 生成自包含 HTML(条款 + 内嵌标注,score ≥8 绿 / ≥5 蓝 / 其余灰,打印按钮 `window.print()`);controller 直接写响应体(中间件检测已写入则不包装 JSON),前端原生 fetch + 手动 Authorization 获取 blob
### 7.9 并行化实现形态
**实现形态**`kb/service/pool.go`,包 init 从 `g.Cfg().MustGet("pool.<key>", 默认值)` 读取;`grpool.New(limit)` 建池,`Pool.AddWithRecover` 提交并防 panic):
- 知识图谱(§7.7):`ExtractDocument` 拆 `callExtract`(池内 LLM+JSON 解析,不写库)/ `saveExtract`(主 goroutine 串行 upsert/insert),「发一批、收一批」循环
- 合同标注(§7.8):`processOne` 条款循环提交「召回+判定」入池,主 goroutine 收结果串行清旧标/插 mark/置状态/更新进度;`recallCandidates` 内逐 dataset 并行召回(`recallOneDataset`),RRF 融合回主 goroutine
- 问答(§7.3/§7.4):`Ask` 中 `retrieve`ChatPool,内部再并行 vec/fts)与 `GraphEnhance`(纯读,主 goroutine 直接跑)并行;`Retrieve` 内 vec 段与 fts 段并行(`vecRetrieve`/`ftsRetrieve`),RRF 合并、LLM 重排保持串行
- 任务级仍由轮询器单 goroutine 串行消费(决策 10),并行只发生在任务内部
---
## 8. 鉴权实现细节
> 鉴权设计概述(令牌生命周期、公开路径白名单)见 README.md;以下为实现细节。
### 8.1 令牌生命周期
```
每次启动
│ EnsureAccessToken()RandomToken(16) 生成 32 位 hex tokenSetAccessToken 存入内存
common/auth.go 包级变量,不落库;app_config 仅存分块默认值,无 access_token 键)
启动日志打印(main.go):
============================================
访问令牌(登录用): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
请在登录页输入上述令牌
============================================
登录页:输入 token → POST /system-config/login → CheckAccessToken 校验 → 签发 JWT
│ JWTHS256claims: { role: "owner", token_fp: 指纹 },有效期 24h
后续请求:Authorization: Bearer <JWT>,中间件校验 token_fp 与内存令牌指纹一致
```
要点:
- **内存持有,不落库**`common/auth.go` 中 `var accessToken string` 包级变量;`EnsureAccessToken`system_config_service.go)每次启动无条件重新生成。登录页提示语「请输入服务启动时打印在控制台的访问令牌」
- **指纹校验**JWT 中携带令牌的 SHA-256 指纹(`TokenFingerprint`,取前 16 位 hex);鉴权中间件将 JWT 指纹与内存令牌指纹比对(`claims.TokenFp != AccessTokenFingerprint()`),不一致即 401「访问令牌已变更,请重新登录」。因此**重启服务后所有旧会话立即失效**,前端 401 拦截器自动跳登录页
- **无 regenerate-token 接口**`system_config_dto.go` 仅有 `/login`、`/settings`、`/save-settings` 三个接口;换令牌 = 重启服务(新令牌自动打印到日志)。设置页**没有**令牌管理 UI(Settings.vue 只含分块默认值 + 模型配置)
- **公开路径白名单**auth_middleware.go 精确匹配):`/system-config/login`、`GET /`、`GET /assets/*`(hash 路由下 SPA 只请求这两类静态路径,无鉴权绕过);`GET /workspace/*` 前缀放行(浏览器下载/预览请求不带 Authorization),仅做路径穿越防护(拒绝 `..`);其余路径一律校验
- 无注册、无角色、无用户表;登录日志不落库(如需审计可在日志文件输出);JWT 密钥为代码内固定常量(单机场景可接受,如需更换改 `common/auth.go` 的 `jwtSecret`
```go
// common/auth_middleware.go(实际实现)
func Auth(r *ghttp.Request) {
if isPublicPath(r.URL.Path) { r.Middleware.Next(); return }
claims, err := ParseToken(bearer(r))
if err != nil { 401 "登录已过期,请重新登录"; return }
if claims.TokenFp != AccessTokenFingerprint() { 401 "访问令牌已变更,请重新登录"; return }
r.SetCtxVar("role", claims.Role)
r.Middleware.Next()
}
```
---
## 9. 前端设计(Vue 3 + Vite + Element Plus
### 9.1 工程
- `ui-src/` 独立 Vite 工程(vite@5 + @vitejs/plugin-vue@5),dev server 端口 5173`VITE_API_PROXY_TARGET` 环境变量可覆盖代理目标(默认 `http://localhost:8080`
- hash 路由(无需服务端 SPA fallback),路由守卫:无 JWT → 跳登录页
- 依赖:`vue@3.4`、`vue-router@4`、`pinia@2`、`element-plus@2.5`zh-cn 语言包全量引入)、`@element-plus/icons-vue`、`axios@1.6`、`echarts@6`(图谱页按需注册 GraphChart 等模块);无 UI 测试框架
- dev proxy 路径(vite.config.js):`/system-config` `/model-config` `/dataset` `/document` `/parse-task` `/conversation` `/message` `/kg-entity` `/kg-relation` `/contract` `/workspace`(共 11 个,与后端路由前缀一一对应)
- 构建产物 `ui-src/dist`,本地开发 `npm run build` 后由 Go 统一端口托管;开发期可用 Vite dev server + proxy
### 9.3 API 封装
- `api/request.js`axios 实例(baseURL `/`、token 注入、401 跳转、错误 toast
- `api/xxx.js`:按后端模块组织,与 dto 对齐(dataset/document/chunk/chat/kg/contract/model_config/settings/auth
- 二进制下载(导出标注版 HTML)走原生 fetch + 手动 `Authorization` 头,绕过拦截器对 blob 的 JSON 误判
- 流式:`fetch` + `ReadableStream` 解析 SSE(按 `\n\n` 分块、解析 `data: ` 行 JSON,按字段识别:`citations`(引用 + conversation_id/ `content`(增量文本)/ `status==='ok'`(完成,或流读完兜底)/ `message`(错误);`data: [DONE]` 与 15 秒心跳注释行静默跳过)
## 14. 风险与备选方案
### 14.2 中文检索质量
FTS5 召回依赖 gse 分词质量;专有名词(人名/产品名)可能切碎。实际缓解:查询端用「引号词组 OR 语义」(`TokenizeQuery`)避免 AND 空召回、向量与关键词双路召回经 RRF 融合互补。改进方向(未实现):trigram tokenizer 兜底(召回全但噪声大),或对 FTS 零命中 query 降级为纯向量检索。
### 14.3 SQLite 写并发
解析任务轮询与用户操作可能并发写 business.db,偶发 `database is locked (5)`(**已实测,未根治**)。当前缓解(三层):① 两个 poller 各自单 goroutine 串行消费任务;② 任务内并行段(grpool 协程池,§7.9)只做读查询与 LLM/Embedding 调用,**SQLite 写一律收敛回主 goroutine 串行执行**——并发不会引入新的写竞争;③ 写操作集中到 service/dao 单事务。**改进方向(未实现)**:连接串初始化时执行 `PRAGMA journal_mode=WAL` 与 `PRAGMA busy_timeout=5000`modernc 驱动支持),或 config.yml `database` 段配置 `busy_timeout` 后重试机制。
### 14.4 切换 embedding 模型
不同模型的向量空间不可混用。方案:数据集绑定 embedding 配置(`kb_dataset.embedding_cfg_id`),编辑数据集时检测变更 → 前端确认弹窗 → 提交 `reembed` 任务(**仅对现有分块重算向量**:`UpdateVec` 覆写 vec0,不重新解析/分块/重建 FTS)。分块参数(chunk_size/overlap)变更则触发全量 `parse` 任务(重新解析 + 重分块 + 重向量化,先清旧索引幂等重建)。两种任务均异步轮询执行,前端文档列表看进度;切换维度不同的模型时需注意 vec0 建表维度(`vector.dim` 配置),维度不匹配的库需清库重建。