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]}」,认不出是哪种坏法"