跳到主要内容

Ranking 与 FastTrack

本章讲 NLWeb 真正的「价值核心」:怎么把一堆检索候选变成排好序的结果。先讲打分机制(逐条 LLM 评分 + 边算边发),再讲 FastTrack 怎么把这件事和预检并发起来抢时间。

3.1 逐条 LLM 打分:retrieve-then-rank

要解决的小问题。 向量检索给回 ~50 条候选,但向量相似度只是粗排,未必真切题。怎么精排?

思路。 NLWeb 不写复杂的重排模型(默认),而是让 LLM 当裁判:对每一条候选,把「用户问题 + 该条目的描述」塞进 prompt,让 LLM 输出 0-100 的相关性分 + 一句描述。简单、可解释,且天然能写出「为什么相关」。

默认打分 promptranking.py:96 RANKING_PROMPT)大意:

给这个 {itemType} 打 0-100 分,看它跟用户问题多相关。分数高于 50 就给一句突出相关性的描述(但别提及用户问题本身)……用户问题是 {request.query},条目描述是 {item.description}。

原理演示(核心想法,去掉异步与流式):

# 示意,非源码:逐条打分的本质
for item in candidates:
desc = trim_json(item.schema) # 把 schema.org JSON 裁剪精简
prompt = fill_prompt(RANKING_PROMPT, query, desc)
result = ask_llm(prompt, {"score": int, "description": str})
item.score = result["score"]
ranked = sorted(candidates, key=score, reverse=True)

真实实现Ranking.rankItemranking.py:214):它 fill_promptawait ask_llm(...)ranking.py:226-227),把结果连同 schema_object 打包成一个 ansr 字典。每条候选是并发打分的——do() 给每个 item 起一个 task 再 asyncio.gatherranking.py:446-452)。

3.2 边算边发:高分项不等全部算完

要解决的小问题。 50 条全打完分再排序再发,用户要干等。能不能先把明显的好结果推出去?

思路。 设一个「早发阈值」EARLY_SEND_THRESHOLD = 59ranking.py:87)。某条一旦打分超过它,立刻尝试发出去,不等其余条目(ranking.py:247):

# 示意,非源码:rankItem 末尾
if ranking["score"] > self.EARLY_SEND_THRESHOLD: # 59
await self.sendAnswers([ansr]) # 这一条先流式发出
self.rankedAnswers.append(ansr) # 同时仍记账,供最后统一排序

这样用户几乎在第一条好结果算完时就看到东西了。

发多少有节制。 shouldSend 控制别超 max_results,且接近上限时只发「比已发的还好」的(ranking.py:306);sendAnswers 里还有多重「绝不超限」的安全检查(ranking.py:354ranking.py:387)。

最后兜底排序。 所有条目打完后,do()min_score(默认 51)过滤、按分降序、截到 max_results,把未发出的好结果补发(ranking.py:466-492)。注意这里和早发阈值 59 是两个不同的门槛:59 是「好到可以抢跑」,51 是「及格可以留下」

3.3 多套 prompt:按场景换裁判标准

get_ranking_prompt 按情况选打分 prompt(ranking.py:167):

场景用哪套 prompt特别之处
WHO 排序(找相关站点)WHO_RANKING_PROMPT额外让 LLM 产出「发给该站的优化查询」
对话历史搜索CONVERSATION_SEARCH_PROMPT同时考量问题和当时回答
Bing / 电商商品PRODUCT_FOCUSED_PROMPT描述里强制带品牌/价格/规格,禁说「本网页」
站点有自定义find_prompt(...) 从 XML 取站点可覆盖默认
兜底RANKING_PROMPT通用

这是「同一个机制、可换标准」的典型——排序逻辑不变,只换喂给 LLM 的评判尺子。

3.4 可选的非 LLM 打分器:NLWebScorer

LLM 逐条打分准但慢且贵。NLWeb 留了个口子:若配置启用且 checkpoint 文件存在,就用 NLWebScorer(一个 BERT + GAM 的本地模型)批量打分,零 LLM 调用(ranking.py:266 rankItemsWithScorer)。

选谁由 do() 决定(ranking.py:426):?scorer=llm 强制用 LLM、?scorer=nlwebscorer 强制用本地模型,否则自动探测 checkpoint 是否存在(ranking.py:27 _nlweb_scorer_available,结果带缓存)。

