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 8ae7ecf..a28dd05 100644 Binary files a/data/business.db and b/data/business.db differ diff --git a/data/chat.db b/data/chat.db index 5803fa7..7f1d990 100644 Binary files a/data/chat.db and b/data/chat.db differ diff --git a/data/system.db b/data/system.db index 56b7313..925db52 100644 Binary files a/data/system.db and b/data/system.db differ 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」;溯源失败仅告警不阻断标注(展示增强,非判定依据)