Files
rag-local/技术设计.md
2026-08-20 12:05:53 +08:00

42 KiB
Raw Permalink Blame History

rag-local 本地知识库 技术设计

文档定位:项目功能与使用见 README.md,通用开发规范见 CLAUDE.md。 本文件只保留实现细节与技术决策(版本要点、表结构约定、检索参数、风险与备选方案等)。

1. 关键版本说明(向量扩展)

  • sqlite-vec 需要 modernc.org/sqlite >= v1.47.02026-03-17 起内置,免 CGO
  • GoFrame v2.10.2 驱动链默认锁定 modernc.org/sqlite v1.23.1(过旧),必须在 go.mod 中显式升级
    go get modernc.org/sqlite@v1.47.0
    
    Go modules 最小版本选择(MVS)会使全链路统一使用 v1.47+glebarez 为薄封装,API 兼容。主程序只需:
    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_veckb_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 失败)。图谱抽取失败不阻断完成:文档仍置 4error_msg 记录「知识图谱未构建」原因,前端以黄色标签提示(不再出现"显示已完成但图谱没建完"的假象)。
  9. 合同标注宁滥毋缺:标注业务的召回策略与问答相反——问答要精(topK=5 + 重排门槛 max(最高分×50%, 6)),标注宁滥毋缺(漏标比多标严重)。召回放宽(每数据集向量+FTS 各 15 条)、不做重排门槛、全部候选交 LLM 判定后保留(含 0 分),见 §7.8。
  10. 轮询任务并发模型StartParsePollerStartAnnotationPoller 各自gtimer 单例定时器串行消费(5 秒间隔,job 未结束不重入),不并发处理多个任务,避免 SQLite 写冲突;任务粒度(kb_parse_task / kb_contract_task+ 子粒度(clause)断点续跑。任务内部热点(kg 逐 chunk 抽取、标注逐条款、多数据集召回、问答双路检索+图增强)用 grpool 协程池并行化,池大小 config.yml pool 段配置——并行段只做读查询与 LLM/Embedding 调用,SQLite 写全部收敛回主 goroutine 串行(见 §7.9)。
  11. 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(先落盘成功再插记录,插记录失败回滚删文件) 解析流水线 ParseFileGET /workspace/*(下载/预览) DocumentService.Delete
合同源文件 kb_contract_task.file_path workspace/contract/{yyyymmdd}/{uuid16}.{ext} contract_controller.Upload(同上,插入失败回滚删文件) 标注流水线 AnnotationService.processOneParseFile 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_documentos.Remove 文件(chunk_dao.go 的 DeleteByDocument 单事务完成,无 FTS5 optimize
    • 删合同任务:AnnotationService.Deletemarkjoin clause 定位)→ clause → taskos.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(删会话级联删消息)

删除级联矩阵(已实现行为,删除动作均落库失败回滚):

删除动作 级联行为
删数据集 存在文档记录(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.Deletechunk_dao.DeleteByDocument 各有一次 kg 删除,职责重叠)。
  5. DocumentService.Deletechunk_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.goRetriever/组件工厂/工作流归入 chat_service.go,分词归入 common/tokenizer.go

7.1 组件清单

组件 实现 归属
ChatModel 自研 OpenAIChatModel chat_service.goOpenAI 兼容 /chat/completionsGenerate/Stream),baseURL 可配,兼容 Ollama/DeepSeek/OpenAI 等
Embedding 自研 OpenAIEmbedder chat_service.goOpenAI 兼容 /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

流程:

  1. embedder 由调用方注入且解析路径强制非空parse_task_service 解析流水线中按数据集绑定的 embedding 配置构建 OpenAIEmbedder——Save 与解析任务双重校验 embedding_cfg_id,未绑定直接任务失败(「数据集未绑定向量模型」),不存在降级路径
  2. EmbedBatchSize16)批量调用 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_countstatus=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=20RrfK=60RerankTopK=10HybridTopK=5):

  1. 向量检索query → Embedding → vec0 KNNL2 距离)预取 topK*4 候选,再单表查 kb_chunk 按 dataset_id 过滤(单表约束:内存组装,候选已按距离排序故过滤后取前 topK 等价原 JOIN),取 20
  2. 关键词检索query → TokenizeQuery 分词(引号词组 OR 语义、过滤 <2 rune 与 FTS 特殊字符)→ FTS5 MATCHbm25 负分升序)取 20
  3. RRF 融合score = Σ 1/(60 + rank) 全局融合,截 RerankTopK=10
  4. LLM 重排rerankByLLM 一次调用为 10 个候选打 0-10 分(候选截 RerankMaxChars=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 分钟即此因)
  5. 按重排分降序截 HybridTopK=5,回表 kb_chunk 取原文与元数据,组装 schema.Document 与引用信息
  6. 支持 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.9Askretrievecommon.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 循环开始前先无条件执行一次 executeSearchToolretrieve + GraphEnhance 并行,失败仅告警不阻断),结果以 [N] 内容 + 图谱三元组注入首轮 user 消息,引用列表随循环首轮推送——检索不依赖模型自觉调用 search2026-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 时已强制绑定,见下)
   ▼
StartParsePollermain.go 启动,gtimer 单例 5 秒轮询,job 未结束不重入)
   │  取 status=0 任务 → 置 status=1(解析中)
   │  1. 校验 embedding 配置 → 构建 OpenAIEmbedder(无配置 → 任务失败「数据集未绑定向量模型」;
   │     数据集 Save 强制绑定向量模型「数据集必须绑定向量模型,请先选择向量模型」,此处为防御性校验)
   │  2. 按扩展名分发解析器(txt/md 直接读;pdf 用 pdfcpu 抽取文本;
   │     docx 解 zip 读 word/document.xml 提取段落;html 用 goquery 取 body 文本)
   │  3. 先 DeleteByDocument 清旧索引(chunk+vec+fts+kg 单事务,重解析幂等)
   │  4. SplitAuto 四阶段组合分块(document → status=2 向量生成中):
   │     a. 结构识别:8 种单元模式 + 3 种上下文模式(条文/章节/编号等),命中达到阈值
   │        自动采用,unit_pattern/context_pattern 落库;命中则按结构切分(超长单元截断续标)
   │     b. 标题感知:SplitText 按标题/段落切
   │     c. 语义分块:eino semantic splitterMinChunkSize=chunkSize/2
   │     d. 递归兜底:eino recursiveKeepTypeEnd
   │  5. 批量 Embedding → InsertAllchunk+vec0+FTS5 同事务,按 EmbedBatchSize=16 分批)
   │  6. 知识图谱抽取(document → status=3 图谱构建中;逐 chunk LLM 抽取并发执行
   │     kg_extract 池,见 §7.9),失败不阻断,error_msg 记录「知识图谱未构建」原因,
   │     前端黄色标签提示)
   │  7. 完成:更新 document.status=4(已完成)、chunk_count;任务 status=2
   │  失败 → document.status=5 + error_msg,前端可重试

7.7 知识图谱(LLM 抽取 + 图增强检索)

面向"数据集整体关系"类问题(如"谁与谁合作过""公司有哪些产品线"),在向量/关键词检索之外补充图谱能力。图谱不是替代 RAG,而是为问答注入结构化关系上下文。

构建(LLM 抽取,挂在解析流水线向量化之后、完成之前——对应文档状态机第 3 态「图谱构建中」)

解析 → 分块 → 向量化 ──▶ 批量抽取(每 chunk 一次 LLM 调用,JSON 输出;经 kg_extract 池并发,
                             池内只做 LLM 调用,upsert/insert 收敛回主 goroutine 串行,见 §7.9
                              ▼
              {entities:[{name, type}], relations:[{head, relation, tail}]}
                              ▼
              upsert kg_entity(按 dataset_id+name 去重)→ 写 kg_relation(带来源 chunk_id
  • 复用 M4 的 chat 组件工厂,抽取 Prompt 要求模型只输出 JSON
    {"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_chunkkb_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,标注时多选)。

与问答检索的本质差异——宁滥毋缺:问答策略 HybridRetrievertopK=5 截断 + 重排门槛 max(最高分×50%, 6))不适用于标注——topK 截断会漏标、6 分门槛会杀光「段落对段落」的弱相关匹配。标注业务漏标比多标严重:召回放宽(AnnoRecallTopK=15/数据集,向量+FTS 各 15 条)、RRF 融合截 60 候选、不做重排门槛、全部候选交 LLM 判定后保留(含 0 分)。

流水线annotation_service.goStartAnnotationPoller gtimer 单例 5 秒轮询):

上传合同 → 任务落库(pending) → poller 取任务 → ParseFile 解析文本 → 正则切分条款
→ 逐条款并发(annotation_clause 池):多 dataset 各召回(Vec 15 + FTS 15annotation_dataset 池并行)
→ RRF 融合截 60 候选 → LLM 一次调用判定(0-10 分 + 理由, JSON)
→ 主 goroutine 串行落库(清旧标→插 mark→置状态→更新进度)
→ 全部条款完成 → 任务 done(断点:重启后按 clause 状态续跑,已 done 条款跳过)
  • 条款切分:按优先级探测三种行首正则(第X条 / \d+(.\d+)*[、.] / 中文数字[、.]),命中 ≥2 采用,否则整篇单条(title=「全文」);title=标记、content=标记行至下一标记全文
  • 召回:每 dataset 各调 VecSearch(dsId, vec, 15) + FtsSearch(dsId, 分词截 200 字, 15),全局按 RRF(1/(RrfK+rank+1)) 融合截 AnnoMaxCandidates=60;候选 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 token24576 留 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) 多行 INSERTGoFrame 一次语句写 100 行,8 列 ≈800 变量 < SQLite 999 上限);进行中/完成状态用 UpdateStatusesWHERE 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%);任一条款失败 → 任务置 Failederror_msg 记「N 条条款标注失败」(前端可见,2026-08-20 修复——此前任务无条件置 Done,出现「完成+75%+2 条失败」不一致);全部成功(含无风险条款)任务置 Done;未配置默认 chat 模型 → 任务失败
  • LLM 调用重试doOpenAIRequest 实现 chat.max_retries2026-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):ExtractDocumentcallExtract(池内 LLM+JSON 解析,不写库)/ saveExtract(主 goroutine 串行 upsert/insert),「发一批、收一批」循环
  • 合同标注(§7.8):processOne 条款循环提交「召回+判定」入池,主 goroutine 收结果串行清旧标/插 mark/置状态/更新进度;recallCandidates 内逐 dataset 并行召回(recallOneDataset),RRF 融合回主 goroutine
  • 问答(§7.3/§7.4):AskretrieveChatPool,内部再并行 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 tokenSetAccessToken 存入内存
   │  common/auth.go 包级变量,不落库)
   ▼
启动日志打印(main.go):
   ============================================
   访问令牌(登录用): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
   请在登录页输入上述令牌
   ============================================
   ▼
登录页:输入 token → POST /system-config/login → CheckAccessToken 校验 → 签发 JWT
   │  JWTHS256claims: { role: "owner", token_fp: 指纹 },有效期 24h
   ▼
后续请求:Authorization: Bearer <JWT>,中间件校验 token_fp 与内存令牌指纹一致

要点:

  • 内存持有,不落库common/auth.govar accessToken string 包级变量;EnsureAccessTokensystem_config_service.go)每次启动无条件重新生成。登录页提示语「请输入服务启动时打印在控制台的访问令牌」
  • 指纹校验JWT 中携带令牌的 SHA-256 指纹(TokenFingerprint,取前 16 位 hex);鉴权中间件将 JWT 指纹与内存令牌指纹比对(claims.TokenFp != AccessTokenFingerprint()),不一致即 401「访问令牌已变更,请重新登录」。因此重启服务后所有旧会话立即失效,前端 401 拦截器自动跳登录页
  • 无 regenerate-token 接口system_config_dto.go 仅有 /login 一个接口;换令牌 = 重启服务(新令牌自动打印到日志)。设置页没有令牌管理 UISettings.vue 只含模型配置)
  • 公开路径白名单auth_middleware.go 精确匹配):/system-config/loginGET /GET /assets/*(hash 路由下 SPA 只请求这两类静态路径,无鉴权绕过);GET /workspace/* 前缀放行(浏览器下载/预览请求不带 Authorization),仅做路径穿越防护(拒绝 ..);其余路径一律校验
  • 无注册、无角色、无用户表;登录日志不落库(如需审计可在日志文件输出);JWT 密钥为代码内固定常量(单机场景可接受,如需更换改 common/auth.gojwtSecret
// 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 端口 5173VITE_API_PROXY_TARGET 环境变量可覆盖代理目标(默认 http://localhost:8080
  • hash 路由(无需服务端 SPA fallback),路由守卫:无 JWT → 跳登录页
  • 依赖:vue@3.4vue-router@4pinia@2element-plus@2.5zh-cn 语言包全量引入)、@element-plus/icons-vueaxios@1.6echarts@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.jsaxios 实例(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=WALPRAGMA busy_timeout=5000modernc 驱动支持),或 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 配置),维度不匹配的库需清库重建。