From 15830ff0df2d22a70ba073ffdd4560a50f6e94cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Wed, 12 Aug 2026 12:17:36 +0800 Subject: [PATCH] 1 --- .gitignore | 4 ++++ README.md | 15 +++++++++++++++ data/business.db | Bin 8667136 -> 8667136 bytes data/chat.db | Bin 77824 -> 77824 bytes data/system.db | Bin 20480 -> 20480 bytes kb/consts/consts.go | 25 +++++++++++++++---------- kb/service/chat_service.go | 20 +++++++++++++++----- scripts/start_models.sh | 30 ++++++++++++++++++++++++++++++ scripts/stop_models.sh | 9 +++++++++ 技术设计.md | 4 ++-- 10 files changed, 90 insertions(+), 17 deletions(-) create mode 100755 scripts/start_models.sh create mode 100755 scripts/stop_models.sh diff --git a/.gitignore b/.gitignore index 00a5567..1dd9f82 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ backends/ # 模型权重(大文件,不提交;*.yaml 配置保留提交) models/*.gguf +models/mlx/ + +# Python 虚拟环境(MLX 模型服务) +.venv/ diff --git a/README.md b/README.md index d7ab28a..63fd27e 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,21 @@ npm run dev # Vite 开发服务器(5173),API 代理见 vite.config - **对话模型**(`/v1/chat/completions`,支持 SSE 流式):用于问答与知识图谱实体抽取 - **向量模型**(`/v1/embeddings`):用于分块向量化与语义检索;维度须与 `config.yml` 中 `vector.dim` 一致(默认 1024) +示例(本地 oMLX,Apple Silicon 推荐): + +```bash +# 模型文件放 models/mlx/omlx/(chat: Qwen3.5-9B-MLX-4bit;embedding: bge-m3), +# 依赖装在项目根 .venv/(mlx + omlx + fastapi + uvicorn) +./scripts/start_models.sh # 启动单实例 oMLX :18080,同时提供 chat + embedding +./scripts/stop_models.sh # 停止 + +# 设置页配置: +# 对话模型:endpoint http://127.0.0.1:18080/v1,模型名 Qwen3.5-9B-MLX-4bit +# 向量模型:endpoint http://127.0.0.1:18080/v1,模型名 bge-m3,维度 1024 +``` + +> 16GB 内存机器需 `sudo sysctl iogpu.wired_limit_mb=14336` 提高 Metal 上限(oMLX 内存 guard ceiling 14GB);Qwen3.5 思考链默认开启,应用侧已在 agent / 主回答 / 重排调用中通过 `chat_template_kwargs: {"enable_thinking": false}` 关闭。 + 示例(Ollama): ```bash diff --git a/data/business.db b/data/business.db index 8ae7ecf23169d4831a0134c8246b989c4ba0bf8d..a28dd05d8634868ce2e5bf027301600daeb292ad 100644 GIT binary patch delta 11250 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 A4FCWD diff --git a/data/chat.db b/data/chat.db index 5803fa7f248c3b2795c4dbd4282e2e72b6c596ea..7f1d9907b31cfe2f1d7da8945f6f7b8758b8c5cf 100644 GIT binary patch delta 4589 zcmcJTO;A%;6vvY)qX~rPN4se|(@d(413}(LKHrS1Zab|VzXikyDZF_WXGoZ?+6zU2 zD4L|8*r)}BppFQBPz`~~rmMQ?bkS})Q?(rj3| zG9WLF;(H3s~8Gpw+#r)xp}8PFU=+FDiceG zVtI$!hDhDD2#ffHy0WuackW6Cl;RTX1cl1C^OC?UPcypK&|E_8a%rK}?eY*9)>8g- zDK4RAIaV>$?$Rd;VZq$EbMsPM_pHBCTfLj!ShwBS((Vg3f94Mad~N>Lp+D>2-npgX zseu}zc2v(-e`xvL(rKxvx>QwL`Ka;}bISa=`D4>FQ5YNP>GlLp zB;=)t9Ep>GfIxtxuS}~-0fyh$LBb{nNm%qU5nU-V;|ppv;P+}?YFRkCuqf*HFTz6p z5O%r3Kf}v$n@Ucm z=BF}Ouce3Y%d-z5WO^Z->AjH|xgpPu%5xK`WKs&vLr3ArdFkRj4$tB<-@>fG52f>Q zVA<@pK+DNaKg=%7ZQx{AXNx~z;~Q+B1_QRCEp2Zi7h|`z3vKNu;1z84cR(X}JYO{F z))EnH9-ecFEgKESw|d^UaNe$fzf<4$c}-tZ4nAKTCG#*D!{|;FKIGAeJQR;>G*9_x zg)qb`YEblPH1`_PvT|_8u??`1ST=JmUjSwIk%pX#!-EXZdc|gKRIare6#_w?6_eso zjpke{ngUa#qu8XvkcD%sEsY3Du~AEn%Y(iC_2Omk7IafqKZ*?+jm@JKUNBIJi_^+9 zA}Ylr8jUGQD}Tl{VN~WGszfr$XKT$tYKTwaT z1a*&^rlzPdDnxac}6V;r- zY%%gBfy{Na1`=%xY_u2}O9GkeYISL#$znWH63ARvtF{H|Y$gj2sG6P+-Ef`NL1-P% z)A*nqh?3Aao*qFdr^#?h4-1!h9_1!*B)6XJgJmSyu@Is0G3tIyJOe1xD`$M0NsR{1 z2uY2WMnkEq6?~UY?NU|zuY3*W(tMT%^CI_?_Mm?Mj`cI3yxL1=yK!tJ_yebY-9~2|w0{M!2I;bs=G`rI#`UbH1!bma zIEq&yC>O%1rNXd(J=-IXbkk%2JMwT67UJ|?4m`^7xX(#?=TOdtBp(E$bpLvGM5C$a zShY400Twu)&Q2AErxhyLFAARB;@Uu6Z9GfeONy7;omdEB&ph#s<5&U?;o3~sp4z)K z^yu!E;vIp-}sgE7!+Kfs#r-<-82omZMjei}nF*4yk zB&At~cmM*ivzPQt0cV=*erk7+-XStKq5LJ@NWnSBoPjvXJV3dAU`RrCFg5}v+}Q1c zoXTJMNe|N{H_4PrEh8xa)!Rl~JesLENfyHZ2dy$$fD}r5TRloJ`FVtESgfx_e%%0#f>B@g1*KLe%0 z)8nEeci0AqX#N361%;P0BKCW5>|tTpNx~im`FmL77E5k$G(L$W5lJI(5wPR|_^khhAy&U|i~%a%5u8Z_MbY}n z%i?XZ#pY;kt$;h(b-t3%Zx2|N6hrRP#SWRl`OY`%3)|&!5`Bzdi8Xx~09d zvrMh7y#%|i*M0==&rV!~_pTEkGJnkn%&vK#+0_@AUHu-jtIjjK>Ro2*n!!GxI|uI< bbnh^~?rrAh-eNZQCbRXe%+`NAd1dWyD#Ntp diff --git a/data/system.db b/data/system.db index 56b73138341288e78d7eca95d4bbb5b5ec07153b..925db52efa388a3b3f6226809024f3dca8a72c88 100644 GIT binary patch delta 158 zcmZozz}T>Wae_3X&qNt#MxTud3;FqZelW1`G&1o%<$K4|$eX`eP@s^Wae_3X`$QRMM)!>g3;FpO7#LW1elYMp<$K3l%JXBhpu!6t_G)$>21|ME z$z`%?{DtMIdB(b_xk;%hDVcfclef!gPChNmXKA2dXk=w-Y-MPc$Hv28D5`AF%fxGJ z%=v82nx}I%D7dHQ<|ZnbD7cz9C1)g-q=UG+Cb~c_&;)ZXux=wuD?`i8Pi3Wz0O{;4 A;{X5v diff --git a/kb/consts/consts.go b/kb/consts/consts.go index 80d1868..8fced38 100644 --- a/kb/consts/consts.go +++ b/kb/consts/consts.go @@ -8,23 +8,28 @@ const ( HybridTopK = 5 // 混合检索最终返回数(重排+门槛过滤后) RrfK = 60 // RRF 融合常数 - RerankTopK = 10 // 喂给 LLM 重排器的候选数(RRF 融合后截取;数据集可覆盖) - RerankKeepRatio = 0.5 // 重排分低于最高分该比例的条目剔除(门槛作用在语义分上,RRF 分因区间过窄无区分度) - RerankMinScore = 6 // 重排分绝对下限:低于该分的条目直接剔除(重排器对泛化条款会给宽松低分,需绝对下限兜底) - RerankMaxChars = 500 // 重排候选段截断字数(控制 prompt 长度) + RerankTopK = 10 // 喂给 LLM 重排器的候选数(RRF 融合后截取;数据集可覆盖) + RerankKeepRatio = 0.5 // 重排分低于最高分该比例的条目剔除(门槛作用在语义分上,RRF 分因区间过窄无区分度) + RerankMinScore = 6 // 重排分绝对下限:低于该分的条目直接剔除(重排器对泛化条款会给宽松低分,需绝对下限兜底) + RerankMaxChars = 300 // 重排候选段截断字数(16GB 机器 prompt 预算护栏,见 MaxRerankTopK 注释) + RerankMaxTokens = 1024 // 重排输出 token 上限:打分 JSON 实际 <100 token,余量防思考链失控长文(Qwen3.5 思考文本会混入 content) // 数据集召回数量配置(vec_top_k/fts_top_k/rerank_top_k/recall_top_k):0=用上方全局默认,-1=尽量多,>0=固定值 - MaxRecallTopK = 50 // 四个配置字段的统一上限 - MaxRecallRawTopK = 100 // 原始召回(向量/全文)"-1=尽量多"的保护上限 - MaxRerankTopK = 60 // 重排候选 "-1=尽量多"的保护上限(重排 prompt 成本护栏) - MaxRecallChars = 50000 // 最终返回 "-1=不限制数量"时的总字符物理保护(按相关性门槛过滤后仍超预算则按分截断) + MaxRecallTopK = 50 // 四个配置字段的统一上限 + MaxRecallRawTopK = 100 // 原始召回(向量/全文)"-1=尽量多"的保护上限 + MaxRerankTopK = 6 // 重排候选 "-1=尽量多"的保护上限。16GB 机器 prefill 预算:实测 1.54MB/token, + // oMLX target≈10.98GB(ceiling 12.2×0.9),chat 5.9GB+BGE-M3 2.1GB 常驻后 current≈9.7GB,evict 后≈8.1GB, + // 可用 transient 仅 ~2.9GB≈1900 token。6 候选×300 字≈1200 token 落在预算内;60 候选会触发 throttle 后挂起 + MaxRecallChars = 50000 // 最终返回 "-1=不限制数量"时的总字符物理保护(按相关性门槛过滤后仍超预算则按分截断) + + CitationMaxChars = 400 // 主回答系统提示单条引用资料截断字数(同受 16GB prefill 预算约束) DefaultChunkSize = 800 // 分块最大字数(数据集默认值) DefaultChunkOverlap = 150 // 分块重叠字数(数据集默认值) // 智能体(ReAct)参数 - MaxReactRounds = 10 // ReAct 轮次上限 - ToolResultMaxChars = 1500 // search 工具返回的单条 chunk 截断字数(控制上下文体积) + MaxReactRounds = 10 // ReAct 轮次上限 + ToolResultMaxChars = 400 // search 工具返回的单条 chunk 截断字数(16GB 机器 prefill 预算护栏,见 MaxRerankTopK 注释) ParsePollIntervalSeconds = 15 // 解析/标注任务轮询间隔 diff --git a/kb/service/chat_service.go b/kb/service/chat_service.go index 549fafe..cd650f8 100644 --- a/kb/service/chat_service.go +++ b/kb/service/chat_service.go @@ -81,9 +81,10 @@ func NewOpenAIChatModel(cfg *entity.ModelConfig) *OpenAIChatModel { return &OpenAIChatModel{cfg: cfg, extra: map[string]any{}} } -// DisableThinking 关闭模型思维链:本地 gemma-4-E4B 偶发长思考会烧光上下文,导致返回空内容 +// DisableThinking 关闭模型思维链:批量任务(标注/图谱抽取)保持快速稳定,避免长思考拖慢甚至超时 +// 经 mlx server 的 chat_template_kwargs 透传模板参数(Qwen3.5 模板键为 enable_thinking) func (m *OpenAIChatModel) DisableThinking() *OpenAIChatModel { - m.extra["thinking"] = false + m.extra["chat_template_kwargs"] = map[string]any{"enable_thinking": false} return m } @@ -605,7 +606,10 @@ func (r *HybridRetriever) rerankByLLM(ctx context.Context, query string, items [ sb.WriteString(fmt.Sprintf("[%d] %s\n", i+1, content)) } sb.WriteString("\n只输出 JSON,不要其他内容:{\"scores\":{\"1\":8,\"2\":3}}") - msg, err := r.reranker.Generate(ctx, []*schema.Message{{Role: schema.User, Content: sb.String()}}) + // 重排打分任务关闭思考链并限制输出上限:Qwen3.5 思考链会输出千级 token 的"Thinking Process" + // 长文(~5 tok/s 下可达数分钟),无 max_tokens 兜底会拖到应用超时;且思考文本混入 content 破坏 JSON 解析 + msg, err := r.reranker.Generate(ctx, []*schema.Message{{Role: schema.User, Content: sb.String()}}, + emodel.WithMaxTokens(consts.RerankMaxTokens)) if err != nil { return nil, err } @@ -664,6 +668,7 @@ func (s *chatService) Ask(ctx context.Context, datasetId int64, question string, if err != nil { return "", nil, err } + model.DisableThinking() // 工具轮次优先稳定快速,思考文本会混入 content 干扰解析 msgs := make([]*schema.Message, 0, len(history)+1) if start := len(history) - MaxHistoryRounds*2; start > 0 { history = history[start:] @@ -717,6 +722,7 @@ func (s *chatService) Ask(ctx context.Context, datasetId int64, question string, if err != nil { return "", nil, err } + model.DisableThinking() // Qwen3.5 思考文本会直接混入 content,关闭后输出干净答案 msgs := make([]*schema.Message, 0, len(history)+1) msgs = append(msgs, &schema.Message{Role: schema.System, Content: buildSystemPrompt(citations, triples)}) @@ -761,7 +767,7 @@ func (s *chatService) retrieve(ctx context.Context, datasetId int64, question st var reranker *OpenAIChatModel if defaultChatModel, err := dao.ModelConfig.GetDefault(ctx, consts.ModelTypeChat); err == nil && defaultChatModel > 0 { if m, err := BuildChatModel(ctx, defaultChatModel); err == nil { - reranker = m + reranker = m.DisableThinking() // 重排打分无需思考链,思考文本会拖慢任务并破坏 JSON 解析 } else { g.Log().Warningf(ctx, "build reranker failed, skip rerank: %v", err) } @@ -1108,7 +1114,11 @@ func buildSystemPrompt(citations []domain.Citation, triples []string) string { sb.WriteString("禁止把条文的具体编号(如「第二十一条」)或资料序号写进方括号,条文编号请在正文中用文字描述。") sb.WriteString("若某句没有资料依据,不要标注 [0],直接说明资料不足。\n\n【资料】\n") for _, c := range citations { - sb.WriteString(c.Content + "\n") + content := c.Content + if rs := []rune(content); len(rs) > consts.CitationMaxChars { + content = string(rs[:consts.CitationMaxChars]) + } + sb.WriteString(content + "\n") } if len(triples) > 0 { sb.WriteString("\n【知识图谱】以下为与问题相关的实体关系,可辅助回答关系类问题:\n") diff --git a/scripts/start_models.sh b/scripts/start_models.sh new file mode 100755 index 0000000..b501c3f --- /dev/null +++ b/scripts/start_models.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# 启动本地 MLX 模型服务:chat + embedding 均由 oMLX(18080) 提供 +# chat=Qwen3.5-9B-MLX-4bit(4-bit),embedding=BGE-M3(oMLX 原生 BERT 系支持) +# 用法: ./scripts/start_models.sh +set -u +cd "$(dirname "$0")/.." +ROOT=$(pwd) +VENV="$ROOT/.venv" +LOG_DIR="$HOME/.omlx/logs" +CACHE_DIR="$HOME/.omlx/cache" +mkdir -p "$LOG_DIR" "$CACHE_DIR" + +# oMLX 模型目录:chat(Qwen3.5-9B-MLX-4bit)+ embedding(bge-m3)均为实体目录,oMLX 按目录扫描注册模型 +OMLX_MODEL_DIR="$ROOT/models/mlx/omlx" + +if lsof -iTCP:18080 -sTCP:LISTEN >/dev/null 2>&1; then + echo "[skip] oMLX 18080 已被占用" +else + # 内存策略写在 ~/.omlx/settings.json(custom 上限 14GB), + # CLI 不加 --memory-guard,避免覆盖 settings.json 的 custom 配置 + nohup "$VENV/bin/omlx" serve \ + --model-dir "$OMLX_MODEL_DIR" \ + --host 127.0.0.1 --port 18080 \ + --paged-ssd-cache-dir "$CACHE_DIR/omlx-cache" \ + --max-concurrent-requests 8 \ + --log-level info >> "$LOG_DIR/mlx-chat.log" 2>&1 & + echo "[start] oMLX chat+embedding -> 127.0.0.1:18080 (pid $!)" +fi + +echo "日志: $LOG_DIR/mlx-chat.log" diff --git a/scripts/stop_models.sh b/scripts/stop_models.sh new file mode 100755 index 0000000..065cc5b --- /dev/null +++ b/scripts/stop_models.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# 停止本地 MLX 模型服务(oMLX:chat + embedding 一体) +# 用法: ./scripts/stop_models.sh +if pgrep -f "[o]mlx-server" >/dev/null 2>&1; then + pkill -f "[o]mlx-server" + echo "[stop] oMLX 18080 (chat+embedding)" +else + echo "oMLX 未在运行" +fi diff --git a/技术设计.md b/技术设计.md index 42fa00b..9506b1c 100644 --- a/技术设计.md +++ b/技术设计.md @@ -159,7 +159,7 @@ func (h *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...re 1. **向量检索**:query → Embedding → `vec0` KNN(L2 距离)预取 `topK*4` 候选,再单表查 `kb_chunk` 按 dataset_id 过滤(单表约束:内存组装,候选已按距离排序故过滤后取前 topK 等价原 JOIN),取 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`)**,低于门槛剔除 +4. **LLM 重排**:`rerankByLLM` 一次调用为 10 个候选打 0-10 分(候选截 `RerankMaxChars=300` 字),**门槛 = max(最高分×`RerankKeepRatio=0.5`, `RerankMinScore=6`)**,低于门槛剔除;重排模型调用**关闭思考链**(`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` 选项 @@ -292,7 +292,7 @@ StartParsePoller(main.go 启动,gtimer 单例 5 秒轮询,job 未结束不 - **条款切分**:按优先级探测三种行首正则(`第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`)+ 编号候选,输出 `{"marks":[{"cand_id":1,"law_item":"第四十四条","score":9,"reason":"..."}]}`;**law_item 由 LLM 判定输出**(法条编号,`第X条` 正则兜底提取);JSON 解析与 rerankByLLM 同套路(```json 提取 + 截首尾花括号);**不做门槛过滤,全部候选(含 0 分)按分降序保留** -- **判定 prompt 上下文预算**:本地 gemma-4-E4B 由 LocalAI 托管(`models/gemma-4-E4B.yaml`,llama.cpp 后端 gRPC 上报错 `rpc error`),`context_size=131072`(模型上限,GGUF `gemma4.context_length`;2026-08-11 起)**跨 4 个 parallel slot 共享**——上限按并发请求合计占用量算(曾以 16384 运行,4 并发全量被拒 `Context size has been exceeded`)。**思维链默认开启**(推理模型,2026-08-11 起按用户要求移除 `reasoning_effort: none`,思考内容写入 `reasoning` 字段;此前靠它+`thinking:false` 双关仍占 2.7k+ 字符)。**生成预算 `max_tokens=24576`**(判定与知识抽取同值,2026-08-11 起;此前判定 1024/抽取 4096,思考链会烧光预算导致 `content` 为空或 JSON 截断):实测 4 并发判定自然输出仅 ~900-1100 token(思考链 ~3k 字符),24576 为 ~20 倍余量、截断物理上不可能;并发核验 4×(prompt≤3k+24.5k)≈110k≤131072 且实测 4 并发全部通过,更高值(如 32768)在真实 prompt 下会触发共享上下文拒收。候选块按 `AnnoJudgePromptBudget=1500` 字总预算**贪心填充**(按 RRF 相关度降序,每条截 `AnnoJudgeCandidateChars=300` 字,首条保底入队,超预算截断后续候选)。条文精确文本不依赖 prompt 全文——`extractLawItem` 用未截断的 `ContentFull` 抽取;LLM 引用编号受展示条数约束(越界引用丢弃) +- **判定 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 引用编号受展示条数约束(越界引用丢弃) - **快照落库**: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」;溯源失败仅告警不阻断标注(展示增强,非判定依据)