注:NLWebScorer 本身(BERT+GAM 的训练与推理)在独立的 NLWebScorer/ 模块,本章只看到它在 ranking 里的接入点,未深入其内部。

3.5 FastTrack:把检索打分和预检并发起来赌一把

要解决的小问题。 大多数查询其实是「简单、相关、信息齐全、不用补上下文」的。可如果老老实实「先做完所有预检,再检索,再打分」,那些预检的 LLM 往返就白白串在关键路径上。

思路(模块注释写得很直白,fastTrack.py:5)。 假设查询简单,检索 + 打分(结果攒着别发),同时并发做那些预检;等预检都过了,攒的结果应该刚好算好,直接放行。赌对了就省下预检的时间;赌错了就丢弃重来。

赌的前提is_fastTrack_eligiblefastTrack.py:40):站点支持标准检索、没有 context_url、没有历史提问。任一不满足就不赌。

赌的过程fastTrack.py:54 do):

# 示意,非源码:FastTrack 主流程
if not self.is_fastTrack_eligible():
return
self.handler.retrieval_done_event.set() # 宣告「检索我来做」
items = await search(query, site, ...) # 立刻检索
if not query_done and not abort_fast_track_event.is_set():
ranker = Ranking(handler, items, Ranking.FAST_TRACK)
await ranker.do() # 立刻打分(结果攒着)

放行的闸门Ranking.sendAnswers:如果是 FAST_TRACK 类型,发之前要先 wait_for_prechecks()(最多等 5 秒),并再查一次 should_abort_fast_track()ranking.py:332-344)。只有预检全过且没被叫停,攒的结果才真正发出,并置 fastTrackWorked = Trueranking.py:392)。

赌输的两种叫停

  • 去上下文化判定要改写 → abort_fast_track_event.set()decontextualize.py:84)。
  • 工具路由选了非 search → 同样 set(router.py:535)。

一旦叫停,FastTrack 攒的结果就不发了,主线回到 route_query_based_on_tools 走正常路径(因为 fastTrackWorked 仍是 False,baseHandler.py:330)。

怎么读这张时序

prepare 启动
├─ FastTrack ──检索──打分──[攒着]──→ 想发,但要先 wait_for_prechecks()
│ │
├─ 去上下文化 ──LLM──→ 要改写? ──set abort──┐ │
└─ 工具路由 ──LLM──→ 非search? ──set abort──┤ │
▼ ▼
预检完成 & 没叫停? ──是──→ 发出结果, fastTrackWorked=True

└──否──→ 丢弃, 主线走常规 ranking

巧妙之处

  • 两个阈值各司其职:59 抢跑、51 及格(ranking.py:87ranking.py:466)。把「值得让用户早点看到」和「值得留在最终结果里」分开,避免一刀切。
  • 打分即解释:让 LLM 在打分的同时产出描述(ranking.py:102),结果天然自带「为什么给你看这条」,省掉单独的解释步骤。
  • 打分器可热插拔?scorer= 一个查询参数就能在「准的 LLM」和「快的本地模型」间切,且自动探测降级(ranking.py:426)。

边界与局限

  • 逐条 LLM 打分的成本随候选数线性增长——50 条候选就是 50 次 LLM 往返(并发,但仍耗 token)。这正是 NLWebScorer 存在的理由。
  • FastTrack 的「赌」在多轮对话、带 context_url 的场景一律不触发(fastTrack.py:45-49),那些场景拿不到这份加速。
  • 早发的结果可能在最终排序里并非最优——它只保证「分数 > 59」,不保证是全局前几。

代码地图

主题文件符号
单条打分core/ranking.pyRanking.rankItem
打分主循环 / 排序兜底core/ranking.pyRanking.do
边算边发 / 限流core/ranking.pyRanking.sendAnswers / Ranking.shouldSend
早发阈值 / 及格阈值core/ranking.pyEARLY_SEND_THRESHOLD / min_score
选打分 promptcore/ranking.pyRanking.get_ranking_prompt
本地批量打分器接入core/ranking.pyrankItemsWithScorer / _nlweb_scorer_available
FastTrack 主流程core/fastTrack.pyFastTrack.do / is_fastTrack_eligible
站点是否支持检索core/fastTrack.pysite_supports_standard_retrieval