42 KiB
rag-local 本地知识库 技术设计
文档定位:项目功能与使用见 README.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 中显式升级:Go modules 最小版本选择(MVS)会使全链路统一使用 v1.47+;glebarez 为薄封装,API 兼容。主程序只需:go get modernc.org/sqlite@v1.47.0import _ "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 关键设计决策
- 向量与业务同库同事务:
kb_chunk_vec与kb_chunk同在 business.db。删除文档时,chunk → vec → fts在一个事务内删除,无一致性问题。 - 数据库与源文件分离:
data/仅存 3 个 db 文件;上传源文件存workspace/。备份 = 两个目录分别打包(db 小而关键、workspace 大而可重建),清理与迁移互不影响。 - 向量维度跟随模型:
model_config.dimension决定 vec0 建表维度;数据集绑定 embedding 配置(kb_dataset.embedding_cfg_id,保存与解析均强制校验,未绑定不可保存/任务失败)。切换 embedding 模型时前端确认弹窗提示,触发reembed任务(仅重算全部向量,不重新解析;分块参数变更才触发全量parse重新分块)。 - 中文分词在应用层:SQLite 内置 tokenizer 无法正确切分中文。写入 FTS5 时用 gse 分词后以空格连接存入
content_tokens;查询时对 query 同样分词。备选:trigram tokenizer(召回差但零依赖)。 - FTS5 表自包含:只存
chunk_id + dataset_id + title + content_tokens,原文在kb_chunk,命中后回表 join 取原文与元数据,避免 FTS 表膨胀。 - 解析任务用表驱动:
kb_parse_task轮询模式,单 goroutine 串行消费,避免 SQLite 并发写冲突。 - SQLite 并发:3 个库文件各自独立连接;写操作集中在任务轮询 goroutine 与用户操作。当前未启用 WAL / busy_timeout(代码无 PRAGMA),并发写 business.db 偶发
database is locked (5),缓解措施与实际缺口见 §5.6 / §14.3。 - 文档状态机 6 态:解析 → 向量生成 → 图谱构建分三段推进(
0 待处理/1 解析中/2 向量生成中/3 图谱构建中/4 已完成/5 失败)。图谱抽取失败不阻断完成:文档仍置 4,error_msg记录「知识图谱未构建」原因,前端以黄色标签提示(不再出现"显示已完成但图谱没建完"的假象)。 - 合同标注宁滥毋缺:标注业务的召回策略与问答相反——问答要精(topK=5 + 重排门槛 max(最高分×50%, 6)),标注宁滥毋缺(漏标比多标严重)。召回放宽(每数据集向量+FTS 各 15 条)、不做重排门槛、全部候选交 LLM 判定后保留(含 0 分),见 §7.8。
- 轮询任务并发模型:
StartParsePoller与StartAnnotationPoller各自gtimer 单例定时器串行消费(5 秒间隔,job 未结束不重入),不并发处理多个任务,避免 SQLite 写冲突;任务粒度(kb_parse_task / kb_contract_task)+ 子粒度(clause)断点续跑。任务内部热点(kg 逐 chunk 抽取、标注逐条款、多数据集召回、问答双路检索+图增强)用 grpool 协程池并行化,池大小 config.ymlpool段配置——并行段只做读查询与 LLM/Embedding 调用,SQLite 写全部收敛回主 goroutine 串行(见 §7.9)。 - SQL 单表约束:每个 SQL 只访问一张表,禁止 JOIN 与跨表子查询(
IN (SELECT ...)/EXISTS);跨表数据拆多条单表 SQL + 应用层内存组装(先取外键 id 列表再IN目标表,IN参数按 ≤100 分批防 SQLite 999 变量上限)。影响实例:vec0 检索(无 dataset 列,先 vec 候选再按 chunk 表过滤)、实体来源溯源(4 条单表查询内存组装)、文档图谱判定(先 chunk id 再数关系)、合同标注按任务查询(先 clause id 再 IN 分批)。
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 |
强约束规则:
- file_path 存相对路径(不含
workspace/前缀),统一经filepath.Join("workspace", FilePath)组装绝对路径读写,禁止散落的绝对路径拼接。 - 文件名不可信:上传文件一律
RandomToken(16)随机重命名,数据库不存用户原始路径(filename仅作展示名);按日期分子目录,天然按月/日归档。 - 同生共死顺序:新增 = 先写文件成功 → 再插记录(插失败删文件);删除 = 先删数据成功 → 再删文件(删文件失败仅记日志,孤儿文件可接受;反之删了文件留着记录会导致解析/导出直接报错)。
- 删除一致性:
- 删文档:
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文件
- 删文档:
- 备份即两目录打包:
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(删会话级联删消息)
删除级联矩阵(已实现行为,删除动作均落库失败回滚):
| 删除动作 | 级联行为 |
|---|---|
| 删数据集 | 存在文档记录(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 模型后问答组件构建失败(缺口见下) |
已知弱引用/缺口清单(数据关联审查结论,按影响排序):
kb_contract_task.dataset_ids逗号字符串:无法外键约束,是刻意取舍(任务标注留存快照自洽);改进方向 = 关联表kb_contract_task_dataset(未做)。chat_conversation.dataset_id:数据集删除后成孤儿引用,问答时需容错(当前已跳过检索,未报错)。model_config删除校验不完整:已挡embedding_cfg_id绑定,但is_default(chat 默认模型)未拦截——删除默认 chat 模型后问答组件构建失败,应补。kg无独立删除接口:DeleteByDataset为死代码;数据集删除被「有文档拒绝」约束,实际 kg 清理只发生在删文档路径(DocumentService.Delete与chunk_dao.DeleteByDocument各有一次 kg 删除,职责重叠)。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)
func (s *chunkService) InsertAll(ctx context.Context, datasetId, documentId int64,
chunks []string, embedder embedding.Embedder) error
流程:
- embedder 由调用方注入且解析路径强制非空:
parse_task_service解析流水线中按数据集绑定的 embedding 配置构建OpenAIEmbedder——Save与解析任务双重校验embedding_cfg_id,未绑定直接任务失败(「数据集未绑定向量模型」),不存在降级路径 - 按
EmbedBatchSize(16)批量调用Embedder.EmbedStrings生成向量 - 逐 chunk 同一事务:
kb_chunk自增 id →kb_chunk_vec(chunk_id, vec_f32(?))→ gse 分词后kb_chunk_fts(chunk_id, dataset_id, title, content_tokens) - 末尾更新
kb_document.chunk_count(status=2由解析流水线在分块完成时先行设置,见 §7.6)
解析流水线为轮询任务而非图节点,故不单独实现 eino Indexer 接口,
InsertAll承担等价职责。
7.3 自研 HybridRetriever(读取路径)
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):
- 向量检索:query → Embedding →
vec0KNN(L2 距离)预取topK*4候选,再单表查kb_chunk按 dataset_id 过滤(单表约束:内存组装,候选已按距离排序故过滤后取前 topK 等价原 JOIN),取 20 - 关键词检索:query →
TokenizeQuery分词(引号词组 OR 语义、过滤 <2 rune 与 FTS 特殊字符)→ FTS5 MATCH(bm25 负分升序)取 20 - RRF 融合:
score = Σ 1/(60 + rank)全局融合,截RerankTopK=10 - LLM 重排:
rerankByLLM一次调用为 10 个候选打 0-10 分(候选截RerankMaxChars=300字),门槛 = max(最高分×RerankKeepRatio=0.5,RerankMinScore=7),低于门槛剔除;重排 prompt 含严格相关性指令:仅当段落直接回答问题的具体情境(如偷窃、归还、退赃)才给高分,泛化提及概念(如犯罪、处罚)的条款必须给低分 0-3——实测重排器对泛化条款会宽松打 9 分(2026-08-12 "偷了又放回"问答中共同犯罪条款无关却得 9 分进引用);重排模型调用关闭思考链(chat_template_kwargs: {"enable_thinking": false})+ 输出上限RerankMaxTokens=1024——Qwen3.5 思考链默认开启且思考文本直接混入 content,无上限时输出千级 token"Thinking Process"长文(~5 tok/s 下拖至数分钟超时,2026-08-12 实测挂起 7 分钟即此因) - 按重排分降序截
HybridTopK=5,回表kb_chunk取原文与元数据,组装schema.Document与引用信息 - 支持
retriever.WithTopK/WithScoreThreshold选项
// 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)
- 引用排序:单次流水线路径
buildCitations按检索结果顺序(重排分降序)编号;多轮 ReAct 工具路径aggregateCitations按 chunk_id 去重后按重排分 Score 降序重新编号——2026-08-12 实测多轮路径曾按轮次累积顺序输出,导致高分引用排后面、顺序与提示词 [N] 不一致 - Agent 路径强制首轮预检索:
askAgent循环开始前先无条件执行一次executeSearchTool(retrieve + GraphEnhance 并行,失败仅告警不阻断),结果以[N] 内容+ 图谱三元组注入首轮 user 消息,引用列表随循环首轮推送——检索不依赖模型自觉调用 search:2026-08-12 实测 Qwen3.5-9B 思考链关闭后偶发跳过工具调用直接凭自身知识作答(oMLX 日志该次仅 1 次 chat completion、0 次 embedding 请求),引用列表落库[]、回答中 [N] 为模型自编;模型后续仍可调用 search 补充检索,chunk_id 去重防重复引用
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:
{"entities": [{"name": "张明", "type": "person"}], "relations": [{"head": "张明", "relation": "任职于", "tail": "XX科技"}]} - 实体按
(dataset_id, name)去重(同一实体多 chunk 出现只建一次,UNIQUE 约束 + ON CONFLICT upsert),关系带来源 chunk_id;删除文档时按分块 id 级联清理 - 实体名/关系谓词归一化(防别名分裂,见 §7.10):实体名与关系 head/tail 落库前经
common.NormalizeKgTerm(全半角、去空白/书名号/版本注记括号/「中华人民共和国」前缀、别名表全串映射),关系谓词经谓词别名表(KgPredicateAliasMap,如「颁布」→「发布」)归并;归一化后 chunk 内实体按名、关系按head|relation|tail去重。别名表为保守内置集(kb/consts/kg_alias.go),只做高置信合并,不做模糊相似度合并 - 实体多文件溯源(不建关联表,见 §7.10):有关系的实体经关系表溯源(关系不去重、每条带来源 chunk_id,JOIN
kb_chunk→kb_document得完整出现文件清单);孤立实体(无关系)由实体行chunk_id弱引用兜底(GET /kg-entity/sources,图谱页节点标注来源) - 抽取失败不阻断:无默认对话模型、或部分分块抽取失败,文档仍置 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;候选 chunk 内容批量加载(ListByIds一次 IN 查回内存映射,禁止逐条 GetOne——N+1);embedder 按 dataset 绑定的 embedding 配置构建并缓存;无候选的条款视为完成(跳过 LLM 调用,无 mark) - 判定:单次 LLM 调用,prompt 含条款全文(截
AnnoMaxClauseChars=2000)+ 编号候选,输出{"risks":[{"level":"high|mid|low","desc":"...","laws":[{"cand":1,"law_item":"第九十二条"}]}]}(无风险输出空数组);law_item 由 LLM 判定输出(法条编号,第X条正则兜底提取);JSON 解析容错:剥 Markdown 代码围栏 → 截{...}对象区间解析 → 对象解析失败回退裸数组[...](按 risks 列表解析,[]即无风险)→ 仍失败才记该条款 failed(Qwen3.5 实测偶发输出裸[]/[]",不按格式包装即解析失败,2026-08-20 修复);不做门槛过滤,全部候选按分降序保留 - 判定 prompt 上下文预算:本地对话模型为 Qwen3.5-9B-MLX-4bit,由 oMLX 托管(2026-08-12 起,原 LocalAI/gemma-4-E4B 已卸载;oMLX 单实例 :18080 同时提供 chat+embedding,见 README 模型配置)。oMLX 无 LocalAI 式跨 slot 共享上下文拒收(
max_context_window_policy=16384仅按单请求 prompt 上限拒绝,实际 prompt ≤3k token 远低于此);并发请求由调度器排队+轻量批处理,16GB 机器上并发标注会变慢但不会因上下文被拒。Qwen3.5 思考链默认开启且思考文本直接混入content字段(非独立 reasoning 字段)——应用侧所有判定/抽取/重排/agent 调用均以chat_template_kwargs: {"enable_thinking": false}关闭(DisableThinking,模板键名在 chat_template.jinja 核实)。生成预算max_tokens=24576(判定与知识抽取同值):实测判定自然输出仅 ~900-1100 token,24576 留 20 倍余量、截断物理上不可能。候选块按AnnoJudgePromptBudget=1500字总预算贪心填充(按 RRF 相关度降序,每条截AnnoJudgeCandidateChars=300字,首条保底入队,超预算截断后续候选)。条文精确文本不依赖 prompt 全文——extractLawItem用未截断的ContentFull抽取;LLM 引用编号受展示条数约束(越界引用丢弃) - oMLX 并发串行,条款判定降为串行:实测 oMLX 对并发请求串行调度(4 个 50-token 小请求总耗时 ≈4 倍单请求时间,典型串行);
pool.annotation_clause自 4 降为 1(2026-08-20)——串行服务下并发零加速纯排队,8 条条款以 4 并发提交时队尾条款累计等待超过chat.timeout客户端超时(任务 27 第八条context deadline exceeded (Client.Timeout...)),串行后每条独享 600s 预算不再超时,总耗时与并发排队时相当 - 快照落库:mark 存命中 chunk 的法条快照(law_title=dataset 名、law_item=LLM 判定的法条编号、content=chunk 内容截断 800 字),标注结果不随语料变更失效
- 批量落库(禁逐条 SQL):条款插入与风险快照用
Batch(100)多行 INSERT(GoFrame 一次语句写 100 行,8 列 ≈800 变量 < SQLite 999 上限);进行中/完成状态用UpdateStatuses(WHERE id IN,≤100 分批)各刷一次;进度按本地计数每完成一条刷一次UpdateProgress(不再逐条款ListByTask读库统计)。仅失败路径保留逐条UpdateStatus(error_msg 各异的罕见路径,不批量) - 来源文件标注:法条引用快照同时记录源文件名(
LawRef.source_file,如「劳动合同法.pdf」)——law_title 只是 dataset 名(如「法律」),看不出出自哪部法文件,溯源到 chunk 才能定位。判定时按单表约束拆两条 SQL(kb_chunk按 id 批量查 document_id、kb_document按 id 批量查 filename,IN ≤100 分批)内存组装;界面「法律依据」与导出 HTML 显示「来源:xx.pdf」;溯源失败仅告警不阻断标注(展示增强,非判定依据) - 容错:单条款失败(LLM 判定失败或风险落库失败)仅该条 failed(记 error_msg)不重试;失败条款计入 done_clauses(进度按「已处理条款数」计,恒 100%,2026-08-20 修复——此前失败不计数导致任务完成但进度停在 75%);任一条款失败 → 任务置 Failed,error_msg 记「N 条条款标注失败」(前端可见,2026-08-20 修复——此前任务无条件置 Done,出现「完成+75%+2 条失败」不一致);全部成功(含无风险条款)任务置 Done;未配置默认 chat 模型 → 任务失败
- LLM 调用重试:
doOpenAIRequest实现chat.max_retries(2026-08-20 落实「配置即使用」——此前 config.yml 已配置但代码未实现):仅连接类瞬时错误(连接拒绝/重置/EOF)与 5xx 重试,指数退避 1/2/4s;超时类不重试(context.DeadlineExceeded/net.Error.Timeout)——超时源于排队或慢响应,重试只会重新排队,成本翻倍 - 断点续跑:任务按
status IN (0,1)领取,clause 粒度续跑(已 done 不重复产生 mark;重跑任务先 DeleteByClause 幂等重建) - 导出:
AnnotatedHTML生成自包含 HTML(条款 + 内嵌标注,score ≥8 绿 / ≥5 蓝 / 其余灰,打印按钮window.print());controller 直接写响应体(中间件检测已写入则不包装 JSON),前端原生 fetch + 手动 Authorization 获取 blob
7.9 并行化实现形态
实现形态(kb/service/pool.go,包 init 从 g.Cfg().MustGet("pool.<key>", 默认值) 读取;grpool.New(limit) 建池,Pool.AddWithRecover 提交并防 panic):
- 知识图谱(§7.7):
ExtractDocument拆callExtract(池内 LLM+JSON 解析,不写库)/saveExtract(主 goroutine 串行 upsert/insert),「发一批、收一批」循环 - 合同标注(§7.8):
processOne条款循环提交「召回+判定」入池,主 goroutine 收结果串行清旧标/插 mark/置状态/更新进度;recallCandidates内逐 dataset 并行召回(recallOneDataset),RRF 融合回主 goroutine - 问答(§7.3/§7.4):
Ask中retrieve(ChatPool,内部再并行 vec/fts)与GraphEnhance(纯读,主 goroutine 直接跑)并行;Retrieve内 vec 段与 fts 段并行(vecRetrieve/ftsRetrieve),RRF 合并、LLM 重排保持串行 - 任务级仍由轮询器单 goroutine 串行消费(决策 10),并行只发生在任务内部
7.10 实体名归一化与多文件溯源(P0)
背景:实体按 (dataset_id, name) 精确字符串去重,法律文档全称/简称混用(「中华人民共和国刑法」vs「刑法」、「民诉法」vs「民事诉讼法」)把同一实体拆成多行,切断了跨文件隐式关联(一跳邻居以共享实体名为桥)。
归一化规则(common/kg_name_normalize.go,纯函数、幂等、保守防误合并):
| 规则 | 示例 | 边界 |
|---|---|---|
| 全角→半角、去首尾/压缩内部空白 | “中华人民共和国刑法 ” → 中华人民共和国刑法 |
— |
| 去书名号《》 | 《民事诉讼法》 → 民事诉讼法 |
— |
| 去尾部标点 | 民事诉讼法。 → 民事诉讼法 |
— |
| 去版本注记括号及内容 | 刑法(2020修正) → 刑法 |
仅剥含 年/修正/修订/施行/数字 的括号;「张三(北京分公司)」不剥 |
| 去「中华人民共和国」前缀 | 中华人民共和国刑法 → 刑法 |
仅前缀;结果为空则保留原名(防实体「中华人民共和国」被删) |
| 别名全串精确映射 | 民诉法 → 民事诉讼法 |
全串匹配非 contains 替换(防「民诉法」吃掉「民诉法解释」);映射为空保留原名 |
别名表 KgAliasMap(变体→标准名)与谓词表 KgPredicateAliasMap(如 颁布→发布、施行→实施)内置在 kb/consts/kg_alias.go,由调用方传入 common.NormalizeKgTerm(common 不依赖 kb,分层约束)。
写入路径(saveExtract):实体名、关系 head/relation/tail 先归一化 → 过滤空名/自环 → chunk 内实体按名、关系按 head|relation|tail 去重 → 落库。
溯源设计(回答"去重后怎么知道实体出现在哪些文件"):
- 实体行
chunk_id仅「最近来源」弱引用(每次 upsert 覆盖),不作为溯源依据 - 溯源组合查询(单表约束:拆 4 条单表 SQL——kg_relation 全量 head/tail+chunk_id、kg_entity 全量 name+chunk_id、kb_chunk 全量 id+document_id、kb_document 全量 id+filename,内存组装):① 关系表溯源——有关系的实体得完整出现文件清单(关系不去重天然保留来源);② 实体行弱引用兜底——孤立实体(无任何关系)至少给出最近来源文件
- 不建实体-分块关联表(
kg_entity_chunk决策否决):关联表唯一增量价值是"孤立实体的精确多文件溯源",在图谱页标注来源的展示场景价值低,而成本实打实(一张表 + 写入多一次查询 + 删文档/重建图谱/迁移三个清理点)——够用即止 - 图谱页(KgGraph.vue)节点 tooltip 与实体表格展示来源文件(
GET /kg-entity/sources)
图谱缺失判定修复:CountByDocument 原按实体行 chunk_id 计数,跨文件 upsert 覆盖后漏计导致 IsGraphMissing 误报;改为关系表 EXISTS(关系每 chunk 至少一条、不受覆盖影响)。
8. 鉴权实现细节
鉴权设计概述(令牌生命周期、公开路径白名单)见 README.md;以下为实现细节。
8.1 令牌生命周期
每次启动
│ EnsureAccessToken():RandomToken(16) 生成 32 位 hex token,SetAccessToken 存入内存
│ (common/auth.go 包级变量,不落库)
▼
启动日志打印(main.go):
============================================
访问令牌(登录用): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
请在登录页输入上述令牌
============================================
▼
登录页:输入 token → POST /system-config/login → CheckAccessToken 校验 → 签发 JWT
│ JWT(HS256)claims: { role: "owner", token_fp: 指纹 },有效期 24h
▼
后续请求:Authorization: Bearer <JWT>,中间件校验 token_fp 与内存令牌指纹一致
要点:
- 内存持有,不落库:
common/auth.go中var accessToken string包级变量;EnsureAccessToken(system_config_service.go)每次启动无条件重新生成。登录页提示语「请输入服务启动时打印在控制台的访问令牌」 - 指纹校验:JWT 中携带令牌的 SHA-256 指纹(
TokenFingerprint,取前 16 位 hex);鉴权中间件将 JWT 指纹与内存令牌指纹比对(claims.TokenFp != AccessTokenFingerprint()),不一致即 401「访问令牌已变更,请重新登录」。因此重启服务后所有旧会话立即失效,前端 401 拦截器自动跳登录页 - 无 regenerate-token 接口:
system_config_dto.go仅有/login一个接口;换令牌 = 重启服务(新令牌自动打印到日志)。设置页没有令牌管理 UI(Settings.vue 只含模型配置) - 公开路径白名单(auth_middleware.go 精确匹配):
/system-config/login、GET /、GET /assets/*(hash 路由下 SPA 只请求这两类静态路径,无鉴权绕过);GET /workspace/*前缀放行(浏览器下载/预览请求不带 Authorization),仅做路径穿越防护(拒绝..);其余路径一律校验 - 无注册、无角色、无用户表;登录日志不落库(如需审计可在日志文件输出);JWT 密钥为代码内固定常量(单机场景可接受,如需更换改
common/auth.go的jwtSecret)
// 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/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 配置),维度不匹配的库需清库重建。