From 61d4f1be6308d8e97b57118265439a363c69e4cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Mon, 10 Aug 2026 10:55:50 +0800 Subject: [PATCH] 1 --- CLAUDE.md | 86 ++ README.md | 187 ++++- docs/实现方案.md | 1123 -------------------------- kb/controller/contract_controller.go | 90 +-- kb/controller/dataset_controller.go | 1 - kb/controller/document_controller.go | 17 +- kb/dao/contract_clause_dao.go | 6 - kb/dao/contract_mark_dao.go | 18 +- kb/dao/contract_risk_dao.go | 7 - kb/dao/contract_task_dao.go | 27 + kb/service/annotation_service.go | 107 ++- kb/service/document_service.go | 17 +- 技术设计.md | 384 +++++++++ 13 files changed, 781 insertions(+), 1289 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 docs/实现方案.md create mode 100644 技术设计.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..83b521c --- /dev/null +++ b/CLAUDE.md @@ -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 ./...` diff --git a/README.md b/README.md index 4e264ae..ef5e249 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ -# rag-local 本地知识库 +好# rag-local 本地知识库 -纯本地的 RAG(检索增强生成)知识库系统,基于 SQLite 全栈文件型存储,无外部数据库依赖。 +纯本地的 RAG(检索增强生成)知识库系统,基于 SQLite 全栈文件型存储,无外部数据库依赖。面向 NAS / 小主机等边缘设备单机部署。 ## 功能 -- **文档流水线**:上传 txt / md / pdf / docx / html → 自动解析 → 标题感知分块 → 向量化 + 全文索引 -- **混合检索**:sqlite-vec 向量 KNN + FTS5 全文 BM25,RRF 融合排序,中文 gse 分词 +- **文档流水线**:上传 txt / md / pdf / docx / html → 自动解析 → 四阶段分块(结构识别 → 标题感知 → 语义 → 递归兜底)→ 向量化 + 全文索引,6 态解析状态机全程可见 +- **混合检索**:sqlite-vec 向量 KNN + FTS5 全文 BM25,RRF 融合排序 + 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: 会话/消息 │ │ +│ └───────┬───────┘ └───────────────────────────┘ │ +│ │ HTTP(OpenAI 兼容 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 / embedding,is_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):向量 KNN(vec0,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(HS256,24h 有效,携带令牌 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 开发服务器(5173),API 代理见 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 `;接口只使用 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 `。 +## 前端页面 + +| 页面 | 功能 | +|---|---| +| 登录页 | 输入访问令牌登录,无注册入口 | +| 数据集列表 | 新建/编辑/删除(绑定向量模型必选、分块参数带出全局默认);变更模型/分块 → 确认弹窗提示重新处理 | +| 数据集详情 | 文档上传(拖拽)、6 态解析状态/分块数/失败重试/「图谱未构建」提示、分块预览与编辑 | +| 图谱页 | 实体/关系列表 + eCharts 力导向图 | +| 合同标注页 | 选语料数据集 → 上传合同 → 任务进度(3s 轮询)→ 条款-标注对照抽屉 → 导出标注版 HTML | +| 问答页 | 会话列表 + 知识库下拉,SSE 流式渲染,引用折叠面板 | +| 设置页 | 分块默认值 + 模型配置 CRUD(chat/embedding 两个 tab)、连通性测试、设置默认 | ## 项目结构 ``` -common/ 通用层:HTTP 服务/鉴权/文件解析/中文分词/向量 JSON +common/ 通用层:HTTP 服务/鉴权/文件解析/中文分词/向量 JSON/协程池 kb/ - consts/ 表名、状态、常量 - model/ entity / dto / domain - dao/ 数据访问(每表一个文件) - service/ 业务逻辑(每表一个文件 + chat_service 问答编排) + consts/ 表名、状态、默认参数与协程池默认大小 + model/ entity(表结构)/ dto(Req/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、检索参数、风险与备选方案等) diff --git a/docs/实现方案.md b/docs/实现方案.md deleted file mode 100644 index 92794fe..0000000 --- a/docs/实现方案.md +++ /dev/null @@ -1,1123 +0,0 @@ -# rag-local 本地知识库 实现方案 - -> 技术栈:GoFrame v2 + Vue 3 + Eino(字节 CloudWeGo LLM 编排框架) -> 数据库:全部文件型 —— SQL = SQLite,向量 = sqlite-vec(SQLite 扩展),全文检索 = SQLite FTS5 -> 部署:docker-compose 一键部署,前后端不分离,统一端口暴露 -> 鉴权:单用户 + 每次启动重新生成的访问令牌(内存持有不落库,面向边缘设备部署) - ---- - -## 1. 项目概述 - -本地私有知识库系统(面向 NAS / 小主机等边缘设备单机部署),支持: - -- 文档管理:上传(txt / md / pdf / docx / html)、自动解析、分块、向量化(6 态解析状态机) -- 混合检索:语义(向量)检索 + 关键词(全文)检索,RRF 融合排序 -- RAG 问答:基于 Eino 组件的流式对话(直接编排,非 Graph),答案附带引用来源 -- 知识图谱:LLM 自动抽取实体/关系,图谱增强检索回答关系类问题 -- **合同法律条款标注**:上传合同按条款切分,逐条款自动标注对应法条(0-10 分 + 理由),支持导出标注版 HTML(打印/另存 PDF) -- 模型可配置:对话模型 / 嵌入模型均为 OpenAI 兼容 API,可对接任意供应商(本地 Ollama、DeepSeek、硅基流动、OpenAI 等) -- 单用户访问令牌登录:token 每次启动随机生成、内存持有(不落库)并打印在控制台,登录页输入即可使用;重启即换新令牌 - -设计原则: - -1. **全文件型数据库**:业务 SQL、向量、全文检索全部落地为普通文件,随数据目录一键备份/迁移,无任何外部数据库服务 -2. **按业务领域分库文件**:系统 / 数据集 / 问答 三个 SQLite 文件,互不干扰 -3. **分层文件与表对齐**:每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(13 张实体表,参照 video-factory 的表×5 层组织方式;虚拟表 vec0/fts5 由 chunk_dao 兼管) -4. **前后端不分离**:Vue 构建产物由 GoFrame 统一端口托管,单容器部署 -5. **零配置**:不设用户体系与角色权限(单机单人场景),启动即用 - ---- - -## 2. 技术选型 - -| 能力 | 选型 | 说明 | -|---|---|---| -| Web 框架 | GoFrame v2.10+ | 与 video-factory 一致,复用其分层/路由/鉴权模式 | -| SQL 数据库 | SQLite(modernc.org/sqlite 纯 Go 实现) | GoFrame `contrib/drivers/sqlite/v2`,免 CGO,`CGO_ENABLED=0` | -| 向量检索 | **sqlite-vec**(`modernc.org/sqlite/vec` 子包) | modernc 从 **v1.47.0 起内置** sqlite-vec 的 CGO-free 版本,`vec0` 虚拟表提供 KNN 检索;与业务表**同库同事务**,天然文件型 | -| 全文检索 | SQLite FTS5(modernc 内置) | BM25 打分;默认 unicode61 tokenizer,中文通过**应用层分词**(纯 Go `github.com/go-ego/gse`)写入分词列(content_tokens 空格连接) | -| LLM 编排 | Eino `github.com/cloudwego/eino` | ChatModel / Embedding 自研 OpenAI 兼容 HTTP 实现(仅依赖 eino 接口,不引 eino-ext 的 chat/embedding);eino-ext 仅用两个 splitter 子包(semantic / recursive)做分块兜底;Indexer / Retriever 自研(SQLite 后端) | -| 文档解析 | pdfcpu(pdf)、纯 Go zip+xml 解析(docx)、goquery(html)、标准库(txt/md) | 全部纯 Go,免 CGO | -| 前端 | Vue 3 + Vite + Element Plus + axios | hash 路由,构建产物 `ui-src/dist`,由 Go 统一端口托管 | -| 部署 | Docker 多阶段构建 + docker-compose | 单服务单端口,数据目录挂载宿主机卷 | - -### 2.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 扩展 - ``` -- **风险与验证**:升级后首步执行 `go build` 验证 glebarez 与 v1.47 兼容;若出现编译错误,启用备选方案(见 §14.1) - ---- - -## 3. 总体架构 - -``` -┌─────────────────────────── 浏览器 ───────────────────────────┐ -│ http://host:8080 │ -└──────────────────────────────┬───────────────────────────────┘ - │ 统一端口(SPA 静态资源 + REST API + SSE 流式) -┌──────────────────────────────▼───────────────────────────────┐ -│ GoFrame HTTP Server (:8080) │ -│ ┌───────────┐ ┌──────────────┐ ┌────────────────────────┐ │ -│ │ 静态资源 │ │ API 路由 │ │ 访问令牌鉴权 / CORS / │ │ -│ │ ui-src/dist│ │ /dataset │ │ panic 恢复 中间件 │ │ -│ └───────────┘ │ /document ... │ └────────────────────────┘ │ -│ └──────┬───────┘ │ -│ ┌──────────────┼──────────────┐ │ -│ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼──────┐ │ -│ │ controller │→│ service │→│ dao │ │ -│ │ (11 个文件) │ │ (12 个文件) │ │ (13 个文件) │ │ -│ └────────────┘ └──────┬─────┘ └──────┬──────┘ │ -│ │ │ │ -│ ┌─────────▼──────┐ ┌────▼─────────────────────┐ │ -│ │ 业务编排 │ │ SQLite × 3(文件型) │ │ -│ │ RAG 问答 │ │ business.db: 业务表+向量+ │ │ -│ │ 文档解析流水线 │ │ FTS(vec0 + fts5) │ │ -│ │ 知识图谱抽取 │ │ system.db: 全局配置/模型 │ │ -│ │ 合同条款标注 │ │ chat.db: 会话/消息 │ │ -│ └───────┬────────┘ └───────────────────────────┘ │ -│ │ HTTP(OpenAI 兼容 API) │ -└──────────────────────┼────────────────────────────────────────┘ - ┌────────▼────────┐ - │ 模型供应商(可配置)│ - │ Ollama/OpenAI/ │ - │ DeepSeek/硅基流动 │ - └─────────────────┘ -``` - -数据流(三条独立链路,各自任务轮询驱动): - -``` -① 语料入库:上传文档 → ParseFile 解析 → 分块 → Embedding 向量化 ──┐ - 关键词索引(FTS5 BM25)───────────────────────────────────────┤ - ↓ │ - business.db(chunk 表 + vec0 + fts5 同库同事务)──┐ -② 问答:问题 → 混合检索(向量 + 关键词 + RRF 融合 + LLM 重排门槛)→ 组装上下文 ─┤ - (+ 知识图谱图增强)→ ChatModel 流式回答 → SSE(引用 [编号] 定位到分块) │ -③ 合同标注:上传合同 → 按条款切分 → 逐条款多数据集召回(vec+FTS,宁滥毋缺) - → LLM 判定(0-10 分 + 理由)→ 标注落库 → 进度递增 → 导出标注版 HTML -``` - ---- - -## 4. 目录结构 - -``` -rag-local/ -├── main.go # 入口:路由注册、静态资源托管、双任务轮询启动、访问令牌生成(每次启动重新生成)/打印 -├── config.yml # 多库配置 + 服务配置 -├── go.mod / go.sum -├── Dockerfile # 多阶段构建(ui-builder → builder → runtime) -├── docker-compose.yml # 一键部署,挂载数据卷 -├── common/ # 公共层(与 video-factory 对齐) -│ ├── http.go # RouteRegister 自动路由 + OpenAPI + 中间件注册 -│ ├── auth.go # JWT 签发/解析 + 访问令牌指纹校验 -│ ├── auth_middleware.go # 鉴权中间件(白名单 + 静态资源放行) -│ ├── base_dao.go # InsertAndReturnId / GetOneByPk / UpdateByPk / DeleteByPk -│ ├── cache.go # DAO 查询缓存 TTL(database.cache.ttl) -│ ├── util.go # 通用工具 -│ ├── parser.go # 解析器接口 + 注册表(按扩展名分发) -│ ├── pdf_parser.go # pdfcpu 解析 PDF -│ ├── docx_parser.go # zip+xml 解析 word/document.xml -│ ├── html_parser.go # goquery 解析 HTML -│ ├── text_parser.go # txt / md -│ ├── tokenizer.go # gse 中文分词(写索引/检索共用) -│ ├── util.go # RandomToken(随机文件名/令牌)、TokenFingerprint(SHA-256 指纹) -│ ├── pool.go # grpool 协程池单例(5 池,config pool 段配置大小,见 §7.9) -│ └── parser_test.go # 解析器单元测试 -├── kb/ # 业务模块(knowledge base,对应 video-factory 的 shortdrama) -│ ├── consts/ -│ │ ├── table_name.go # 表名常量 + 数据库组常量(DbGroupSystem 等) -│ │ ├── status.go # 文档状态 / 任务状态 / 消息角色 -│ │ ├── content_type.go # 文件类型 / 模型类型(chat/embedding) -│ │ └── consts.go # 通用常量(向量维度默认值、TopK、access_token 配置键) -│ ├── model/ -│ │ ├── entity/ # 12 个文件,与 12 张实体表一一对应(app_config 无独立 entity) -│ │ ├── dto/ # 11 个文件(chunk/contract 各含多组 Req/Res;含 g.Meta 路由定义) -│ │ └── domain/ # 领域对象(RAG 检索结果、引用来源、流式事件等) -│ ├── dao/ # 13 个文件,与实体表一一对应(chunk_dao 兼管 vec0/fts5 虚拟表) -│ ├── service/ # 12 个文件,与 dao 对应(文档流水线、RAG 问答、合同标注编排归入对应 service;grpool 协程池单例在 common/pool.go,见 §7.9) -│ ├── controller/ # 11 个文件,与 service 对应(合同标注路由在 contract_controller) -├── ui-src/ # Vue 3 前端工程 -│ ├── package.json -│ ├── vite.config.js # base:'/'、build.outDir:'dist' -│ └── src/ -│ ├── main.js / App.vue -│ ├── router/index.js # hash 路由(登录守卫) -│ ├── api/ # axios 封装 + 各模块 API(与后端 dto 对齐) -│ ├── stores/ # Pinia(auth:token 存 localStorage,401 自动登出) -│ ├── views/ -│ │ ├── Login.vue # 输入访问令牌登录 -│ │ ├── Layout.vue -│ │ ├── DatasetList.vue # 数据集列表 -│ │ ├── DatasetDetail.vue # 文档管理 + 上传 + 解析状态(6 态进度) -│ │ ├── KgGraph.vue # 知识图谱(实体/关系列表) -│ │ ├── Contract.vue # 合同标注(上传/任务进度/条款-法条对照) -│ │ ├── Chat.vue # RAG 问答(SSE 流式 + 引用来源) -│ │ └── Settings.vue # 分块默认值 + 模型配置(无令牌管理,见 §8) -│ └── components/ # (无独立组件目录,组件内聚于各 views) -├── data/ # 仅数据库文件(gitignore) -│ ├── business.db # 数据集领域 -│ ├── system.db # 系统领域 -│ └── chat.db # 问答领域 -├── workspace/ # 上传的文档源文件(gitignore,与数据库分离) -│ ├── {datasetId}/{yyyymmdd}/{uuid}.{ext} # 语料文档(与 kb_document.file_path 强约束) -│ └── contract/{yyyymmdd}/{uuid16}.{ext} # 合同标注源文件(与 kb_contract_task.file_path 强约束) -└── docs/ - └── 实现方案.md -``` - -> 分层文件与表对齐规则(硬性约定,参照 video-factory): -> - `model/entity/` 每个文件定义一张实体表的结构体,`orm` 标签命名 -> - `dao/` 每个文件 = 一张表的单例 DAO(`var Xxx = &xxxDao{}`),`init()` 内建表 + 索引 + 迁移 -> - `service/` 每个文件对应一个 DAO,承载业务逻辑(文档流水线、RAG 调用) -> - `controller/` 每个文件对应一个 service,暴露 REST 接口(`g.Meta` 定义 path/method) -> - `model/dto/` 每个文件定义一张表的 Req/Res 结构体 -> - 虚拟表(vec0 / FTS5)是 chunk 表的附属索引,**不单独建分层文件**,由 `chunk_dao.go` 统一管理 -> - **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词)平铺在 `common/`;业务编排(分块、Indexer、Retriever、工作流)归入对应 service 文件 - ---- - -## 5. 数据库设计 - -### 5.1 数据库文件划分(按业务领域) - -| 文件 | 配置组名 | 领域 | 说明 | -|---|---|---|---| -| `data/business.db` | `default` | 数据集 | 数据集、文档、分块、向量(vec0)、全文索引(FTS5)、解析任务、知识图谱(kg 两表)、合同标注(任务/条款/标注) | -| `data/system.db` | `system` | 系统 | 全局设置(app_config)、模型配置(访问令牌不落库,见 §8) | -| `data/chat.db` | `chat` | 问答 | 会话、消息 | - -config.yml: - -```yaml -database: - default: - name: data/business.db - type: sqlite - debug: true - system: - name: data/system.db - type: sqlite - debug: true - chat: - name: data/chat.db - type: sqlite - debug: true - cache: - ttl: 60 - -server: - address: :8080 - name: rag-local - workerId: 1 - clientMaxBodySize: 209715200 # 200MB,支持大文件上传 - requestTimeout: 3000 # 秒;支持 AI 长响应 - -# 向量配置 -vector: - dim: 1024 # 向量维度(vec0 建表维度;须与所用 embedding 模型一致,变更需清库重建) - -# AI 模型调用配置 -chat: - timeout: 600 # 对话模型 API 请求超时(秒) - max_retries: 3 # 请求失败最大重试次数 -``` - -### 5.2 表清单(13 张实体表 + 2 张虚拟表) - -| # | 表名 | 库 | 类型 | 说明 | -|---|---|---|---|---| -| 1 | `app_config` | system | 实体 | 全局键值配置(分块默认参数,无令牌键) | -| 2 | `model_config` | system | 实体 | 模型配置(chat / embedding,is_default 标记默认) | -| 3 | `kb_dataset` | business | 实体 | 数据集(绑定 embedding 配置、分块参数) | -| 4 | `kb_document` | business | 实体 | 文档(6 态状态机,见 §5.4) | -| 5 | `kb_chunk` | business | 实体 | 分块 | -| 6 | `kb_chunk_vec` | business | **虚拟表 vec0** | 分块向量(chunk_id 与 kb_chunk 1:1) | -| 7 | `kb_chunk_fts` | business | **虚拟表 FTS5** | 分块全文索引(content_tokens 存 gse 分词) | -| 8 | `kb_parse_task` | business | 实体 | 文档解析/向量化任务(parse / reembed) | -| 9 | `kg_entity` | business | 实体 | 知识图谱实体(按 dataset_id+name 去重,带来源 chunk_id) | -| 10 | `kg_relation` | business | 实体 | 知识图谱关系(head/relation/tail 文本快照,带来源 chunk_id) | -| 11 | `kb_contract_task` | business | 实体 | 合同标注任务(dataset_ids 逗号分隔,见 §5.6 弱引用说明) | -| 12 | `kb_contract_clause` | business | 实体 | 合同条款(断点续跑粒度) | -| 13 | `kb_contract_mark` | business | 实体 | 标注结果(法条快照 + 理由 + 0-10 分) | -| 14 | `chat_conversation` | chat | 实体 | 问答会话(dataset_id 弱引用) | -| 15 | `chat_message` | chat | 实体 | 问答消息(citations 存引用 JSON) | - -> 分层文件对齐(实际数量):`entity` 12 个(app_config 无独立 entity,读写走 app_config_dao 的 GetInt/SetInt)、 -> `dao` 13 个(`chunk_dao.go` 兼管 vec0/fts5 两张虚拟表)、`service` 12 个(合同标注归 `annotation_service.go`)、 -> `controller` 11 个 + `dto` 11 个。 -> 无 user / login_log / 角色权限:单用户场景,访问令牌内存持有、启动时重新生成(见 §8)。 -> 知识图谱(`kg_entity` / `kg_relation`)属数据集领域,见 §7.7;合同标注见 §7.8。 -> 注:用 `sqlite3 .tables` 会看到 `kb_chunk_fts_*`(5 张)与 `kb_chunk_vec_*`(4 张)等额外表, -> 它们是 FTS5 / vec0 虚拟表自动生成的**内部影子表**(倒排索引、向量分块等存储),由 SQLite 自动维护, -> 不是业务表,**不可手动删除**——删除会破坏虚拟表模块状态(如插入报 -> `Error opening vector blob`)。虚拟表清理只能 `DROP TABLE` 后重建(见 §5.5)。 - -### 5.3 建表 DDL - -表名前缀:`kb_`(数据集领域)、`chat_`(问答领域);`consts.TableNameXxx` 常量集中管理,参照 video-factory。 - -```sql --- ==================== system.db ==================== - -CREATE TABLE IF NOT EXISTS app_config ( - cfg_key TEXT PRIMARY KEY, - cfg_value TEXT NOT NULL DEFAULT '', - updated_at DATETIME DEFAULT (datetime('now','localtime')) -); - --- 预置键(consts.SettingsKey* 常量管理): --- chunk_default_size 分块默认最大字数(SaveSettings 校验 50~5000) --- chunk_default_overlap 分块默认重叠字数(校验 0~500) --- 说明:无 access_token 键——访问令牌由 SystemConfigService.EnsureAccessToken 每次启动 --- 重新生成,内存持有不落库(见 §8);默认对话/embedding 模型用 model_config.is_default 标记(迁移已处理旧键) - -CREATE TABLE IF NOT EXISTS model_config ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL DEFAULT '', -- 配置名称,如 "DeepSeek Chat" - model_type TEXT NOT NULL DEFAULT 'chat', -- chat / embedding - model_name TEXT NOT NULL DEFAULT '', -- 模型名,如 deepseek-chat - endpoint_url TEXT NOT NULL DEFAULT '', -- 如 https://api.deepseek.com - api_key TEXT NOT NULL DEFAULT '', - dimension INTEGER NOT NULL DEFAULT 1024, -- 仅 embedding 有效(向量维度) - extra TEXT NOT NULL DEFAULT '', -- JSON 扩展(temperature 等) - is_default INTEGER NOT NULL DEFAULT 0, -- 1=默认配置(chat 各取一) - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')) -); - --- ==================== business.db ==================== - -CREATE TABLE IF NOT EXISTS kb_dataset ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL DEFAULT '', - description TEXT NOT NULL DEFAULT '', - embedding_cfg_id INTEGER NOT NULL DEFAULT 0, -- 绑定 embedding 模型配置;切换需重新向量化 - chunk_size INTEGER NOT NULL DEFAULT 800, -- 分块最大字数(覆盖全局默认) - chunk_overlap INTEGER NOT NULL DEFAULT 150, -- 分块重叠字数 - chunk_strategy TEXT NOT NULL DEFAULT 'title', -- 分块策略(内置组合,非用户可选,见 §7.6) - unit_pattern TEXT NOT NULL DEFAULT '', -- 单位切分正则(如条款式语料按「第X条」切) - context_pattern TEXT NOT NULL DEFAULT '', -- 上下文正则(抽取当前单位上下文) - status INTEGER NOT NULL DEFAULT 1, -- 1 正常 / 0 禁用 - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')) -); - -CREATE TABLE IF NOT EXISTS kb_document ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dataset_id INTEGER NOT NULL DEFAULT 0, - filename TEXT NOT NULL DEFAULT '', - file_path TEXT NOT NULL DEFAULT '', -- workspace 下相对路径(workspace/3/20260804/xxx.pdf),见 §5.5 - file_size INTEGER NOT NULL DEFAULT 0, - file_type TEXT NOT NULL DEFAULT '', -- txt/md/pdf/docx/html - status INTEGER NOT NULL DEFAULT 0, -- 6 态:0 待处理 1 解析中 2 向量生成中 3 图谱构建中 4 已完成 5 失败 - chunk_count INTEGER NOT NULL DEFAULT 0, - error_msg TEXT NOT NULL DEFAULT '', -- 失败原因 / 「知识图谱未构建」原因 - content TEXT NOT NULL DEFAULT '', -- 解析后的原始全文(详情抽屉展示,迁移列) - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')) -); - -CREATE TABLE IF NOT EXISTS kb_chunk ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dataset_id INTEGER NOT NULL DEFAULT 0, - document_id INTEGER NOT NULL DEFAULT 0, - seq INTEGER NOT NULL DEFAULT 0, -- 分块序号 - content TEXT NOT NULL DEFAULT '', -- 分块原文 - meta TEXT NOT NULL DEFAULT '', -- JSON:来源页码/标题路径等 - created_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE INDEX IF NOT EXISTS idx_kb_chunk_document ON kb_chunk(document_id); - --- 向量虚拟表(sqlite-vec,维度按 embedding 模型定,默认 1024) -CREATE VIRTUAL TABLE IF NOT EXISTS kb_chunk_vec USING vec0( - chunk_id INTEGER PRIMARY KEY, - embedding float[1024] -); - --- 全文索引虚拟表(FTS5,默认 unicode61 tokenizer + 应用层 gse 中文分词; --- FTS5 不支持 simple tokenizer,中文分词在应用层完成,content_tokens 存分词后空格连接文本) -CREATE VIRTUAL TABLE IF NOT EXISTS kb_chunk_fts USING fts5( - chunk_id UNINDEXED, - dataset_id UNINDEXED, - title, - content_tokens -- gse 分词后空格连接 -); - -CREATE TABLE IF NOT EXISTS kb_parse_task ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - document_id INTEGER NOT NULL DEFAULT 0, - dataset_id INTEGER NOT NULL DEFAULT 0, - task_type TEXT NOT NULL DEFAULT 'parse', -- parse / reembed - status INTEGER NOT NULL DEFAULT 0, -- 0 待处理 1 处理中 2 完成 3 失败 - error_msg TEXT NOT NULL DEFAULT '', - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE INDEX IF NOT EXISTS idx_kb_parse_task_status ON kb_parse_task(status); - --- 知识图谱(LLM 抽取,见 §7.7) --- 实体按 (dataset_id, name) 去重(UNIQUE + ON CONFLICT upsert);chunk_id 记录来源分块,随文档删除级联 -CREATE TABLE IF NOT EXISTS kg_entity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dataset_id INTEGER NOT NULL DEFAULT 0, - name TEXT NOT NULL DEFAULT '', -- 实体名 - entity_type TEXT NOT NULL DEFAULT '', -- 类型(person/org/location/...) - chunk_id INTEGER NOT NULL DEFAULT 0, -- 来源分块 kb_chunk.id - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')), - UNIQUE(dataset_id, name) -); -CREATE INDEX IF NOT EXISTS idx_kg_entity_dataset ON kg_entity(dataset_id); - --- 关系存 head/relation/tail 文本快照(非外键引用):实体可能被 upsert 覆盖/改名,文本快照保证展示与检索自洽 -CREATE TABLE IF NOT EXISTS kg_relation ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dataset_id INTEGER NOT NULL DEFAULT 0, - head TEXT NOT NULL DEFAULT '', -- 头实体名(文本快照) - relation TEXT NOT NULL DEFAULT '', -- 关系类型 - tail TEXT NOT NULL DEFAULT '', -- 尾实体名(文本快照) - chunk_id INTEGER NOT NULL DEFAULT 0, -- 来源分块 kb_chunk.id - created_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE INDEX IF NOT EXISTS idx_kg_relation_dataset ON kg_relation(dataset_id); - --- 合同标注三表(见 §7.8):task → clause(断点续跑粒度)→ mark(法条快照,宁滥毋缺不设门槛) -CREATE TABLE IF NOT EXISTS kb_contract_task ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - filename TEXT NOT NULL DEFAULT '', - file_path TEXT NOT NULL DEFAULT '', -- workspace/contract/{yyyymmdd}/{uuid16}.{ext},见 §5.5 - dataset_ids TEXT NOT NULL DEFAULT '', -- 逗号分隔的语料数据集 id "1,3,5"(弱引用,见 §5.6) - status INTEGER NOT NULL DEFAULT 0, -- 复用任务状态:0 待处理 1 标注中 2 完成 3 失败 - total_clauses INTEGER NOT NULL DEFAULT 0, - done_clauses INTEGER NOT NULL DEFAULT 0, - error_msg TEXT NOT NULL DEFAULT '', - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE INDEX IF NOT EXISTS idx_kb_contract_task_status ON kb_contract_task(status); - -CREATE TABLE IF NOT EXISTS kb_contract_clause ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id INTEGER NOT NULL DEFAULT 0, -- kb_contract_task.id - seq INTEGER NOT NULL DEFAULT 0, -- 条款序号 - title TEXT NOT NULL DEFAULT '', -- 「第一条」/「1.」/「1.1」 - content TEXT NOT NULL DEFAULT '', -- 条款全文 - status INTEGER NOT NULL DEFAULT 0, -- 0 待处理 1 标注中 2 完成 3 失败 - error_msg TEXT NOT NULL DEFAULT '', - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE INDEX IF NOT EXISTS idx_kb_contract_clause_task ON kb_contract_clause(task_id); - -CREATE TABLE IF NOT EXISTS kb_contract_mark ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - clause_id INTEGER NOT NULL DEFAULT 0, -- kb_contract_clause.id - chunk_id INTEGER NOT NULL DEFAULT 0, -- 命中的法条分块 kb_chunk.id - dataset_id INTEGER NOT NULL DEFAULT 0, -- 命中的语料数据集 - law_title TEXT NOT NULL DEFAULT '', -- 法律名 = 数据集名(快照) - law_item TEXT NOT NULL DEFAULT '', -- 法条编号(如 第十九条) - content TEXT NOT NULL DEFAULT '', -- 法条原文快照 - reason TEXT NOT NULL DEFAULT '', -- 标注理由 - score REAL NOT NULL DEFAULT 0, -- 0-10 相关度(≥8 强相关/≥5 一般/其余弱,前端分级着色) - created_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE INDEX IF NOT EXISTS idx_kb_contract_mark_clause ON kb_contract_mark(clause_id); - --- ==================== chat.db ==================== - -CREATE TABLE IF NOT EXISTS chat_conversation ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dataset_id INTEGER NOT NULL DEFAULT 0, -- 问答绑定的数据集 - title TEXT NOT NULL DEFAULT '', - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')) -); - -CREATE TABLE IF NOT EXISTS chat_message ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - conversation_id INTEGER NOT NULL DEFAULT 0, - role TEXT NOT NULL DEFAULT 'user', -- user / assistant / system - content TEXT NOT NULL DEFAULT '', - citations TEXT NOT NULL DEFAULT '', -- JSON:引用来源(文档/分块/得分) - created_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE INDEX IF NOT EXISTS idx_chat_message_conversation ON chat_message(conversation_id); -``` - -### 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` 轮询模式(与 video-factory 的 `StartVideoPoller` 一致),单 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` 删 `mark(join 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),重复执行幂等无副作用,重构时收敛到一处。 - -**事务与缓存约束**: - -- DAO 查询走 60s 缓存(`CacheTTL`),**写操作后必须清对应缓存**,否则出现"库里已改、查询还是旧值"(测试期踩过:改库后看不到立即生效)。 -- 多表删除/插入必须单事务(`tx.Begin` + 失败回滚),且用 `defer` 防护已提交后的二次 Rollback。 -- SQLITE_BUSY:两个 poller + 用户操作并发写 business.db 偶发 `database is locked (5)`。当前实际缓解:任务轮询各自单 goroutine 串行消费 + 测试期顺序操作规避(未根治)。改进方向(未实现):`PRAGMA journal_mode=WAL` / `busy_timeout`(如 5000ms)可显著缓解,见 §14.3。 - ---- - -## 6. 分层实现规范(参照 video-factory) - -### 6.1 consts 层 - -```go -// kb/consts/table_name.go(与实际 13 实体表 + 2 虚拟表一一对应) -const ( - TableNameAppConfig = "app_config" // 全局键值(仅分块默认参数;访问令牌内存持有不落库) - TableNameModelConfig = "model_config" - TableNameDataset = "kb_dataset" - TableNameDocument = "kb_document" - TableNameChunk = "kb_chunk" - TableNameChunkVec = "kb_chunk_vec" // 虚拟表 vec0 - TableNameChunkFts = "kb_chunk_fts" // 虚拟表 FTS5 - TableNameParseTask = "kb_parse_task" - TableNameKgEntity = "kg_entity" - TableNameKgRelation = "kg_relation" - TableNameContractTask = "kb_contract_task" - TableNameContractClause = "kb_contract_clause" - TableNameContractMark = "kb_contract_mark" - TableNameConversation = "chat_conversation" - TableNameMessage = "chat_message" -) - -// 数据库组 -const ( - DbGroupDefault = "" // business.db - DbGroupSystem = "system" // system.db - DbGroupChat = "chat" // chat.db -) -``` - -### 6.2 entity 层 - -每文件一张表,`orm` 标签与列名一致,时间用 `*gtime.Time`(参照 video-factory 的 `user.go`): - -```go -type Chunk struct { - Id int64 `orm:"id" json:"id"` - DatasetId int64 `orm:"dataset_id" json:"dataset_id"` - DocumentId int64 `orm:"document_id" json:"document_id"` - Seq int `orm:"seq" json:"seq"` - Content string `orm:"content" json:"content"` - Meta string `orm:"meta" json:"meta"` - CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` -} -``` - -### 6.3 dao 层 - -- 单例模式:`var Chunk = &chunkDao{}` -- `init()` 内执行 `CREATE TABLE IF NOT EXISTS` + 索引 + 迁移(参照 video-factory `user_dao.go`) -- 每表一个文件;`chunk_dao.go` 额外管理 `kb_chunk_vec`(向量 KNN 查询、批量插入)与 `kb_chunk_fts`(BM25 检索、分词写索引),并提供事务内删除(chunk+vec+fts) -- 查询缓存:`gdb.CacheOption{Duration: common.CacheTTL(), Name: ...}`,写操作后清理(参照 video-factory `clearUserCache`) -- 通用 CRUD 用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk / DeleteByPk) - -向量与全文检索 DAO 示例: - -```go -// 向量 KNN(vec0 默认 L2 距离;vec0 无 dataset 列,topK*4 预取后 join kb_chunk 按数据集过滤) -// 实际 SQL(chunk_dao.go): -// SELECT v.chunk_id, v.distance FROM ( -// SELECT chunk_id, distance FROM kb_chunk_vec -// WHERE embedding MATCH ? ORDER BY distance LIMIT ? -// ) v INNER JOIN kb_chunk c ON c.id = v.chunk_id -// WHERE c.dataset_id = ? ORDER BY v.distance LIMIT ? -// 参数:vecJson, vecJson, topK*4, datasetId, topK -func (d *chunkDao) VecSearch(ctx context.Context, datasetId int64, vector []float32, topK int) ([]VecHit, error) { - vecStr := vectorToJson(vector) // [0.1,0.2,...](JSON 数组字符串) - // ...执行上面的两段式 SQL,结果集为 chunkId + distance(L2 距离,越小越近) -} - -// 全文检索(BM25) -func (d *chunkDao) FtsSearch(ctx context.Context, datasetId int64, query string, topK int) ([]FtsHit, error) { - tokens := common.TokenizeQuery(query) - r, err := g.DB(consts.DbGroupDefault).GetCtx(ctx).Raw( - "SELECT chunk_id, bm25(kb_chunk_fts) AS score FROM kb_chunk_fts "+ - "WHERE kb_chunk_fts MATCH ? AND dataset_id = ? ORDER BY score LIMIT ?", - tokens, datasetId, topK, - ) - ... -} -``` - -### 6.4 service 层 - -每表一个文件 + 业务方法(解析与分词等纯技术能力在 `common/`,业务编排全部归入对应 service): -- `system_config_service.go`:**访问令牌**生成(每次启动)与登录签发 JWT、系统设置读写(app_config 键值由 `app_config_dao` 的 GetInt/SetInt 提供,无独立 entity/service 文件) -- `dataset_service.go`:数据集 CRUD(含删除级联,见 §5.6) -- `document_service.go`:上传落盘(workspace/{datasetId}/...)、创建文档记录、提交解析任务、删除(文件+数据同删,见 §5.5) -- `chunk_service.go`:文本分块(`SplitAuto` 四阶段组合:结构识别→标题感知→语义→递归,见 §7.6)、`InsertAll`(批量向量化 + 写 chunk+vec0+FTS5,等价 Indexer 职责)、分块列表/搜索、编辑(改文本后重分词 + 重向量化) -- `parse_task_service.go`:任务队列消费(`StartParsePoller`,与 video-factory `StartVideoPoller` 同模式;解析→分块→索引→图谱抽取全流程编排) -- `kg_entity_service.go` / `kg_relation_service.go`:知识图谱 LLM 抽取(`ExtractDocument` 返回失败分块数)、实体链接 + 一跳邻居(图增强检索) -- `annotation_service.go`:**合同标注**(`StartAnnotationPoller` 任务消费、条款切分、多数据集召回+LLM 判定、`AnnotatedHTML` 导出标注版),见 §7.8 -- `conversation_service.go` / `message_service.go`:会话 CRUD、历史消息 -- `chat_service.go`:**RAG 问答入口**(模型组件工厂、`HybridRetriever`(vec0+FTS5+RRF)、问答工作流编排,SSE 流式回传,落库 message;检索调 `common/tokenizer.go` 分词) -- `model_config_service.go`:模型配置 CRUD + 连通性测试 - -### 6.5 controller 层 + dto 层 - -- 结构体命名决定路由前缀:`dataset` → `/dataset`,`system-config` → `/system-config`(`common.RouteRegister` 自动注册) -- 接口定义在 `model/dto/`,`g.Meta` 携带 path/method/summary;OpenAPI 文档自动生成(`/api.json`) -- 主要接口清单: - -| 路由 | 说明 | -|---|---| -| `POST /system-config/login` | **访问令牌登录**(body: {token} → 返回 JWT) | -| `GET /system-config/settings` | 全局设置(分块默认大小/重叠) | -| `POST /system-config/save-settings` | 保存全局设置(校验 50~5000 / 0~500) | -| `GET /dataset/list` `POST /dataset/save` `POST /dataset/delete` | 数据集列表 / 保存(新建/编辑)/ 删除(有文档拒绝) | -| `POST /document/upload`(multipart)`GET /document/list` `POST /document/delete` `GET /document/detail` | 文档上传 / 列表(分页)/ 删除 / 详情(含原始全文,空时现场解析回填) | -| `GET /chunk/list` `POST /chunk/update` | 分块查看(分页 + 关键词搜索)/ 编辑(重分词 + 重向量化) | -| `POST /document/reembed` | 重新向量化(仅重算向量,不重新解析) | -| `GET /parse-task/list` `POST /parse-task/retry` | 任务列表 / 重试失败任务 | -| `GET /kg-entity/list` `GET /kg-relation/list` | 知识图谱(实体/关系列表,按 dataset_id + 分页) | -| `POST /contract/upload`(multipart + dataset_ids) | **合同标注**:上传合同并启动标注 | -| `GET /contract/list` `GET /contract/detail` `POST /contract/delete` | 标注任务列表 / 详情(条款+标注)/ 删除 | -| `GET /contract/annotated?id=` | 导出标注版合同(HTML,直接写响应体,不走 JSON 包装) | -| `POST /conversation/save` `GET /conversation/list` `POST /conversation/delete` | 会话新建 / 列表 / 删除(级联删消息) | -| `GET /message/list` | 历史消息(按会话) | -| **`POST /message/chat`** | **RAG 问答(SSE 流式)** | -| `GET /model-config/list` `POST /model-config/save` `POST /model-config/delete` | 模型配置列表(可按 model_type 过滤)/ 保存 / 删除(挡 embedding 绑定) | -| `POST /model-config/set-default` `POST /model-config/test` | 设为默认(同类型唯一)/ 连通性测试 | -| `GET /workspace/*` | 源文件访问(main.go BindHandler 静态服务,鉴权放行 + 路径穿越防护) | - -> 接口约定:**全部为 GET / POST 两种方法**(GoFrame 反射路由 + 前端 axios 封装均按此实现), -> 无 PUT/DELETE;写操作传 JSON body(或 multipart),读操作走 query params。 - ---- - -## 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` KNN(L2 距离)预取 `topK*4` 后 join `kb_chunk` 按 dataset_id 过滤,取 20 -2. **关键词检索**:query → `TokenizeQuery` 分词(引号词组 OR 语义、过滤 <2 rune 与 FTS 特殊字符)→ FTS5 MATCH(bm25 负分升序)取 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 时已强制绑定,见下) - ▼ -StartParsePoller(main.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 splitter(MinChunkSize=chunkSize/2) - │ d. 递归兜底:eino recursive(KeepTypeEnd) - │ 5. 批量 Embedding → InsertAll(chunk+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 15,annotation_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 并行化设计(grpool 协程池) - -**背景**:四个串行热点用 GoFrame `grpool` 并行化,池大小在 config.yml `pool` 段配置(缺失或 <1 回退代码内默认值,见 `kb/consts/consts.go`): - -| 池 | 配置键 | 默认 | 作用点 | -|---|---|---|---| -| 知识图谱抽取 | `pool.kg_extract` | 4 | 文档解析第 6 步:逐 chunk LLM 抽取 | -| 合同标注-条款 | `pool.annotation_clause` | 4 | 标注流水线:逐条款 (recall+judge) | -| 合同标注-数据集 | `pool.annotation_dataset` | 8 | 条款内:逐 dataset 召回(向量化+vec+fts) | -| 问答编排 | `pool.chat` | 4 | Ask:retrieve 与 GraphEnhance 并行 | -| 问答检索 | `pool.chat_retrieve` | 4 | HybridRetriever:vec 段与 fts 段并行 | - -**两条铁律**: - -1. **写收敛**:business.db 无 WAL / busy_timeout(§14.3),多 goroutine 并发写会 `database is locked (5)`(已实测)。因此并发段只做**读查询与 LLM/Embedding 调用(纯 IO)**,所有 SQLite 写(状态更新、进度、落库)一律收敛回主 goroutine 串行执行——锁定风险归零,并发只赢在 IO 等待上。 -2. **死锁防护**:池内任务若等待同一池的任务会饿死(worker 全部阻塞在等待上)。被等待的池其任务必须**无嵌套等待**,等待链单向「主 → A池 → B池」(如 主→common.AnnotationClausePool→common.AnnotationDatasetPool、主→common.ChatPool→common.ChatRetrievePool)。`grpool` 无 Wait 方法,等待一律用调用方 `sync.WaitGroup`;任务结果经 buffered channel 回主 goroutine。 - -**实现形态**(`kb/service/pool.go`,包 init 从 `g.Cfg().MustGet("pool.", 默认值)` 读取;`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. 鉴权设计(单用户 + 启动令牌) - -面向边缘设备部署,不做用户体系,只有一个"门禁"级别的访问令牌。**令牌由服务进程持有**:每次启动时重新生成、仅存内存、不落库、打印到启动日志。**重启即换新令牌**,旧 JWT 全部失效——这是刻意的安全取舍(服务重启意味着重新信任环境),也是本系统的"令牌管理"方式:没有独立的管理接口,管理 = 重启服务。 - -### 8.1 令牌生命周期 - -``` -每次启动 - │ EnsureAccessToken():RandomToken(16) 生成 32 位 hex token,SetAccessToken 存入内存 - │ (common/auth.go 包级变量,不落库;app_config 仅存分块默认值,无 access_token 键) - ▼ -启动日志打印(main.go): - ============================================ - 访问令牌(登录用): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 - 请在登录页输入上述令牌 - ============================================ - ▼ -登录页:输入 token → POST /system-config/login → CheckAccessToken 校验 → 签发 JWT - │ JWT(HS256)claims: { role: "owner", token_fp: 指纹 },有效期 24h - ▼ -后续请求:Authorization: Bearer ,中间件校验 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,与 video-factory 一致),路由守卫:无 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.2 页面 - -| 页面 | 功能 | -|---|---| -| 登录页 | 输入访问令牌登录(提示语「请输入服务启动时打印在控制台的访问令牌」,token 校验通过换取 JWT 存 localStorage);无注册入口 | -| 数据集列表 | 表格列表:新建/编辑/删除(绑定 embedding 模型必选、分块大小/重叠带出全局默认、结构识别模式展示);编辑时变更模型/分块 → 确认弹窗提示重新处理全部文档 | -| 数据集详情 | 文档上传(拖拽 + 进度)、文档列表(6 态解析状态/分块数/失败重试/「图谱未构建」提示)、分块预览与编辑 | -| 图谱页 | 知识图谱实体/关系列表(按数据集过滤,分页)+ **eCharts 力导向图**(按名称去重、节点大小随出入度、点击节点高亮一跳邻居;实体 >400 截断仅展示度数最高者) | -| 合同标注页 | 选语料数据集(多选)→ 上传合同 → 任务列表(el-progress 进度、3s 轮询)→ 详情抽屉双栏:左条款列表、右标注卡片(score 分级着色,按分降序,0 分保留);**导出标注版(HTML)** | -| 问答页 | 左侧会话列表 + 知识库下拉(切换清空会话流),右侧消息流;SSE 流式渲染,答案带引用折叠面板(来源 tag + 得分 toFixed(1)) | -| 设置页 | 分块默认值(新建数据集自动带出)+ 模型配置 CRUD(chat/embedding 两个 tab,类型创建后锁定)、同名模型带出 endpoint/维度、连通性测试、设置默认;**无令牌管理**(换令牌 = 重启服务,见 §8) | - -### 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 秒心跳注释行静默跳过) - ---- - -## 10. Docker 部署 - -### 10.1 Dockerfile(多阶段,参照 video-factory) - -```dockerfile -# ==================== 前端构建 ==================== -FROM node:20-alpine AS ui-builder -RUN apk add --no-cache git -WORKDIR /build-ui -COPY rag-local/ui-src/package.json rag-local/ui-src/package-lock.json ./ -RUN npm ci --registry=https://registry.npmmirror.com -COPY rag-local/ui-src/ ./ -RUN npm run build - -# ==================== 后端构建 ==================== -FROM golang:alpine AS builder -RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk add --no-cache git ca-certificates tzdata -ENV TZ=Asia/Shanghai GO111MODULE=on \ - GOPROXY=https://goproxy.cn,direct \ - CGO_ENABLED=0 GOTOOLCHAIN=auto -WORKDIR /build -COPY rag-local/ . -RUN go mod download && go build -ldflags="-s -w" -o main ./main.go - -# ==================== 运行镜像 ==================== -FROM alpine:3.19 -RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk add --no-cache ca-certificates tzdata -ENV TZ=Asia/Shanghai -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -WORKDIR /app -COPY --from=builder /build/main . -COPY --from=builder /build/config.yml . -COPY --from=ui-builder /build-ui/dist ./ui-src/dist -RUN mkdir -p /app/data /app/workspace \ - && printf '#!/bin/sh\nfor db in business.db system.db chat.db; do\n if [ -d /app/data/$db ]; then rm -rf /app/data/$db; fi\n touch /app/data/$db 2>/dev/null || true\ndone\nexec ./main\n' > /app/entrypoint.sh \ - && chmod +x /app/entrypoint.sh -EXPOSE 8080 -ENTRYPOINT ["/app/entrypoint.sh"] -``` - -### 10.2 docker-compose.yml - -```yaml -services: - rag-local: - build: - context: .. - dockerfile: rag-local/Dockerfile - container_name: rag-local - ports: - - "8080:8080" # 统一端口:前后端一体 - volumes: - - /data/rag-local/data:/app/data # 数据库(3 个 db 文件) - - /data/rag-local/workspace:/app/workspace # 上传的文档源文件 - restart: unless-stopped - -networks: - default: - name: rag-local-network -``` - -> 数据即文件:备份 = 打包 `data`(3 个 db)+ `workspace`(源文件)两个目录;迁移 = 拷贝到新机器即可。 -> 每次启动后通过 `docker compose logs rag-local` 查看访问令牌(每次重启都会生成新令牌)。 - ---- - -## 11. 开发与构建命令 - -```bash -# 后端 -cd rag-local -go mod tidy -go get modernc.org/sqlite@v1.47.0 # 关键:向量扩展依赖 -go build ./... # 验证编译(见 §2.1 风险提示) -go run main.go # 启动日志中查看访问令牌 - -# 前端 -cd rag-local/ui-src -npm install -npm run build # 产物 ui-src/dist,Go 统一端口托管 -npm run dev # 开发期(Vite + proxy) - -# 部署 -docker compose up -d --build -docker compose logs rag-local # 查看访问令牌 -``` - ---- - -## 12. 实施步骤(里程碑) - -| 阶段 | 内容 | 验收标准 | -|---|---|---| -| **M1 骨架** | go mod、config.yml 三库、common 层(http/auth/base_dao/cache)、consts、访问令牌(内存生成/登录)与 model_config 全链路(entity/dao/service/controller/dto)、ui-src 空壳 SPA 由 Go 托管 | 8080 端口可打开页面,启动日志打印令牌,登录页输入令牌可进入,3 个 db 文件生成 | -| **M2 文档流水线** | dataset/document/parse_task 全链路、解析器(txt/md/pdf/docx/html)、分块、任务轮询 | 上传文档 → 解析 → 分块 → 状态流转,前端可看 chunk 列表 | -| **M3 向量 + 全文** | 升级 modernc v1.47、验证 vec0、chunk_vec/chunk_fts 建表、gse 分词、SqliteIndexer/向量检索/FTS5 检索、RRF 融合 | 库内文档可被语义检索与关键词检索命中 | -| **M4 RAG 问答** ✅ | model_config 全链路、chat/embedding 组件工厂、问答工作流直接编排(非 Graph)、SSE 流式(含 15s 心跳)、conversation/message 落库、引用展示 | 问答页流式对话,回答有引用来源,历史消息可回溯 | -| **M5 知识图谱** ✅ | kg_entity/kg_relation 全链路、LLM 抽取(挂在解析流水线)、实体链接 + 一跳邻居注入 prompt | 图谱页可看实体/关系,问答能回答关系类问题 | -| **M6 打磨与部署** ✅ | 分块编辑(改后自动重向量化)、文档重新向量化、模型连通性测试、设置/数据集/详情三页填充、Dockerfile + docker-compose + README | docker compose up 一键启动,全功能可用 | -| **M7 合同标注** ✅ | kb_contract_task/clause/mark 三表 + 标注 poller、条款切分、多数据集召回 + LLM 判定(宁滥毋缺)、进度/断点/容错、标注版 HTML 导出、Contract.vue 页面 | 上传合同 → 逐条款标注对应法条(0-10 分 + 理由),导出标注版可打印;kill 重启后续跑不重复标注 | - ---- - -## 13. 依赖清单(go.mod) - -``` -github.com/gogf/gf/v2 v2.10.2 -github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 -modernc.org/sqlite v1.47.0 # 显式升级,内置 sqlite-vec(go.mod require) -github.com/cloudwego/eino v0.9.13 # RAG 编排(接口 + schema) -github.com/cloudwego/eino-ext/components/document/transformer/splitter/semantic # 语义分块(四阶段管线第 3 步) -github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive # 递归分块兜底(第 4 步) -github.com/go-ego/gse v1.0.2 # 中文分词(纯 Go) -github.com/pdfcpu/pdfcpu v0.14.0 # PDF 文本抽取 -github.com/PuerkitoBio/goquery v1.12.0 # HTML 解析 -github.com/golang-jwt/jwt/v5 v5.3.1 # JWT -``` -> go.mod 中 `github.com/glebarez/go-sqlite`(v1.21.2)为 GoFrame sqlite 驱动的间接依赖(薄封装,锁定 modernc v1.23.1,被 MVS 覆盖到 v1.47.0)。 -> 主程序入口 `import _ "modernc.org/sqlite/vec"` 空导入注册 vec0 扩展。 - ---- - -## 14. 风险与备选方案 - -### 14.1 向量扩展兼容性(主要风险) - -**风险**:glebarez/go-sqlite(GoFrame 驱动链)锁定 modernc v1.23.1,MVS 升级到 v1.47.0 后可能存在 API 编译不兼容。 - -**应对**:M3 阶段第一步即验证 `go build`。若失败,按序切换: - -- **备选 A(推荐)**:自研平面扫描向量检索 —— `kb_chunk_vec` 改用普通表存向量 JSON,检索时过滤 + 内存余弦相似度排序(批量预取 + 并发分片)。10 万 chunk × 1024 维单次查询约 20–50ms,本地知识库规模完全够用。Indexer/Retriever 接口不变,仅换后端实现。 -- **备选 B**:`gosqlite.org` 模块(CGo-free,类型化 vec + FTS5 API),业务 SQL 走 GoFrame、向量/FTS 走独立连接,应用层 RRF 融合。 -- **备选 C**:LanceDB(文件型向量库,纯 Go SDK),向量独立目录存储。 - -### 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 单事务(参照 video-factory 单机场景)。**改进方向(未实现)**:连接串初始化时执行 `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` 配置),维度不匹配的库需清库重建。 - ---- - -## 15. 与 video-factory 的差异点说明 - -| 项 | video-factory | rag-local | -|---|---|---| -| 数据库 | 3 个 SQLite(业务/系统/财务) | 3 个 SQLite(业务/系统/问答) | -| 向量/全文 | 无 | **SQLite 同库内嵌**(vec0 + FTS5),无额外服务 | -| AI 层 | 自研 ReAct agent(openai 直连) | **Eino** 组件化编排(Indexer/Retriever/Graph) | -| 鉴权 | 多用户 + 角色 + JWT(user/login_log 表) | **单用户访问令牌**:每次启动重新生成(内存持有不落库)打印,登录换取 JWT,无用户表 | -| 前端 | HTML + 少量 Vue | 纯 Vue 3 SPA(构建产物 Go 托管) | -| 异步任务 | 视频生成轮询 | 文档解析/向量化 + 合同条款标注 双轮询(任务级单 goroutine 串行,任务内热点用 grpool 协程池并行,见 §7.9) | -| 其余 | 分层/路由/鉴权/缓存/部署模式 | 完全对齐 | diff --git a/kb/controller/contract_controller.go b/kb/controller/contract_controller.go index 2d33d6a..f40b1d5 100644 --- a/kb/controller/contract_controller.go +++ b/kb/controller/contract_controller.go @@ -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 } diff --git a/kb/controller/dataset_controller.go b/kb/controller/dataset_controller.go index 4a87742..bb73936 100644 --- a/kb/controller/dataset_controller.go +++ b/kb/controller/dataset_controller.go @@ -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 diff --git a/kb/controller/document_controller.go b/kb/controller/document_controller.go index 4a20509..ea99697 100644 --- a/kb/controller/document_controller.go +++ b/kb/controller/document_controller.go @@ -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 } diff --git a/kb/dao/contract_clause_dao.go b/kb/dao/contract_clause_dao.go index a8bbbac..3ce5936 100644 --- a/kb/dao/contract_clause_dao.go +++ b/kb/dao/contract_clause_dao.go @@ -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 -} diff --git a/kb/dao/contract_mark_dao.go b/kb/dao/contract_mark_dao.go index 2c7d5f0..b827c6b 100644 --- a/kb/dao/contract_mark_dao.go +++ b/kb/dao/contract_mark_dao.go @@ -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 -} diff --git a/kb/dao/contract_risk_dao.go b/kb/dao/contract_risk_dao.go index 0f4ae60..2d37f62 100644 --- a/kb/dao/contract_risk_dao.go +++ b/kb/dao/contract_risk_dao.go @@ -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 -} diff --git a/kb/dao/contract_task_dao.go b/kb/dao/contract_task_dao.go index 07cabc0..4607cd0 100644 --- a/kb/dao/contract_task_dao.go +++ b/kb/dao/contract_task_dao.go @@ -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() +} diff --git a/kb/service/annotation_service.go b/kb/service/annotation_service.go index 7ac000b..839c8b9 100644 --- a/kb/service/annotation_service.go +++ b/kb/service/annotation_service.go @@ -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 != "" { diff --git a/kb/service/document_service.go b/kb/service/document_service.go index 588f308..ed08746 100644 --- a/kb/service/document_service.go +++ b/kb/service/document_service.go @@ -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 diff --git a/技术设计.md b/技术设计.md new file mode 100644 index 0000000..653442f --- /dev/null +++ b/技术设计.md @@ -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` 删 `mark(join 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` KNN(L2 距离)预取 `topK*4` 后 join `kb_chunk` 按 dataset_id 过滤,取 20 +2. **关键词检索**:query → `TokenizeQuery` 分词(引号词组 OR 语义、过滤 <2 rune 与 FTS 特殊字符)→ FTS5 MATCH(bm25 负分升序)取 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 时已强制绑定,见下) + ▼ +StartParsePoller(main.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 splitter(MinChunkSize=chunkSize/2) + │ d. 递归兜底:eino recursive(KeepTypeEnd) + │ 5. 批量 Embedding → InsertAll(chunk+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 15,annotation_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.", 默认值)` 读取;`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 token,SetAccessToken 存入内存 + │ (common/auth.go 包级变量,不落库;app_config 仅存分块默认值,无 access_token 键) + ▼ +启动日志打印(main.go): + ============================================ + 访问令牌(登录用): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 + 请在登录页输入上述令牌 + ============================================ + ▼ +登录页:输入 token → POST /system-config/login → CheckAccessToken 校验 → 签发 JWT + │ JWT(HS256)claims: { role: "owner", token_fp: 指纹 },有效期 24h + ▼ +后续请求:Authorization: Bearer ,中间件校验 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` 配置),维度不匹配的库需清库重建。 +