This commit is contained in:
2026-08-13 09:23:16 +08:00
parent 9fccadf50d
commit eaabd6056e
4 changed files with 30 additions and 4 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -10,7 +10,7 @@ const (
RerankTopK = 10 // 喂给 LLM 重排器的候选数(RRF 融合后截取;数据集可覆盖) RerankTopK = 10 // 喂给 LLM 重排器的候选数(RRF 融合后截取;数据集可覆盖)
RerankKeepRatio = 0.5 // 重排分低于最高分该比例的条目剔除(门槛作用在语义分上,RRF 分因区间过窄无区分度) RerankKeepRatio = 0.5 // 重排分低于最高分该比例的条目剔除(门槛作用在语义分上,RRF 分因区间过窄无区分度)
RerankMinScore = 6 // 重排分绝对下限:低于该分的条目直接剔除(重排器对泛化条款会给宽松低分,需绝对下限兜底) RerankMinScore = 7 // 重排分绝对下限:低于该分的条目直接剔除(重排器对泛化条款会给宽松低分,需绝对下限兜底)
RerankMaxChars = 300 // 重排候选段截断字数(16GB 机器 prompt 预算护栏,见 MaxRerankTopK 注释) RerankMaxChars = 300 // 重排候选段截断字数(16GB 机器 prompt 预算护栏,见 MaxRerankTopK 注释)
RerankMaxTokens = 1024 // 重排输出 token 上限:打分 JSON 实际 <100 token,余量防思考链失控长文(Qwen3.5 思考文本会混入 content RerankMaxTokens = 1024 // 重排输出 token 上限:打分 JSON 实际 <100 token,余量防思考链失控长文(Qwen3.5 思考文本会混入 content
+26 -2
View File
@@ -591,7 +591,9 @@ func (r *HybridRetriever) ftsRetrieve(ctx context.Context, query string) []retri
// 任何失败(调用/解析/空结果)返回错误,由调用方回退 RRF 排序 // 任何失败(调用/解析/空结果)返回错误,由调用方回退 RRF 排序
func (r *HybridRetriever) rerankByLLM(ctx context.Context, query string, items []scoredChunk) (map[int64]float64, error) { func (r *HybridRetriever) rerankByLLM(ctx context.Context, query string, items []scoredChunk) (map[int64]float64, error) {
var sb strings.Builder var sb strings.Builder
sb.WriteString("你是检索重排器。请评估每个候选段落与用户问题的相关性,为每个候选输出 0-10 的相关性分数(10=高度相关,0=完全不相关)。\n\n用户问题:\n") sb.WriteString("你是检索重排器。请评估每个候选段落与用户问题的相关性,为每个候选输出 0-10 的相关性分数(10=高度相关,0=完全不相关)。")
sb.WriteString("严格规则:仅当段落直接回答问题的具体情境(如涉及偷窃、归还、退赃等具体事实)时才给高分(7-10);")
sb.WriteString("段落只是泛化提及相关概念(如犯罪、处罚、共同犯罪等一般规定)而未涉及问题具体情境时,必须给低分(0-3)。\n\n用户问题:\n")
sb.WriteString(query) sb.WriteString(query)
sb.WriteString("\n\n候选段落:\n") sb.WriteString("\n\n候选段落:\n")
for i, it := range items { for i, it := range items {
@@ -788,6 +790,27 @@ func (s *chatService) askAgent(ctx context.Context, model *OpenAIChatModel, data
var allDocs []*schema.Document var allDocs []*schema.Document
var allTriples []string var allTriples []string
// 首轮预检索:不依赖模型自觉调用 search——9B 模型思考链关闭后偶发跳过工具调用直接凭自身知识作答,
// 导致检索不执行、引用列表为空、回答 [N] 为模型自编。强制注入首轮结果,模型仍可后续补充检索。
if docs, triples, err := s.executeSearchTool(ctx, datasetId, question); err != nil {
g.Log().Warningf(ctx, "pre-retrieve failed, fallback to agent search tool: %v", err)
} else if len(docs) > 0 {
allDocs = append(allDocs, docs...)
allTriples = append(allTriples, triples...)
var sb strings.Builder
sb.WriteString("已为你检索到以下资料,回答时请优先基于这些资料,并在引用处标注 [N](N 为该句依据的资料条数);若资料不足,可继续调用 search 工具补充检索:\n\n")
for i, c := range buildCitations(docs, question) {
sb.WriteString(fmt.Sprintf("[%d] %s\n", i+1, c.Content))
}
if len(triples) > 0 {
sb.WriteString("\n【知识图谱】以下为与问题相关的实体关系,可辅助回答关系类问题:\n")
for _, t := range triples {
sb.WriteString(t + "\n")
}
}
msgs = append(msgs, &schema.Message{Role: schema.User, Content: sb.String()})
}
loopRound := 0 loopRound := 0
for { for {
loopRound++ loopRound++
@@ -928,7 +951,7 @@ func (s *chatService) executeSearchTool(ctx context.Context, datasetId int64, qu
return out.docs, triples, nil return out.docs, triples, nil
} }
// aggregateCitations 多轮工具结果聚合引用:按 chunk_id 去重后重排编号 // aggregateCitations 多轮工具结果聚合引用:按 chunk_id 去重后重排分降序排序再编号
func aggregateCitations(docs []*schema.Document, question string) []domain.Citation { func aggregateCitations(docs []*schema.Document, question string) []domain.Citation {
seen := map[int64]bool{} seen := map[int64]bool{}
out := make([]domain.Citation, 0, len(docs)) out := make([]domain.Citation, 0, len(docs))
@@ -939,6 +962,7 @@ func aggregateCitations(docs []*schema.Document, question string) []domain.Citat
seen[c.ChunkId] = true seen[c.ChunkId] = true
out = append(out, c) out = append(out, c)
} }
sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
for i := range out { for i := range out {
out[i].Index = i + 1 out[i].Index = i + 1
} }
+3 -1
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 1. **向量检索**query → Embedding → `vec0` KNNL2 距离)预取 `topK*4` 候选,再单表查 `kb_chunk` 按 dataset_id 过滤(单表约束:内存组装,候选已按距离排序故过滤后取前 topK 等价原 JOIN),取 20
2. **关键词检索**query → `TokenizeQuery` 分词(引号词组 OR 语义、过滤 <2 rune 与 FTS 特殊字符)→ FTS5 MATCHbm25 负分升序)取 20 2. **关键词检索**query → `TokenizeQuery` 分词(引号词组 OR 语义、过滤 <2 rune 与 FTS 特殊字符)→ FTS5 MATCHbm25 负分升序)取 20
3. **RRF 融合**`score = Σ 1/(60 + rank)` 全局融合,截 `RerankTopK=10` 3. **RRF 融合**`score = Σ 1/(60 + rank)` 全局融合,截 `RerankTopK=10`
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 分钟即此因) 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` 与引用信息 5. 按重排分降序截 `HybridTopK=5`,回表 `kb_chunk` 取原文与元数据,组装 `schema.Document` 与引用信息
6. 支持 `retriever.WithTopK` / `WithScoreThreshold` 选项 6. 支持 `retriever.WithTopK` / `WithScoreThreshold` 选项
@@ -196,6 +196,8 @@ score = Σ(1 / (60 + rank)) // RRF 分区间过窄无区分度,仅用于候
- SSE 事件顺序:`event: citations`(先推,含引用列表与 conversation_id)→ `event: delta``{content}` 增量文本)→ `event: done`;异常推 `event: error` - 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 行(心跳)静默跳过 - **15 秒心跳**controller 空闲时周期发送 `: ping` 注释行,防止代理/浏览器断开长连接;前端 `fetch + ReadableStream` 按 `\n\n` 分块、按 `data: ` 行解析,按字段(citations/content/status/message)识别事件,非 JSON 行(心跳)静默跳过
- 引用列表编号 [1][2] 与提示词中资料编号一一对应,随助手消息以 JSON 落库(chat_message.citations - 引用列表编号 [1][2] 与提示词中资料编号一一对应,随助手消息以 JSON 落库(chat_message.citations
- **引用排序**:单次流水线路径 `buildCitations` 按检索结果顺序(重排分降序)编号;多轮 ReAct 工具路径 `aggregateCitations` 按 chunk_id 去重后**按重排分 Score 降序**重新编号——2026-08-12 实测多轮路径曾按轮次累积顺序输出,导致高分引用排后面、顺序与提示词 [N] 不一致
- **Agent 路径强制首轮预检索**:`askAgent` 循环开始前先无条件执行一次 `executeSearchTool`retrieve + GraphEnhance 并行,失败仅告警不阻断),结果以 `[N] 内容` + 图谱三元组注入首轮 user 消息,引用列表随循环首轮推送——检索不依赖模型自觉调用 search2026-08-12 实测 Qwen3.5-9B 思考链关闭后偶发跳过工具调用直接凭自身知识作答(oMLX 日志该次仅 1 次 chat completion、0 次 embedding 请求),引用列表落库 `[]`、回答中 [N] 为模型自编;模型后续仍可调用 search 补充检索,chunk_id 去重防重复引用
### 7.5 中文分词 ### 7.5 中文分词