From 9afde8e2d6f627ce2629a5925ad1e34f832e8066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Thu, 6 Aug 2026 13:25:59 +0800 Subject: [PATCH] 1 --- docs/实现方案.md | 597 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 405 insertions(+), 192 deletions(-) diff --git a/docs/实现方案.md b/docs/实现方案.md index 1941b6c..d1c0830 100644 --- a/docs/实现方案.md +++ b/docs/实现方案.md @@ -3,7 +3,7 @@ > 技术栈:GoFrame v2 + Vue 3 + Eino(字节 CloudWeGo LLM 编排框架) > 数据库:全部文件型 —— SQL = SQLite,向量 = sqlite-vec(SQLite 扩展),全文检索 = SQLite FTS5 > 部署:docker-compose 一键部署,前后端不分离,统一端口暴露 -> 鉴权:单用户 + 启动生成的访问令牌(面向边缘设备部署) +> 鉴权:单用户 + 每次启动重新生成的访问令牌(内存持有不落库,面向边缘设备部署) --- @@ -11,17 +11,19 @@ 本地私有知识库系统(面向 NAS / 小主机等边缘设备单机部署),支持: -- 文档管理:上传(txt / md / pdf / docx / html)、自动解析、分块、向量化 +- 文档管理:上传(txt / md / pdf / docx / html)、自动解析、分块、向量化(6 态解析状态机) - 混合检索:语义(向量)检索 + 关键词(全文)检索,RRF 融合排序 -- RAG 问答:基于 Eino 编排的流式对话,答案附带引用来源 +- RAG 问答:基于 Eino 组件的流式对话(直接编排,非 Graph),答案附带引用来源 +- 知识图谱:LLM 自动抽取实体/关系,图谱增强检索回答关系类问题 +- **合同法律条款标注**:上传合同按条款切分,逐条款自动标注对应法条(0-10 分 + 理由),支持导出标注版 HTML(打印/另存 PDF) - 模型可配置:对话模型 / 嵌入模型均为 OpenAI 兼容 API,可对接任意供应商(本地 Ollama、DeepSeek、硅基流动、OpenAI 等) -- 单用户访问令牌登录:token 首次启动随机生成并打印在控制台,登录页输入即可使用 +- 单用户访问令牌登录:token 每次启动随机生成、内存持有(不落库)并打印在控制台,登录页输入即可使用;重启即换新令牌 设计原则: 1. **全文件型数据库**:业务 SQL、向量、全文检索全部落地为普通文件,随数据目录一键备份/迁移,无任何外部数据库服务 2. **按业务领域分库文件**:系统 / 数据集 / 问答 三个 SQLite 文件,互不干扰 -3. **分层文件与表对齐**:每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(参照 video-factory 17 表 × 5 层的组织方式) +3. **分层文件与表对齐**:每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(13 张实体表,参照 video-factory 的表×5 层组织方式;虚拟表 vec0/fts5 由 chunk_dao 兼管) 4. **前后端不分离**:Vue 构建产物由 GoFrame 统一端口托管,单容器部署 5. **零配置**:不设用户体系与角色权限(单机单人场景),启动即用 @@ -34,8 +36,8 @@ | 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 打分;中文通过**应用层分词**(纯 Go `github.com/go-ego/gse`)写入分词列,simple tokenizer 索引 | -| LLM 编排 | Eino `github.com/cloudwego/eino` | ChatModel / Embedding 自研 OpenAI 兼容 HTTP 实现(eino-ext 为空壳,仅依赖 eino 接口);Indexer / Retriever 自研(SQLite 后端),实现 Eino 标准接口 | +| 全文检索 | 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 | 单服务单端口,数据目录挂载宿主机卷 | @@ -72,15 +74,15 @@ │ ┌──────────────┼──────────────┐ │ │ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼──────┐ │ │ │ controller │→│ service │→│ dao │ │ -│ │ (10 个文件) │ │ (10 个文件) │ │ (10 个文件) │ │ +│ │ (11 个文件) │ │ (12 个文件) │ │ (13 个文件) │ │ │ └────────────┘ └──────┬─────┘ └──────┬──────┘ │ │ │ │ │ │ ┌─────────▼──────┐ ┌────▼─────────────────────┐ │ -│ │ Eino RAG 编排 │ │ SQLite × 3(文件型) │ │ -│ │ Graph/Chain │ │ business.db: 业务表+向量+ │ │ -│ │ Indexer/Retr. │ │ FTS(vec0 + fts5) │ │ -│ │ ChatModel │ │ system.db: 令牌/配置 │ │ -│ │ Embedding │ │ chat.db: 会话/消息 │ │ +│ │ 业务编排 │ │ SQLite × 3(文件型) │ │ +│ │ RAG 问答 │ │ business.db: 业务表+向量+ │ │ +│ │ 文档解析流水线 │ │ FTS(vec0 + fts5) │ │ +│ │ 知识图谱抽取 │ │ system.db: 全局配置/模型 │ │ +│ │ 合同条款标注 │ │ chat.db: 会话/消息 │ │ │ └───────┬────────┘ └───────────────────────────┘ │ │ │ HTTP(OpenAI 兼容 API) │ └──────────────────────┼────────────────────────────────────────┘ @@ -91,14 +93,17 @@ └─────────────────┘ ``` -数据流: +数据流(三条独立链路,各自任务轮询驱动): ``` -上传文档 → 解析文本 → 分块 → Embedding 向量化 ──┐ - ├─→ business.db(chunk 表 + vec0 + fts5 同库同事务) -关键词检索(FTS5 BM25)───────────────────────┤ - ↓ -问题 → 混合检索(向量 + 关键词 + RRF 融合)→ 组装上下文 → ChatModel 流式回答 → SSE +① 语料入库:上传文档 → ParseFile 解析 → 分块 → Embedding 向量化 ──┐ + 关键词索引(FTS5 BM25)───────────────────────────────────────┤ + ↓ │ + business.db(chunk 表 + vec0 + fts5 同库同事务)──┐ +② 问答:问题 → 混合检索(向量 + 关键词 + RRF 融合 + LLM 重排门槛)→ 组装上下文 ─┤ + (+ 知识图谱图增强)→ ChatModel 流式回答 → SSE(引用 [编号] 定位到分块) │ +③ 合同标注:上传合同 → 按条款切分 → 逐条款多数据集召回(vec+FTS,宁滥毋缺) + → LLM 判定(0-10 分 + 理由)→ 标注落库 → 进度递增 → 导出标注版 HTML ``` --- @@ -107,7 +112,7 @@ ``` rag-local/ -├── main.go # 入口:路由注册、静态资源托管、任务轮询启动、访问令牌生成/打印 +├── main.go # 入口:路由注册、静态资源托管、双任务轮询启动、访问令牌生成(每次启动重新生成)/打印 ├── config.yml # 多库配置 + 服务配置 ├── go.mod / go.sum ├── Dockerfile # 多阶段构建(ui-builder → builder → runtime) @@ -124,7 +129,9 @@ rag-local/ │ ├── docx_parser.go # zip+xml 解析 word/document.xml │ ├── html_parser.go # goquery 解析 HTML │ ├── text_parser.go # txt / md -│ └── tokenizer.go # gse 中文分词(写索引/检索共用) +│ ├── tokenizer.go # gse 中文分词(写索引/检索共用) +│ ├── util.go # RandomToken(随机文件名/令牌)、TokenFingerprint(SHA-256 指纹) +│ └── parser_test.go # 解析器单元测试 ├── kb/ # 业务模块(knowledge base,对应 video-factory 的 shortdrama) │ ├── consts/ │ │ ├── table_name.go # 表名常量 + 数据库组常量(DbGroupSystem 等) @@ -132,12 +139,12 @@ rag-local/ │ │ ├── content_type.go # 文件类型 / 模型类型(chat/embedding) │ │ └── consts.go # 通用常量(向量维度默认值、TopK、access_token 配置键) │ ├── model/ -│ │ ├── entity/ # 10 个文件,与 10 张实体表一一对应 -│ │ ├── dto/ # 10 个文件,与 entity 对应(含 g.Meta 路由定义) +│ │ ├── entity/ # 12 个文件,与 12 张实体表一一对应(app_config 无独立 entity) +│ │ ├── dto/ # 11 个文件(chunk/contract 各含多组 Req/Res;含 g.Meta 路由定义) │ │ └── domain/ # 领域对象(RAG 检索结果、引用来源、流式事件等) -│ ├── dao/ # 10 个文件,与实体表一一对应 -│ ├── service/ # 10 个文件,与 dao 对应(文档流水线、RAG 问答编排归入对应 service) -│ ├── controller/ # 10 个文件,与 service 对应 +│ ├── dao/ # 13 个文件,与实体表一一对应(chunk_dao 兼管 vec0/fts5 虚拟表) +│ ├── service/ # 12 个文件,与 dao 对应(文档流水线、RAG 问答、合同标注编排归入对应 service) +│ ├── controller/ # 11 个文件,与 service 对应(合同标注路由在 contract_controller) ├── ui-src/ # Vue 3 前端工程 │ ├── package.json │ ├── vite.config.js # base:'/'、build.outDir:'dist' @@ -145,24 +152,24 @@ rag-local/ │ ├── main.js / App.vue │ ├── router/index.js # hash 路由(登录守卫) │ ├── api/ # axios 封装 + 各模块 API(与后端 dto 对齐) -│ ├── store/ # Pinia(auth / dataset 状态) +│ ├── stores/ # Pinia(auth:token 存 localStorage,401 自动登出) │ ├── views/ │ │ ├── Login.vue # 输入访问令牌登录 │ │ ├── Layout.vue │ │ ├── DatasetList.vue # 数据集列表 -│ │ ├── DatasetDetail.vue # 文档管理 + 上传 + 解析状态 +│ │ ├── DatasetDetail.vue # 文档管理 + 上传 + 解析状态(6 态进度) +│ │ ├── KgGraph.vue # 知识图谱(实体/关系列表) +│ │ ├── Contract.vue # 合同标注(上传/任务进度/条款-法条对照) │ │ ├── Chat.vue # RAG 问答(SSE 流式 + 引用来源) -│ │ └── Settings.vue # 模型配置 + 访问令牌管理 -│ └── components/ -│ ├── DocumentUpload.vue -│ ├── ChunkList.vue -│ └── MessageBubble.vue # 流式消息 + 引用折叠面板 +│ │ └── Settings.vue # 分块默认值 + 模型配置(无令牌管理,见 §8) +│ └── components/ # (无独立组件目录,组件内聚于各 views) ├── data/ # 仅数据库文件(gitignore) │ ├── business.db # 数据集领域 │ ├── system.db # 系统领域 │ └── chat.db # 问答领域 ├── workspace/ # 上传的文档源文件(gitignore,与数据库分离) -│ └── {datasetId}/{yyyymmdd}/{uuid}.{ext} +│ ├── {datasetId}/{yyyymmdd}/{uuid}.{ext} # 语料文档(与 kb_document.file_path 强约束) +│ └── contract/{yyyymmdd}/{uuid16}.{ext} # 合同标注源文件(与 kb_contract_task.file_path 强约束) └── docs/ └── 实现方案.md ``` @@ -184,8 +191,8 @@ rag-local/ | 文件 | 配置组名 | 领域 | 说明 | |---|---|---|---| -| `data/business.db` | `default` | 数据集 | 数据集、文档、分块、向量(vec0)、全文索引(FTS5)、解析任务 | -| `data/system.db` | `system` | 系统 | 访问令牌、模型配置、系统配置 | +| `data/business.db` | `default` | 数据集 | 数据集、文档、分块、向量(vec0)、全文索引(FTS5)、解析任务、知识图谱(kg 两表)、合同标注(任务/条款/标注) | +| `data/system.db` | `system` | 系统 | 全局设置(app_config)、模型配置(访问令牌不落库,见 §8) | | `data/chat.db` | `chat` | 问答 | 会话、消息 | config.yml: @@ -213,31 +220,46 @@ server: workerId: 1 clientMaxBodySize: 209715200 # 200MB,支持大文件上传 requestTimeout: 3000 # 秒;支持 AI 长响应 + +# 向量配置 +vector: + dim: 1024 # 向量维度(vec0 建表维度;须与所用 embedding 模型一致,变更需清库重建) + +# AI 模型调用配置 +chat: + timeout: 600 # 对话模型 API 请求超时(秒) + max_retries: 3 # 请求失败最大重试次数 ``` -### 5.2 表清单(10 张实体表 + 2 张虚拟表) +### 5.2 表清单(13 张实体表 + 2 张虚拟表) | # | 表名 | 库 | 类型 | 说明 | |---|---|---|---|---| -| 1 | `system_config` | system | 实体 | 访问令牌、默认模型等键值配置 | -| 2 | `model_config` | system | 实体 | 模型配置(chat / embedding) | -| 3 | `kb_dataset` | business | 实体 | 数据集 | -| 4 | `kb_document` | business | 实体 | 文档 | +| 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** | 分块全文索引 | -| 8 | `kb_parse_task` | business | 实体 | 文档解析/向量化任务 | -| 9 | `kg_entity` | business | 实体 | 知识图谱实体(LLM 抽取) | -| 10 | `kg_relation` | business | 实体 | 知识图谱关系(头实体-关系-尾实体) | -| 11 | `chat_conversation` | chat | 实体 | 问答会话 | -| 12 | `chat_message` | chat | 实体 | 问答消息 | +| 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) | -> 实体表 10 张 → `entity / dao / service / controller / dto` 各 10 个文件,严格对齐。 -> 无 user / login_log / 角色权限:单用户场景,访问令牌存于 system_config。 -> 知识图谱(`kg_entity` / `kg_relation`)属数据集领域,见 §7.7。 +> 分层文件对齐(实际数量):`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 @@ -246,18 +268,17 @@ server: ```sql -- ==================== system.db ==================== -CREATE TABLE IF NOT EXISTS system_config ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - cfg_key TEXT NOT NULL DEFAULT '', - cfg_value TEXT NOT NULL DEFAULT '', - updated_at DATETIME DEFAULT (datetime('now','localtime')) +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')) ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_system_config_key ON system_config(cfg_key); --- 预置键: --- access_token 访问令牌(首次启动生成,明文存储,见 §8) --- default_chat_model 默认对话模型配置 id --- default_dataset 默认问答数据集 id +-- 预置键(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, @@ -268,6 +289,7 @@ CREATE TABLE IF NOT EXISTS model_config ( 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')) ); @@ -275,25 +297,31 @@ CREATE TABLE IF NOT EXISTS model_config ( -- ==================== 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 模型配置;切换需重新向量化 - status INTEGER NOT NULL DEFAULT 1, -- 1 正常 / 0 禁用 - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')) + 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) + 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, -- 0 待处理 1 处理中 2 完成 3 失败 + 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 '', + 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')) ); @@ -337,27 +365,72 @@ CREATE TABLE IF NOT EXISTS kb_parse_task ( 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/...) - meta TEXT NOT NULL DEFAULT '', -- JSON 扩展 - created_at DATETIME DEFAULT (datetime('now','localtime')) + 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_name ON kg_entity(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_id INTEGER NOT NULL DEFAULT 0, -- 头实体 kg_entity.id + head TEXT NOT NULL DEFAULT '', -- 头实体名(文本快照) relation TEXT NOT NULL DEFAULT '', -- 关系类型 - tail_id INTEGER NOT NULL DEFAULT 0, -- 尾实体 kg_entity.id - meta TEXT NOT NULL DEFAULT '', -- JSON:来源 chunk、置信度 + 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_head ON kg_relation(head_id); -CREATE INDEX IF NOT EXISTS idx_kg_relation_tail ON kg_relation(tail_id); +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 ==================== @@ -384,11 +457,91 @@ CREATE INDEX IF NOT EXISTS idx_chat_message_conversation ON chat_message(convers 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` 任务(重建该库全部向量 + FTS)。 +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 模式(`PRAGMA journal_mode=WAL`)提升读写并发。 +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` 各自**单 goroutine 串行**消费(3 秒间隔),不并发处理多个任务,避免 SQLite 写冲突;任务粒度(kb_parse_task / kb_contract_task)+ 子粒度(clause)断点续跑。 + +--- + +### 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。 --- @@ -397,18 +550,23 @@ CREATE INDEX IF NOT EXISTS idx_chat_message_conversation ON chat_message(convers ### 6.1 consts 层 ```go -// kb/consts/table_name.go +// kb/consts/table_name.go(与实际 13 实体表 + 2 虚拟表一一对应) const ( - TableNameSystemConfig = "system_config" - TableNameModelConfig = "model_config" - TableNameDataset = "kb_dataset" - TableNameDocument = "kb_document" - TableNameChunk = "kb_chunk" - TableNameChunkVec = "kb_chunk_vec" - TableNameChunkFts = "kb_chunk_fts" - TableNameParseTask = "kb_parse_task" - TableNameConversation = "chat_conversation" - TableNameMessage = "chat_message" + 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" ) // 数据库组 @@ -446,15 +604,17 @@ type Chunk struct { 向量与全文检索 DAO 示例: ```go -// 向量 KNN(余弦) +// 向量 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,...] - r, err := g.DB(consts.DbGroupDefault).GetCtx(ctx).Raw( - "SELECT chunk_id, vec_distance_cosine(embedding, vec_f32(?)) AS d "+ - "FROM "+consts.TableNameChunkVec+" WHERE embedding MATCH ? ORDER BY d LIMIT ?", - vecStr, vecStr, topK, - ) - ... + vecStr := vectorToJson(vector) // [0.1,0.2,...](JSON 数组字符串) + // ...执行上面的两段式 SQL,结果集为 chunkId + distance(L2 距离,越小越近) } // 全文检索(BM25) @@ -472,11 +632,13 @@ func (d *chunkDao) FtsSearch(ctx context.Context, datasetId int64, query string, ### 6.4 service 层 每表一个文件 + 业务方法(解析与分词等纯技术能力在 `common/`,业务编排全部归入对应 service): -- `system_config_service.go`:**访问令牌**生成/校验/重新生成、系统设置读写 -- `dataset_service.go`:数据集 CRUD -- `document_service.go`:上传落盘、创建文档记录、提交解析任务(解析器调 `common/parser.go`) -- `chunk_service.go`:文本分块(splitter,标题感知+800字+100重叠)、`InsertAll`(批量向量化 + 写 chunk+vec0+FTS5,等价 Indexer 职责)、分块列表、编辑(改文本后重分词) -- `parse_task_service.go`:任务队列消费(`StartParsePoller`,与 video-factory `StartVideoPoller` 同模式;解析→分块→索引全流程编排) +- `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 + 连通性测试 @@ -490,19 +652,26 @@ func (d *chunkDao) FtsSearch(ctx context.Context, datasetId int64, query string, | 路由 | 说明 | |---|---| | `POST /system-config/login` | **访问令牌登录**(body: {token} → 返回 JWT) | -| `GET/PUT /system-config` | 系统设置(默认模型、默认数据集) | -| `POST /system-config/regenerate-token` | 重新生成访问令牌(旧会话立即失效) | -| `GET/POST/PUT/DELETE /dataset` | 数据集 CRUD | -| `POST /document/upload`(multipart)`DELETE /document` `GET /document/list` | 文档管理 | -| `GET /document/{id}/chunks` `PUT /chunk` | 分块查看/编辑 | -| `POST /document/{id}/reembed` | 重新向量化 | -| `GET /parse-task/list` `POST /parse-task/{id}/retry` | 任务管理 | -| `GET/POST/DELETE /conversation` | 会话 CRUD | -| `GET /message/list` | 历史消息 | +| `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/POST/PUT/DELETE /model-config` | 模型配置 CRUD | +| `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 静态服务,鉴权放行 + 路径穿越防护) | -| `POST /model-config/{id}/test` | 连通性测试 | + +> 接口约定:**全部为 GET / POST 两种方法**(GoFrame 反射路由 + 前端 axios 封装均按此实现), +> 无 PUT/DELETE;写操作传 JSON body(或 multipart),读操作走 query params。 --- @@ -518,7 +687,7 @@ func (d *chunkDao) FtsSearch(ctx context.Context, datasetId int64, query string, | 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 | 自研(标题感知分块) | `chunk_service.go`:标题感知 + 800字 + 100重叠 | +| Splitter | 自研 SplitAuto(四阶段组合) | `chunk_service.go`:结构识别 → 标题感知 → 语义(eino)→ 递归兜底(eino),见 §7.6 | | Tokenizer | gse 中文分词 | `common/tokenizer.go`:写索引/检索共用 | ### 7.2 写入路径(`ChunkService.InsertAll`) @@ -529,10 +698,10 @@ func (s *chunkService) InsertAll(ctx context.Context, datasetId, documentId int6 ``` 流程: -1. 数据集绑定 embedding 配置时构建 `OpenAIEmbedder`(`parse_task_service` 解析流水线中完成,无配置降级仅写 FTS5) +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` +4. 末尾更新 `kb_document.chunk_count`(`status=2` 由解析流水线在分块完成时先行设置,见 §7.6) > 解析流水线为轮询任务而非图节点,故不单独实现 eino Indexer 接口,`InsertAll` 承担等价职责。 @@ -548,17 +717,18 @@ type HybridRetriever struct { func (h *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) ([]*schema.Document, error) ``` -流程: -1. **向量检索**:query → Embedding → `vec0` KNN 余弦 TopK(如 20) -2. **关键词检索**:query → gse 分词 → FTS5 MATCH TopK(如 20) -3. **RRF 融合**:`score = Σ 1/(60 + rank)`,取 TopK(如 10) -4. 回表 `kb_chunk` 取原文与元数据,组装 `schema.Document`,`doc.WithScore(score)` 记录得分与引用信息 -5. 支持 `retriever.WithTopK` / `WithScoreThreshold` 选项 +流程(实际常量:`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)) +score = Σ(1 / (60 + rank)) // RRF 分区间过窄无区分度,仅用于候选截断,最终排序以 LLM 重排分为准 ``` ### 7.4 问答工作流(chat_service.go 编排) @@ -584,39 +754,50 @@ score = Σ(1 / (60 + rank)) [1] 内容... [2] 内容... ``` -- SSE 事件顺序:`citations`(先推,含引用列表与 conversation_id)→ `delta`(增量文本)→ `done`;错误推 `error` 事件 +- 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),初始化标准中文词典(约 35 万词) -- 写索引:`gse.Cut(text)` → 过滤停用词/单字 → 空格 join → `content_tokens` -- 查询:同样流程;额外保留原 query 子串供 trigram 兜底(可选) -- 词典可随镜像内置 `dict/zh/dict.txt` +- `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}/{uuid}.ext + │ 保存文件到 workspace/{datasetId}/{yyyymmdd}/{uuid16}.{ext} │ 插入 kb_document(status=0) + kb_parse_task(status=0) + │ (上传不校验向量模型——数据集在 Save 时已强制绑定,见下) ▼ -StartParsePoller(main.go 启动,3 秒轮询) - │ 取 status=0 任务 → 置 status=1 - │ 1. 按扩展名分发解析器(txt/md 直接读;pdf 用 pdfcpu 抽取文本; +StartParsePoller(main.go 启动,3 秒轮询,单 goroutine 串行) + │ 取 status=0 任务 → 置 status=1(解析中) + │ 1. 校验 embedding 配置 → 构建 OpenAIEmbedder(无配置 → 任务失败「数据集未绑定向量模型」; + │ 数据集 Save 强制绑定向量模型「数据集必须绑定向量模型,请先选择向量模型」,此处为防御性校验) + │ 2. 按扩展名分发解析器(txt/md 直接读;pdf 用 pdfcpu 抽取文本; │ docx 解 zip 读 word/document.xml 提取段落;html 用 goquery 取 body 文本) - │ 2. 分块:优先按标题/段落切(# 标题、空行、PDF 分页), - │ 超过 max_chunk_size(800字) 时按句号/换行回退切分,重叠 100 字 - │ 3. 批量 Embedding → SqliteIndexer.Store - │ 4. 更新 document.status=2、chunk_count;任务 status=2 - │ 失败 → status=3 + error_msg,前端可重试 + │ 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 抽取,失败不阻断, + │ error_msg 记录「知识图谱未构建」原因,前端黄色标签提示) + │ 7. 完成:更新 document.status=4(已完成)、chunk_count;任务 status=2 + │ 失败 → document.status=5 + error_msg,前端可重试 ``` ### 7.7 知识图谱(LLM 抽取 + 图增强检索) 面向"数据集整体关系"类问题(如"谁与谁合作过""公司有哪些产品线"),在向量/关键词检索之外补充图谱能力。图谱**不是替代 RAG**,而是为问答注入结构化关系上下文。 -**构建(LLM 抽取,挂在解析流水线第 3.5 步)**: +**构建(LLM 抽取,挂在解析流水线向量化之后、完成之前——对应文档状态机第 3 态「图谱构建中」)**: ``` 解析 → 分块 → 向量化 ──▶ 批量抽取(每 chunk 一次 LLM 调用,JSON 输出) @@ -631,7 +812,7 @@ StartParsePoller(main.go 启动,3 秒轮询) {"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 之后)**: @@ -647,48 +828,72 @@ StartParsePoller(main.go 启动,3 秒轮询) - 实体链接用 `common/tokenizer.go` 分词 + 名称匹配,零模型调用;打分规则:问题含完整实体名 +5,命中 token 按长度加权,Top3 后名称长者优先 - 三元组作为辅助上下文注入 prompt(排序在检索片段之后),增强模型对关系类问题的回答 -- `kg_entity` / `kg_relation` 两表归属 business.db,各配独立 entity/dao/service/controller 文件(分层 10 文件对齐) +- `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` 单 goroutine 3 秒轮询): + +``` +上传合同 → 任务落库(pending) → poller 取任务 → ParseFile 解析文本 → 正则切分条款 +→ 逐条款:多 dataset 各召回(Vec 15 + FTS 15) → RRF 融合截 60 候选 +→ LLM 一次调用判定(0-10 分 + 理由, JSON) → 全保留按分降序落库 → 更新进度 +→ 全部条款完成 → 任务 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 --- ## 8. 鉴权设计(单用户 + 启动令牌) -面向边缘设备部署,不做用户体系,只有一个"门禁"级别的访问令牌: +面向边缘设备部署,不做用户体系,只有一个"门禁"级别的访问令牌。**令牌由服务进程持有**:每次启动时重新生成、仅存内存、不落库、打印到启动日志。**重启即换新令牌**,旧 JWT 全部失效——这是刻意的安全取舍(服务重启意味着重新信任环境),也是本系统的"令牌管理"方式:没有独立的管理接口,管理 = 重启服务。 ### 8.1 令牌生命周期 ``` -首次启动 - │ 读 system_config(access_token) - │ ┌─ 不存在 → 随机生成 32 位十六进制 token,写入 system_config,日志打印: - │ │ ============================================ - │ │ 访问令牌(登录用): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 - │ │ 请在登录页输入上述令牌 - │ │ ============================================ - │ └─ 已存在 → 直接打印当前 token(便于找回,重启不重新生成) +每次启动 + │ EnsureAccessToken():RandomToken(16) 生成 32 位 hex token,SetAccessToken 存入内存 + │ (common/auth.go 包级变量,不落库;app_config 仅存分块默认值,无 access_token 键) ▼ -登录页:输入 token → POST /system-config/login → 校验通过 → 签发 JWT - │ JWT claims: { role: "owner", token_fp: 指纹 },有效期 24h +启动日志打印(main.go): + ============================================ + 访问令牌(登录用): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 + 请在登录页输入上述令牌 + ============================================ ▼ -后续请求:Authorization: Bearer ,中间件校验 token_fp 与当前令牌指纹一致 +登录页:输入 token → POST /system-config/login → CheckAccessToken 校验 → 签发 JWT + │ JWT(HS256)claims: { role: "owner", token_fp: 指纹 },有效期 24h + ▼ +后续请求:Authorization: Bearer ,中间件校验 token_fp 与内存令牌指纹一致 ``` 要点: -- **指纹校验**:JWT 中携带 access_token 的 SHA-256 指纹(前 16 位 hex);鉴权中间件将 JWT 指纹与 `system_config` 中当前令牌指纹比对(带缓存),不一致即 401。因此**重新生成令牌后所有旧会话立即失效** -- **重新生成**:设置页 `POST /system-config/regenerate-token` → 生成新令牌覆盖并打印日志,前端强制跳转登录页 -- **令牌存储**:system_config 明文存储(本地单机、日志与设置页本就可见明文,且设置页需展示) -- **公开路径白名单**:`/system-config/login`、`GET /`、`GET /assets/*`(hash 路由下 SPA 只请求这两类静态路径,无鉴权绕过);`GET /workspace/*` 前缀放行(同 video-factory `/workspace/*`:浏览器下载/预览请求不带 Authorization),仅做路径穿越防护;其余路径一律校验 -- 无注册、无角色、无用户表;登录日志不落库(如需审计可在日志文件输出) +- **内存持有,不落库**:`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(伪码) +// 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 || claims.TokenFp != currentTokenFingerprint() { - r.Response.WriteJson(401); r.Exit(); return - } + if err != nil { 401 "登录已过期,请重新登录"; return } + if claims.TokenFp != AccessTokenFingerprint() { 401 "访问令牌已变更,请重新登录"; return } + r.SetCtxVar("role", claims.Role) r.Middleware.Next() } ``` @@ -699,26 +904,30 @@ func Auth(r *ghttp.Request) { ### 9.1 工程 -- `ui-src/` 独立 Vite 工程:`vite.config.js` 设 `base: '/'`、`build.outDir: 'dist'` +- `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`、`vue-router@4`、`pinia`、`element-plus`、`axios` +- 依赖:`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 页面 | 页面 | 功能 | |---|---| -| 登录页 | 输入访问令牌登录(提示语:令牌见服务启动日志 / `docker compose logs`);无注册入口 | -| 数据集列表 | 卡片式列表、新建/删除/设置(绑定 embedding 模型) | -| 数据集详情 | 文档上传(拖拽 + 进度)、文档列表(解析状态/分块数/失败重试)、分块预览与编辑 | -| 问答页 | 左侧会话列表,右侧消息流;SSE 流式渲染,答案带引用折叠面板(点击定位到分块原文) | -| 设置页 | 模型配置 CRUD(chat/embedding)、连通性测试;**访问令牌管理**(查看/复制/重新生成,重新生成后强制重新登录) | +| 登录页 | 输入访问令牌登录(提示语「请输入服务启动时打印在控制台的访问令牌」,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/http.js`:axios 实例(baseURL `/`、token 注入、401 跳转、错误 toast) -- `api/xxx.js`:按后端模块组织,与 dto 对齐 -- 流式:`fetch` + `ReadableStream` 解析 SSE(`data: {"content":"..."}` 增量 / `data: {"citations":[...]}` 引用 / `data: [DONE]`) +- `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 秒心跳注释行静默跳过) --- @@ -786,7 +995,7 @@ networks: ``` > 数据即文件:备份 = 打包 `data`(3 个 db)+ `workspace`(源文件)两个目录;迁移 = 拷贝到新机器即可。 -> 首次启动后通过 `docker compose logs rag-local` 查看访问令牌。 +> 每次启动后通过 `docker compose logs rag-local` 查看访问令牌(每次重启都会生成新令牌)。 --- @@ -817,28 +1026,32 @@ docker compose logs rag-local # 查看访问令牌 | 阶段 | 内容 | 验收标准 | |---|---|---| -| **M1 骨架** | go mod、config.yml 三库、common 层(http/auth/base_dao/cache)、consts、system_config(令牌生成/登录)与 model_config 全链路(entity/dao/service/controller/dto)、ui-src 空壳 SPA 由 Go 托管 | 8080 端口可打开页面,启动日志打印令牌,登录页输入令牌可进入,3 个 db 文件生成 | +| **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 组件工厂、Eino Graph 工作流、SSE 流式、conversation/message 落库、引用展示 | 问答页流式对话,回答有引用来源,历史消息可回溯 | +| **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 一键启动,全功能可用 | +| **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.x -github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.x -modernc.org/sqlite v1.47.0+ # 显式升级,内置 sqlite-vec -github.com/cloudwego/eino # RAG 编排 -github.com/cloudwego/eino-ext # chat/openai、embedding/openai -github.com/go-ego/gse # 中文分词(纯 Go) -github.com/pdfcpu/pdfcpu # PDF 文本抽取 -github.com/PuerkitoBio/goquery # HTML 解析 -github.com/golang-jwt/jwt/v5 # JWT +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 扩展。 --- @@ -856,15 +1069,15 @@ github.com/golang-jwt/jwt/v5 # JWT ### 14.2 中文检索质量 -FTS5 召回依赖 gse 分词质量;专有名词(人名/产品名)可能切碎。缓解:检索时同时保留 trigram 兜底,或对命中率低的 query 降级为向量检索为主。 +FTS5 召回依赖 gse 分词质量;专有名词(人名/产品名)可能切碎。实际缓解:查询端用「引号词组 OR 语义」(`TokenizeQuery`)避免 AND 空召回、向量与关键词双路召回经 RRF 融合互补。改进方向(未实现):trigram tokenizer 兜底(召回全但噪声大),或对 FTS 零命中 query 降级为纯向量检索。 ### 14.3 SQLite 写并发 -解析任务轮询与用户操作可能并发写 business.db。缓解:WAL 模式 + 任务串行消费 + 写操作集中到 service 层(参照 video-factory 单机场景)。 +解析任务轮询与用户操作可能并发写 business.db,偶发 `database is locked (5)`(**已实测,未根治**)。当前缓解:两个 poller 各自单 goroutine 串行消费 + 写操作集中到 service/dao 单事务(参照 video-factory 单机场景)。**改进方向(未实现)**:连接串初始化时执行 `PRAGMA journal_mode=WAL` 与 `PRAGMA busy_timeout=5000`(modernc 驱动支持),或 config.yml `database` 段配置 `busy_timeout` 后重试机制。 ### 14.4 切换 embedding 模型 -不同模型的向量空间不可混用。方案:数据集绑定 embedding 配置,切换时强制"重新向量化"(`reembed` 任务重建全部向量与 FTS 索引),并清空缓存。 +不同模型的向量空间不可混用。方案:数据集绑定 embedding 配置(`kb_dataset.embedding_cfg_id`),编辑数据集时检测变更 → 前端确认弹窗 → 提交 `reembed` 任务(**仅对现有分块重算向量**:`UpdateVec` 覆写 vec0,不重新解析/分块/重建 FTS)。分块参数(chunk_size/overlap)变更则触发全量 `parse` 任务(重新解析 + 重分块 + 重向量化,先清旧索引幂等重建)。两种任务均异步轮询执行,前端文档列表看进度;切换维度不同的模型时需注意 vec0 建表维度(`vector.dim` 配置),维度不匹配的库需清库重建。 --- @@ -875,7 +1088,7 @@ FTS5 召回依赖 gse 分词质量;专有名词(人名/产品名)可能切 | 数据库 | 3 个 SQLite(业务/系统/财务) | 3 个 SQLite(业务/系统/问答) | | 向量/全文 | 无 | **SQLite 同库内嵌**(vec0 + FTS5),无额外服务 | | AI 层 | 自研 ReAct agent(openai 直连) | **Eino** 组件化编排(Indexer/Retriever/Graph) | -| 鉴权 | 多用户 + 角色 + JWT(user/login_log 表) | **单用户访问令牌**:启动生成打印,登录换取 JWT,无用户表 | +| 鉴权 | 多用户 + 角色 + JWT(user/login_log 表) | **单用户访问令牌**:每次启动重新生成(内存持有不落库)打印,登录换取 JWT,无用户表 | | 前端 | HTML + 少量 Vue | 纯 Vue 3 SPA(构建产物 Go 托管) | -| 异步任务 | 视频生成轮询 | 文档解析/向量化轮询 | +| 异步任务 | 视频生成轮询 | 文档解析/向量化 + 合同条款标注 双轮询(各单 goroutine 串行) | | 其余 | 分层/路由/鉴权/缓存/部署模式 | 完全对齐 |