第 6 章

第 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)。

📑 Jev 实战:用类型化决策重构软件

1 第 1 章:为什么需要决策层——从"生成一切"到"只做判定" 2 第 2 章:三种类型化输出——Choice、Score、Noul 与调用契约 3 第 3 章:十个决策形态——什么时候该把判断交给 Jev 4 第 4 章:Classification 与 Routing——把分诊做成一等公民 5 第 5 章:Detection 与 Scoring——二元判断与有序评分 6 第 6 章:Search 与 Retrieval——在候选集里找相关 7 第 7 章:Ranking 与 Verification——排序与校验 8 第 8 章:ML Feature Extraction 与 Structured Data Extraction——把文本变特征与结构化数据 9 第 9 章:检索与知识图谱——Search and retrieval / Graphs and knowledge graphs 10 第 10 章:模型路由与 LLM 护栏——Model routing / LLM guardrails 11 第 11 章:语义代码检查——Semantic code linting 12 第 12 章:特征提取与需求预测——Feature extraction / Demand forecasting 13 第 13 章:招聘与线索生成——Recruiting / Lead generation 14 第 14 章:客户支持——Customer support 15 第 15 章:理赔、金融犯罪与风险评估——Insurance claims / Financial crime / Risk assessment 16 第 16 章:法律与合规——Legal and compliance 17 第 17 章:电商市场与广告——E-commerce marketplaces / Advertising 18 第 18 章:内容审核与信任安全——Moderation and trust and safety 19 第 19 章:游戏——Gaming 20 第 20 章:实时应用——150 毫秒预算 21 第 21 章:科学发现——Scientific discovery 22 第 22 章:大数据上的 AI Map Reduce——为什么便宜 100 倍 23 第 23 章:通用验证——Universal Verification 24 第 24 章:Harness Engineering——把决策织进智能体骨架
← 返回本书大纲