From 2a1040bbfcffa44b2a24eb880b6c16a4613d8f22 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 19 Sep 2026 11:09:04 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=8E=92=E7=BB=99=E8=B6=B3=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E9=A2=84=E7=AE=97=EF=BC=9B=E5=A4=B1=E8=B4=A5=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E5=88=86=E6=B8=85=E6=98=AF=E5=93=AA=E7=A7=8D=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拿真 provider(deepseek-v4-pro,千帆网关)跑了一遍评测集,重排确实有用: hit@1 hit@5 初筛 88% 100% 重排后 100% 100% 变动的两题都是语义类的,正好是本地哈希向量的短板——BM25 把「权限」的字面 命中排给了「数据导出」(那篇里有「export 权限」),模型读懂了问的是「谁有权 改」。字面类原本就 100%,没有被重排搞坏。 但 16 次里有 2 次退回了初筛顺序。查下来模型回的是**空字符串**,而失败那次的 out_tokens 正好等于 max_tokens——重排的输出只有一行几十字的 JSON,但模型为了 读完 20 条候选要花 800~1600 个输出 token,顶到上限后正文什么都不剩。llm.py 里对同一个现象早有记录:「思考型模型把额度全花在推理上了,正文是空的」。 所以重排改成有自己的预算(至少 4096),不跟节点上那个走。补跑 16/16 全部成功。 顺带纠正一处判断:thinking=off 救不了这个 provider。实测关思考那组照样在 out_tokens=2048 时空手——factory.py 只在 anthropic 分支里读 thinking 字段, openai_compatible 整条路径不碰它。仍然传 off,因为对 Anthropic 有效、而且打分 任务本来不需要思考模式,但它不是这次的解药,注释里写明了。 失败提示原来四种坏法统一报「解析不出来」,会把人引去查 JSON 格式,而该调的是 token。现在分开说:空回复 → 「把最大输出 token 调大」;找不到 scores → 带上 原文前 80 字;个数不符 → 说明给了几个、候选几个;混了非数字 → 指出是哪个值。 测试里有一条专门断言空回复的提示中不出现「解析」二字。 --- backend/app/engine/nodes/knowledge.py | 15 ++++++++++- backend/app/memory/rerank.py | 33 ++++++++++++++++--------- backend/tests/test_retrieval_quality.py | 32 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/backend/app/engine/nodes/knowledge.py b/backend/app/engine/nodes/knowledge.py index 8acb89dc..47df7590 100644 --- a/backend/app/engine/nodes/knowledge.py +++ b/backend/app/engine/nodes/knowledge.py @@ -91,9 +91,22 @@ async def run_retrieve(state: GraphState, ctx: NodeContext) -> dict[str, Any]: from app.memory.rerank import rerank from app.providers.factory import ProviderNotConfigured, get_chat_model + # 重排有自己的预算,不跟节点上那个走: + # + # 它的输出只有一行几十字的 JSON,但模型为了读完 20 条候选会花掉上千个 + # 输出 token——实测 deepseek-v4-pro 单次用 800~1600 个,一旦顶到上限, + # 正文就什么都不剩(失败那次 out_tokens 正好等于 max_tokens)。 + # + # thinking=off 对 Anthropic 有效;openai_compatible 那条路径不读这个字段 + # (factory.py 只在 anthropic 分支里设 thinking),所以真正兜住的是 + # 上面那个预算。打分任务本来也不需要思考模式。 + spec = _model_spec(ctx).model_copy(update={ + "max_tokens": max(int(ctx.cfg("max_tokens") or 0), 4096), + "thinking": "off", + }) try: async with SessionLocal() as session: - model, _ = await get_chat_model(session, _model_spec(ctx)) + model, _ = await get_chat_model(session, spec) except ProviderNotConfigured as e: degraded.append(f"重排用不了:{e}") model = None diff --git a/backend/app/memory/rerank.py b/backend/app/memory/rerank.py index edb559d8..12f918f4 100644 --- a/backend/app/memory/rerank.py +++ b/backend/app/memory/rerank.py @@ -28,26 +28,36 @@ _PROMPT = """给下面每个片段打分:它对回答这个问题有多大帮 0 表示完全无关,10 表示直接回答了问题。""" -def _parse_scores(text: str, n: int) -> list[float] | None: - """从模型回复里抠出分数数组。 +class Unusable(ValueError): + """回复用不了。message 就是要交给用户的那句话。""" + + +def _parse_scores(text: str, n: int) -> list[float]: + """从模型回复里抠出分数数组。用不了就抛 Unusable,message 说清楚是哪种用不了。 模型偶尔会加围栏或者说两句废话,所以不直接 json.loads 整段; 但也不做过度容错——解析不出来就退回原顺序,比按半截数据重排安全。 """ + if not text.strip(): + # 实测 deepseek-v4-pro:失败那次 out_tokens 正好等于 max_tokens, + # 输出预算被耗光后正文什么都不剩。报"解析不出来"会把人引去查 JSON 格式, + # 而该调的是 max_tokens——llm.py 里对同一个现象也是这么说的 + raise Unusable("模型没有输出正文,多半是输出 token 预算用完了。把节点上的最大输出 token 调大") match = re.search(r"\{[^{}]*\"scores\"\s*:\s*\[[^\]]*\][^{}]*\}", text, re.S) if not match: - return None + raise Unusable(f"模型回复里找不到 scores:{text.strip()[:80]}") try: scores = json.loads(match.group(0)).get("scores") - except json.JSONDecodeError: - return None + except json.JSONDecodeError as e: + raise Unusable(f"scores 不是合法 JSON:{e}") from e if not isinstance(scores, list) or len(scores) != n: # 个数对不上就没法一一对应,硬凑只会把顺序搅乱 - return None + raise Unusable(f"给了 {len(scores) if isinstance(scores, list) else '?'} 个分数," + f"候选有 {n} 个,对不上") out: list[float] = [] for s in scores: if not isinstance(s, (int, float)): - return None + raise Unusable(f"分数里混了非数字:{s!r}") out.append(float(s)) return out @@ -80,16 +90,15 @@ async def rerank( if isinstance(text, list): # Anthropic 的块状 content text = "".join(b.get("text", "") for b in text if isinstance(b, dict)) scores = _parse_scores(str(text), len(hits)) + except Unusable as e: + if on_note: + on_note(f"重排没用上,按初筛顺序返回:{e}") + return hits[:top_n] except Exception as e: # noqa: BLE001 if on_note: on_note(f"重排失败,按初筛顺序返回:{type(e).__name__}: {e}") return hits[:top_n] - if scores is None: - if on_note: - on_note("重排结果解析不出来,按初筛顺序返回") - return hits[:top_n] - ranked = sorted(zip(hits, scores), key=lambda t: t[1], reverse=True) out: list[dict[str, Any]] = [] for hit, score in ranked[:top_n]: diff --git a/backend/tests/test_retrieval_quality.py b/backend/tests/test_retrieval_quality.py index fcb387cf..4a94a3d4 100644 --- a/backend/tests/test_retrieval_quality.py +++ b/backend/tests/test_retrieval_quality.py @@ -383,3 +383,35 @@ async def test_deleting_a_document_takes_its_index_rows() -> None: after = int((await session.execute(select(func.count(ChunkTerm.id)).where( ChunkTerm.collection == "idx_del"))).scalar_one()) assert after == 0, f"文档删了但倒排还留着 {after} 行" + + +async def test_an_empty_reply_is_not_called_a_parse_error() -> None: + """推理型模型把输出预算花光时正文是空的——实测 deepseek-v4-pro 失败那次 + out_tokens 正好等于 max_tokens。 + + 报"解析不出来"会把人引去查 JSON 格式,而该调的是最大输出 token。 + llm.py 里对同一个现象也是这么说的。 + """ + from app.memory.rerank import rerank + + notes: list[str] = [] + out = await rerank(_Scorer(""), "问题", _hits(3), top_n=3, on_note=notes.append) + assert [h["chunk_id"] for h in out] == ["c0", "c1", "c2"] + assert "token" in notes[0] and "调大" in notes[0], notes[0] + assert "解析" not in notes[0] + + +async def test_each_kind_of_bad_reply_says_which_kind() -> None: + """四种坏法要分得开,否则排查时只知道"重排没用上"。""" + from app.memory.rerank import rerank + + cases = { + '': 'token', # 空回复 + '随便说点什么': 'scores', # 找不到 scores + '{"scores": [1, 2]}': '对不上', # 个数不符 + '{"scores": ["高", "低", "中"]}': '非数字', # 类型不对 + } + for reply, want in cases.items(): + notes: list[str] = [] + await rerank(_Scorer(reply), "问题", _hits(3), top_n=3, on_note=notes.append) + assert want in notes[0], f"回复 {reply!r} 给的提示是「{notes[0]}」,认不出是哪种坏法"