This commit is contained in:
2026-08-12 12:17:36 +08:00
parent b8662138a9
commit 15830ff0df
10 changed files with 90 additions and 17 deletions
+4
View File
@@ -15,3 +15,7 @@ backends/
# 模型权重(大文件,不提交;*.yaml 配置保留提交)
models/*.gguf
models/mlx/
# Python 虚拟环境(MLX 模型服务)
.venv/
+15
View File
@@ -178,6 +178,21 @@ npm run dev # Vite 开发服务器(5173),API 代理见 vite.config
- **对话模型**`/v1/chat/completions`,支持 SSE 流式):用于问答与知识图谱实体抽取
- **向量模型**`/v1/embeddings`):用于分块向量化与语义检索;维度须与 `config.yml``vector.dim` 一致(默认 1024
示例(本地 oMLXApple Silicon 推荐):
```bash
# 模型文件放 models/mlx/omlx/chat: Qwen3.5-9B-MLX-4bitembedding: 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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+15 -10
View File
@@ -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.98GBceiling 12.2×0.9),chat 5.9GB+BGE-M3 2.1GB 常驻后 current≈9.7GBevict 后≈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 // 解析/标注任务轮询间隔
+15 -5
View File
@@ -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")
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# 启动本地 MLX 模型服务:chat + embedding 均由 oMLX(18080) 提供
# chat=Qwen3.5-9B-MLX-4bit4-bit),embedding=BGE-M3oMLX 原生 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 模型目录:chatQwen3.5-9B-MLX-4bit+ embeddingbge-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.jsoncustom 上限 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"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# 停止本地 MLX 模型服务(oMLXchat + 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
+2 -2
View File
@@ -159,7 +159,7 @@ func (h *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...re
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=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 @@ StartParsePollermain.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 token24576 20 倍余量、截断物理上不可能。候选块按 `AnnoJudgePromptBudget=1500` 字总预算**贪心填充**(按 RRF 相关度降序,每条截 `AnnoJudgeCandidateChars=300` 字,首条保底入队,超预算截断后续候选)。条文精确文本不依赖 prompt 全文——`extractLawItem` 用未截断的 `ContentFull` 抽取;LLM 引用编号受展示条数约束(越界引用丢弃)
- **快照落库**mark 存命中 chunk 的法条快照(law_title=dataset 名、law_item=LLM 判定的法条编号、content=chunk 内容截断 800 字),标注结果不随语料变更失效
- **批量落库(禁逐条 SQL)**:条款插入与风险快照用 `Batch(100)` 多行 INSERTGoFrame 一次语句写 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」;溯源失败仅告警不阻断标注(展示增强,非判定依据)