重排给足输出预算;失败提示分清是哪种失败

拿真 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 字;个数不符 → 说明给了几个、候选几个;混了非数字 → 指出是哪个值。
测试里有一条专门断言空回复的提示中不出现「解析」二字。
This commit is contained in:
ThearchyHelios
2026-09-19 11:09:04 +08:00
parent 913380f738
commit 2a1040bbfc
3 changed files with 67 additions and 13 deletions
+14 -1
View File
@@ -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
+21 -12
View File
@@ -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]:
+32
View File
@@ -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]}」,认不出是哪种坏法"