第 6 章:Search 与 Retrieval——在候选集里找相关
第 6 章:Search 与 Retrieval——在候选集里找相关
Search 和 Retrieval 是同一个动作的两面:给定一个 query,在一堆候选里判断哪些真的相关。它们不是新 API,而是"Noul 的一种用法"——对每个候选问一个"相关吗"。本章讲清它与向量检索的分工、query 改写、覆盖率与噪声的平衡,以及为什么这套打法对 RAG 特别关键。
一个模式:对每个候选问一个 Noul
先把最核心的一句话摆出来:Search 和 Retrieval 的本质,是"对每个候选问一个 Noul"。
检索的输入是一对(query, 候选条目),输出是"这条和 query 相关吗"。这正是一个布尔判断。于是整个检索可以写成:
from typesafe import TypesafeClient
client = TypesafeClient()
def keep_relevant(query: str, candidates: list[str], threshold: float = 0.5) -> list[str]:
# 每个候选一个 Noul 问题,一次调用全部问完
questions = {
f"cand_{i}": {
"type": "noul",
"question": f"这条内容是否与查询「{query}」相关且有用?"
}
for i in range(len(candidates))
}
state = "\n\n".join(f"[{i}] {c}" for i, c in enumerate(candidates))
d = client.systemone(state=state, questions=questions,
instructions="判断每条编号内容与查询的相关性,只看是否真的回答了查询。")
return [
c for i, c in enumerate(candidates)
if d[f"cand_{i}"].probability >= threshold
]这里有三个设计要点,后面会反复出现:
其一,候选要编号,问题要共享 state。 把候选拼成带编号的 state,比给每个候选单独发一次调用便宜得多——一次请求即可判完一批。JevPDF 就是每请求 16 行,jgrep 每请求 16 个代码块。
其二,把"相关"定义成"是否回答了查询/是否值得落地"。 措辞直接决定质量。jevsearch 问的是"访客落到这个页面会不会高兴",而不是干巴巴的"是否相关"——前者更接近真实意图。
其三,阈值把 Noul 变成筛子。 概率低于阈值就丢弃,高于就留下。阈值调高就是"精筛",调低就是"宽召回"。
Search 与 Retrieval 的区别与重合
在工程语境里两个词经常混用,但有一层细微差别值得分清:
- Search 更偏向"对外找东西":用户给一个查询,系统要去一个(可能很大的)集合里找。重点常在查询理解和结果排序。
- Retrieval 更偏向"为下游拉东西":系统(往往是 RAG 管道)要为一个问题召回一批支撑材料。重点常在召回覆盖率和噪声控制。
但对 Jev 而言,两者用的是同一个动作:候选逐个判定。区别不在调用方式,而在你对召回和精确的取向不一样——Search 常怕漏掉好结果,Retrieval 常怕塞进坏上下文。所以下面讲的分工、改写、平衡,对两者都适用。
flowchart LR
Q["query"] --> RW["query 改写
(可选)"]
RW --> R1["粗召回
向量 / BM25 / 关键词"]
R1 --> CAND["候选集(20–30 条)"]
CAND --> J["Jev:对每个候选
问一个 Noul『相关吗』"]
J --> F["按概率过滤 / 排序"]
F --> OUT["留下的候选
(排序后的证据)"]与向量检索的分工:粗召回 + 精筛
Jev 检索不是要取代向量检索,而是补上它的短板。这个分工值得说透。
向量检索擅长的是语义粗召回——把上百万文档里"可能相关"的一小撮捞出来,快而全。它的问题在于两点:一是语义相似不等于有用,一段讲"退款政策"的文本和用户问的"我怎么退这个订单"向量很近,但不一定回答了问题;二是它对否定、条件、细粒度约束不敏感,"压缩机不结冰的原因"和"压缩机结冰的原因"可能挨得很近。
Jev 的 Noul 判定恰好补这两块:它是带条件的、面向意图的判定,能问"这条是否回答了查询里的具体约束"。所以标准结构是:
向量/BM25 负责粗召回(数量大、要快),Jev 负责精筛与重排(数量小、要准)。
candidates = vector_search(query, k=30) # 粗召回
survivors = keep_relevant(query, candidates, threshold=0.6) # Jev 精筛
top = sorted(survivors, key=lambda c: prob_of(c), reverse=True)[:5]OpenViking(volcengine/OpenViking)的 Jev rerank 客户端就是这个分工的直接体现。它是火山引擎的 Agent 上下文数据库,为了让候选文档能被重排,它用 jev-latest 对每个候选文档打分、把返回的概率当作相关性分数。它这么做的原因写在实现里:TypeSafe 本身没有原生的 rerank 端点,于是社区顺势把它"用成一个 rerank 器"。这恰恰说明 Noul 判定的通用性——一个能对候选问"相关吗"的决策层,天然就是一个 reranker。
query 改写
粗召回之前,往往值得花一次决策做 query 改写。原因很直接:用户的查询常常太口语、太模糊,或者隐含了上下文,直接拿去检索会命中一堆跑偏的东西。
改写的做法可以是让它变成一个更"检索友好"的版本,也可以拆成几个子查询分别召回:
d = client.systemone(
state=user_query,
questions={
"rewritten": {
"type": "choice",
"question": "哪个改写版本最适合拿去检索文档库?",
"choices": [
"原样",
"检索版:<简洁关键词>",
"拆解版:<拆成两个子问题>"
]
}
},
instructions="你为检索系统改写查询,目标是覆盖用户真实意图、去掉寒暄与歧义。"
)注意这里用 Choice 而不是生成——改写的结果被约束在几个候选形式里,代码拿到的是一个可预期的选项,而不是一段需要再解析的自由文本。
覆盖率与噪声的平衡
Retrieval 有两个方向相反的错误,和 Detection 的召回/精确是同构的:
- 覆盖不足(漏):真正相关的候选被阈值挡在门外,进不了上下文。下游生成模型没材料,只能编。
- 噪声过多(脏):不相关的候选混进上下文。不但浪费 token,还会稀释注意力、诱发幻觉。
平衡点是两段式:粗召回用较低的阈值或较大的 k,保证不漏;精筛用 Jev 的 Noul 概率卡一个更高的阈值,保证不脏。
# 粗召回:宁可多
cand = bm25(query, k=30)
# 精筛:阈值可以卡得比较狠
kept = [c for c in cand if relevance_p(query, c) >= 0.6]jev-retrieval(romeromarcelo/jev-retrieval)把这条做到了很细:它用一个本地 BM25 pass 提出候选,然后用 Jev 的 Noul 成员判定去给候选的 100/20 行窗口打分,而且代码和文档分开设阈值——代码卡 0.90、文档卡 0.60。为什么?"related" 对代码的要求更严:一段代码要么真的回答了问题,要么基本没用;而文档允许"沾边就算有点用"。阈值不是全局常量,它随内容类型和下游容忍度变化,这个细节很有工程价值。
为什么这对 RAG 很关键
RAG(检索增强生成)的质量上限,几乎由检索阶段决定。生成模型只能基于你给它的上下文作答——你给的材料里没有答案,再强的生成模型也只会编。
Jev 在 RAG 里的角色,是把"可能相关"精炼成"确实有用"。它和重排(下一章的 Ranking)配合,构成 RAG 的两道闸门:Retrieval 决定"哪些进候选",Ranking 决定"候选里谁排前面"。两者都是"对每个候选问一个问题"的同一族动作。
三个生产项目
jevsearch(kylemclaren/jevsearch)——一个把检索做到极致的命令面板。 jevsearch 是一个 shadcn/ui 的命令面板组件。它有两个阶段:第一个键按下时先流式回显关键词命中(保证即时响应),然后把前 20 条塞进一次 Jev 请求,里面同时问三类问题——一个"访客落到这个页面会不会高兴"的 Noul(每条一题)、一个"哪一个是最好的答案"的 Choice、以及一个"这些页面里到底有没有能回答的" Noul。最后在代码里重排或丢弃命中。
它的自评数据很硬:在 109 页 TypeSafe 文档上的 benchmark 里,Hit@1 达到 83%,而只用关键词那一遍只有 41%。翻倍还多。
这个数字说明了两件事。一是语义判定的增益是实打实的,不是玄学;二是它在 demo 里就把 benchmark harness 一起开源了——检索这种任务必须自带评测,否则你无法知道改 query、换阈值到底有没有用。这一点值得所有做 Search 的人抄走:没有 Hit@k / nDCG 的检索调优都是盲调。
Jev RAG(aifabrice/jev-rag)——标准的两级 RAG 管线。 它先用 BM25 把本地文档段落缩到短名单,然后用一次批量的 Jev Noul 相关性判定重排最多 30 个候选(阈值可选),再把最强的证据连同来源引用交给一个可配置的作答模型。它配套一个可跑的本地 demo 和一个公开的 323 查询 NFCorpus benchmark。这个项目几乎就是把上一节的"粗召回 + 精筛"结构抄成了代码:BM25 粗召回、Jev 批量 Noul 精筛、带引用下传。而且它保留了"阈值可选"——说明作者很清楚阈值该由使用场景来定,不该写死。
jev-retrieval(romeromarcelo/jev-retrieval)——把检索变成一个 CLI 工具。 它是一个 Rust CLI(命令 jevr),把自然语言查询翻译成 grep 风格的 path:start-end 目标。流程是:一个无状态的本地 BM25 pass 提出候选,Jev 用 Noul 成员判定给候选的 100/20 行窗口打分(代码卡 0.90、文档卡 0.60),再用每个 lane 一次 listwise Choice 把留下的排序。它以 Claude Code 的 skill 和 plugin 形式发布,并在 HAKARI-Bench NanoRTEB 重排榜上排到 90 个模型里的第 2。
这个项目信息密度很高。三处值得学:分级窗口(100 行粗、20 行精,先宽后窄);分类型阈值(代码比文档严);呼一个 listwise Choice 来做最终排序(把"筛"和"排"合并进同一条管线)。它排到 90 个模型的第 2,也说明这套"BM25 + Jev Noul + listwise Choice"的组合不是玩具。
其余项目一张表带过
| 项目 | 场景 | 关键设计 |
|---|---|---|
| LlamaIndex Jev(WiktorB2004/llama-index-jev) | RAG 适配器 | Jev 给每个检索到的段落 Score,再用 Choice/Noul 选择查询引擎;nfcorpus nDCG@5 0.340→0.396,约 $0.0003/query |
| jgrep(kyu1204/jgrep) | 语义 grep | 每 5–60 行代码块 / diff hunk / CSV 行问一个 Noul(每请求 16 个),打印超过阈值的 file:line,于是英语句子能当 CI lint 规则 |
| MemSearch Jev reranking(zilliztech/memsearch) | Agent 记忆 | 可选 Jev 重排器对检索到的 Markdown 块问 Noul、按相关性排序,含双语文评测 |
| Oko(bartlomein/oko) | 代码搜索 | ripgrep + BM25 缩小到函数级块,Jev 在三次并行请求里对每块问 Noul 相关性,通过 MCP 返回被接受的片段;阈值与摘录选择留在代码里 |
| jselect(keltokhy/jselect) | 证据选择 | 在 token 预算内用 Jev Noul 相关性判定挑出带来源链接的证据,配上本地多样性感知选择 |
| Milvus Model(milvus-io/milvus-model) | 检索基建 | 把候选文档的 Noul 问题打成一批送给 Jev,返回按分数排序、带原始索引的结果 |
| Search with Jev and Milvus(milvus-io/bootcamp) | 教程 | 九个可跑笔记本:Gemini 向量 + Milvus 检索 + Jev Noul/Choice 判定,Python 侧做排序、过滤、路由、停止策略 |
把这些项目放在一起看,有一条主线:它们全都遵循"粗召回 → Jev 批量 Noul 精筛 → 代码里排序/截断"这同一根脊椎。 差别只在召回用什么(向量 / BM25 / ripgrep)、阈值怎么设、以及要不要再加一个 listwise 排序。一旦你看懂了这根脊椎,RAG 检索部分的调优就从"玄学"变成了"改哪个环节"的工程问题。
小结
- Search 与 Retrieval 的本质是**"对每个候选问一个 Noul"**——把候选编号进共享 state,一次调用判一批,用阈值当筛子。
- 两者的区别在取向:Search 更怕漏好结果(偏召回),Retrieval 更怕塞坏上下文(偏精确),但动作相同。
- 与向量检索的关系是分工不是替代:向量/BM25 负责粗召回(快而全),Jev 负责精筛与重排(准而带条件),TypeSafe 没有原生 rerank 端点,社区直接把它用成 reranker(OpenViking)。
- 覆盖率与噪声是两个相反的错误,用两段式平衡:粗召回保覆盖、Jev 精筛压噪声;阈值随内容类型变化(jev-retrieval 代码 0.90 / 文档 0.60)。
- 检索必须自带评测:jevsearch 在 109 页文档上把 Hit@1 从 41% 提到 83%;Jev RAG 用 323 查询 NFCorpus;LlamaIndex Jev 把 nDCG@5 从 0.340 提到 0.396。
- 对 RAG 的启示:检索阶段决定了生成质量的上限,Jev 的精筛是把"可能相关"变成"确实有用"的那一步。
下一章继续往这套脊椎的下游走——用 Scoring 把候选排出相对次序(Ranking),用 Noul 逐条核实产物是否达标(Verification)。