1
This commit is contained in:
@@ -15,3 +15,7 @@ backends/
|
||||
|
||||
# 模型权重(大文件,不提交;*.yaml 配置保留提交)
|
||||
models/*.gguf
|
||||
models/mlx/
|
||||
|
||||
# Python 虚拟环境(MLX 模型服务)
|
||||
.venv/
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15
-10
@@ -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 // 解析/标注任务轮询间隔
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Executable
+30
@@ -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"
|
||||
Executable
+9
@@ -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
|
||||
@@ -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」;溯源失败仅告警不阻断标注(展示增强,非判定依据)
|
||||
|
||||
Reference in New Issue
Block a user