第 09 篇:重排——Rerank 与 RRF
第 08 篇结尾把一件事按下了:“名单怎么从候选变成上下文,是拿到名单以后的事。”
这一篇就把它打开。夹在检索与生成之间的那一步叫重排,
它回答的是同一个问题的两半:几十条候选里,到底哪几条该进提示词?但这一篇最值钱的结论不是“重排有用”——那是意料之中的。真正意外的是第六节那个实测:
把向量库从 Elasticsearch 换成 Qdrant,向量分数变了,重排分数却一位都没变。
这个结果把“哪一步可以跨后端复用、哪一步不行”这条线画清楚了。
下面这张是本篇全部实测数据汇总出来的对照面板:左边是同一批候选重排前后、
以及“向量 / 融合 / 重排”三种排序的并列,右边是耗时、原始响应体、本工程接口返回,
最下面是换到 Qdrant 之后的跨后端核对。后面每一节都会从这块面板里取一格来讲。
一、粗筛之后为什么还要再排一遍
1.1 粗筛的两处先天不足
向量检索为了让上百万条数据能在毫秒级查完,做了两件事,各留了一处不足:
| 做了什么 | 换来了什么 | 代价 |
|---|---|---|
| 近似最近邻(ANN)搜索 | 快 | 近似,不是精确的最近邻 |
| 把一整段文本压成一个固定长度向量 | 可比、可索引 | 段内细节被平均掉 |
第 08 篇实测到的那个反例正是第二处的后果:查Redis,
真正含这个词的块(02-知识库#0)在向量分支只排第二,输给了一个字面上压根不含它的块:
纯向量:✘ 01-员工手册#1 0.2281 | ✔ 02-知识库#0 0.2190 ← 差 0.0091 就换了名次 纯全文:✔ 02-知识库#0 1.244 ← 只有它含这个词1.2 重排换了做法:把 query 和候选放在一起过模型
- 粗筛(向量/全文检索):query 与文档各自变成向量,然后比距离。
两边从没见过面,相似度是“两个压缩结果的夹角”。 - 重排(rerank 模型):query 和每一条候选拼在一起送进模型,
模型直接判断“这段文本能不能回答这个问题”。
做法更细,代价也更贵:N 条候选就是 N 次前向计算。
全库几万条块不可能都送进去,所以它天然只能做精排——先粗筛几十条,再重排取前几条:
两次排序的目标不一样:粗筛怕漏(召回优先),重排怕错(精度优先)。
所以粗筛阶段不该设相似度阈值——提前砍掉几条,等于让重排少了可挑的料。
这一点在代码里是显式写死的:
// 粗筛:池子放宽到 topK 的两倍(至少 10 条),且不设阈值——// 阈值是"事后砍",而重排正是那个更该决定砍谁的角色,先砍会让它无从下手。poolSize=Math.max(topK*2,10);List<Document>pool=retrievalService.search(question,poolSize,0.0);hits=rerankService.rerankDocuments(question,pool,topK);1.3 RRF 是什么,和重排差在哪
第 08 篇已经实现了 RRF(倒数排名融合)。它和重排常被混为一谈,其实是两件事:
| RRF 融合 | 重排模型 | |
|---|---|---|
| 输入 | 两路已有的名次 | query + 每条候选原文 |
| 输出 | 名次的重排 | 全新的相关性分数(0~1) |
| 会不会引入新信息 | 不会,只是两路名次的加权 | 会,模型重新读了一遍文本 |
| 能不能推翻共识 | 不能,两路都排前面的必然还在前面 | 能 |
| 依赖 | 需要两路检索都可用 | 只需要一个重排接口 |
两者的差别在第五节的数据里会非常清楚:融合名次永远落在两路名次之间,
而重排名次可以把向量检索排第 6 的块直接提到第 3。
二、接上 rerank:本系列的第三套协议
2.1 同一家厂商,三种协议
到这一节为止,整条链路已经用到三家服务的三种协议。这是接真实模型时最容易被低估的成本:
| 能力 | 厂商 | 协议 | 地址 |
|---|---|---|---|
| 对话 | sensenova 网关 | OpenAI 兼容 | https://token.sensenova.cn(不能带/v1) |
| 向量化 | 阿里云百炼 | OpenAI 兼容 | https://dashscope.aliyuncs.com/compatible-mode(也不能带/v1) |
| 重排 | 阿里云百炼 | 原生协议 | https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank |
同一家厂商,embedding 有兼容端点、rerank 没有。实测把同样的请求打给兼容端点:
POST https://dashscope.aliyuncs.com/compatible-mode/v1/rerank -> HTTP 404,响应体为空(耗时 120 ms)空响应体是这几类错误里最难查的一种:401(密钥错)、429(限流)都会带一段说明,
而 404 空响应既不像配置错、也不像限流,唯一的定位手段是“换个地址再试一次”。
所以这一路必须自己拼 JSON、自己解析 JSON,跟对话/向量化是两套完全不同的代码。
2.2 原生的请求体与响应体(真实抓取)
POSThttps://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank{"model":"qwen3.7-text-rerank","input":{"query":"年假有多少天","documents":["员工每年享有 5 天带薪年假,入职满一年后开始计算,未休完的年假可以结转至次年三月底。","公司为全体员工提供补充医疗保险,覆盖门诊与住院费用,报销比例最高 90%。","运维值班分为白班与夜班,白班 9:00 到 18:00,夜班 18:00 到次日 9:00,交接必须有记录。"]},"parameters":{"return_documents":false,"top_n":3}}真实响应(214 ms,325 token):
{"output":{"results":[{"index":0,"relevance_score":0.913086706435771},{"index":1,"relevance_score":0.1489603740749516},{"index":2,"relevance_score":0.007197711682332408}]},"usage":{"prompt_tokens":325,"total_tokens":325},"request_id":"f6ca7b8e-6c65-9bf4-bc9d-e949ba75d98a"}三件事值得记下:
- 真正有用的只有
output.results,每项是index(原候选里的下标,从 0 起)与relevance_score(0~1)。它不会回传正文,也不保证index连续——
只有进了top_n的候选才出现; - 上面三条演示文本的分数是0.9131 / 0.1490 / 0.0072,
一条讲年假的文本拿 0.91,一条讲医保的拿 0.15,一条讲值班的只有 0.007。
区分度肉眼可见; return_documents设成false:正文我们手里已经有了,让接口原样回传纯属浪费带宽。
2.3 客户端的四个关键决定
publicRerankResponsererank(Stringquery,List<String>documents,inttopN){if(documents.isEmpty()){returnnewRerankResponse(List.of(),0,0,"(候选为空,未发起请求)");}...Stringraw=client.post().uri(properties.baseUrl()).header("Authorization","Bearer "+properties.apiKey()).contentType(MediaType.APPLICATION_JSON).body(body)// 用 String 接响应而不是直接反序列化成对象:// 原始响应体要回显给接口和日志,出问题时能一眼看到后端到底说了什么.body(String.class);...List<Hit>hits=parse(raw,documents.size());- 用
String收响应,再自己解析。多写十行,换来两件事:原始响应体能回显给接口
(排查时不用抓包),解析失败时能把原文一起抛出来; index越界立即抛异常:下标越界意味着“调用方与接口对候选的理解不一致”,
这是必须暴露的编程错误,不能默默丢掉;- 读超时单独放宽到 30 秒。重排是逐对计算,候选多时耗时会线性上涨,
超时给小了会表现成“偶发网络抖动”,而真正的原因是模型没算完; - 候选为空时不发请求。省一次调用,也避免“空数组”这种边界情况引发接口的奇怪响应。
配置单独一份,因为它是独立的模型服务:
rag:rerank:base-url:https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerankapi-key:${DASHSCOPE_API_KEY:...}model:qwen3.7-text-rerankread-timeout-ms:30000启动时踩的一个坑:
rerank包最初放在com.example.rerank下,
而启动类在com.example.rag——Spring Boot 的组件扫描根就是启动类所在的包,
于是启动直接失败:Parameter 1 of constructor in com.example.rag.chat.RagChatService required a bean of type 'com.example.rerank.RerankService' that could not be found.
解法不是放宽@ComponentScan,而是把包移到com.example.rag.rerank,与工程其余部分保持一致。
“用String收响应、再回显给接口”这条决定落到实处,就是本工程对外的那个只读接口——
一次调用同时给出before(向量顺序)与after(重排顺序),外加原始响应体:
三、重排到底改了什么:前后对照
3.1 一个问题看得最清楚
问题“年假有多少天”,粗筛池 6 条(库里总共就 6 块),重排取前 5:
| 序号 | 重排前(向量名次与分数) | 重排后(重排名次与分数) |
|---|---|---|
| 1 | 01-员工手册#0 0.5688 | 01-员工手册#00.9251(向量第 1) |
| 2 | 01-员工手册#1 0.3777 | 01-员工手册#1 0.3252(向量第 2) |
| 3 | 03-运维值班规范#1 0.2940 | 02-知识库#1 0.2476(向量第 6) |
| 4 | 02-知识库#0 0.2938 | 02-知识库#0 0.1962(向量第 4) |
| 5 | 03-运维值班规范#0 0.2775 | 03-运维值班规范#0 0.0946(向量第 5) |
| 6 | 02-知识库#1 0.2630 | (未进前 5,接口不返回) |
两件事同时发生:
- 名次换了:向量第 6 名的
02-知识库#1被提到第 3,把向量第 3 名的03-运维值班规范#1挤出前五。重排后挤进前 5、原本不在前 5 的:1 条; - 分数的分布变了:向量的第一名与第二名只差0.1911(0.5688 − 0.3777),
重排差0.5999(0.9251 − 0.3252)——区分度是向量的 3.1 倍。
第二件事比第一件更实用:向量分数挤在 0.26~0.57 这个窄带里,
阈值稍微动一点,命中数就从 5 跳到 1(第 08 篇的扫描表就是证据)。
而重排分数是 0.9251 / 0.3252 / 0.2476 / 0.1962 / 0.0946——
第一名拉开一大截,剩下几条明显是陪跑,这种分布才敢拿来卡阈值。
3.2 五个问题:不是每个问题都需要重排
| 问题 | token | 耗时 | 向量前 5 → 重排后前 5 | 提升 |
|---|---|---|---|---|
| 年假有多少天 | 2494 | 158 ms | 01#0, 01#1, 03#1, 02#0, 03#0→01#0, 01#1, 02#1, 02#0, 03#0 | 1 |
| 数据订正超过一万行由谁执行 | 2530 | 129 ms | 03#1, 01#1, 01#0, 03#0, 02#1→03#1, 03#0, 01#0, 01#1, 02#0 | 1 |
| 云枢知识库支持哪些文件格式 | 2512 | 125 ms | 02#0, 02#1, 01#1, 01#0, 03#0(重排后完全一致) | 0 |
| 180 天 | 2506 | 127 ms | 01#1, 01#0, 03#1, 03#0, 02#1→01#1, 01#0, 03#0, 02#0, 02#1 | 1 |
| v3.2.1 | 2506 | 134 ms | 02#0, 03#0, 03#1, 01#0, 02#1→02#0, 01#0, 03#0, 02#1, 01#1 | 1 |
(01#0=01-员工手册#0,以此类推)
逐条读下来有三个观察:
- 五个问题里四个各提升了 1 条,改动幅度不大但方向一致:都是把“更该进上下文的那条”提上来;
- “云枢知识库支持哪些文件格式”一条没动,且向量分数本来就分得开
(0.6667 / 0.5298 / 0.4558 …)。粗筛已经排对的时候,重排不会画蛇添足——
这一点很重要,说明重排是“修正”而不是“打乱”; - 前两名在全部五个问题里都没被改动过。重排动的是中后段的名次,
也就是决定“哪一条是第 3、第 4 名”这种真正影响上下文质量的位置。
3.3 一个容易被忽略的实现细节
重排只返回进了top_n的候选。上表里02#0(向量第 6)在重排结果里是不存在的,
接口一行都不给。所以回填代码不能写成“按返回顺序取第 i 条候选”,必须按下标对:
// 按 index 回填。注意:只有进了 top_n 的候选会出现在 results 里,// 没进榜的候选不会返回,所以这里不能假设"返回项数 == 候选数"。for(intrank=0;rank<response.hits().size();rank++){RerankClient.Hithit=response.hits().get(rank);intcandidateIndex=hit.index();Documentcandidate=candidates.get(candidateIndex);...}写错这一行的后果很隐蔽:名次看着有变化,但换到的是另一条文档——
比“完全没重排”更难发现,因为接口返回的一切看起来都正常。
四、它救回了什么:字面串案例
第 08 篇留下的那批“向量守不住的字面串”,正好拿来验收重排。判定基准是
“真正含这个字面串的块”(用 ES 的match_phrase直接查出来):
| 字面串 | 含它的块 | 向量第一名 | 重排第一名 | 结论 |
|---|---|---|---|---|
| Redis | 02-知识库#0 | ✘ 01-员工手册#1 | ✔02-知识库#0 | 重排救回 |
| DELETE | 03-运维值班规范#1 | ✔ 03-运维值班规范#1 | ✔ 03-运维值班规范#1 | 都命中 |
| 18:00 | 01-员工手册#1 | ✔ 01-员工手册#1 | ✔ 01-员工手册#1 | 都命中 |
| DBA | 03-运维值班规范#1、#0 | ✔ 03-运维值班规范#1 | ✔ 03-运维值班规范#1 | 都命中 |
| xlsx | 02-知识库#1、#0 | ✔ 02-知识库#1 | ✔ 02-知识库#1 | 都命中 |
五个里四个本来就是对的,唯一错的那个被纠正了。
这个比例(5 个错 1 个)比“重排能修一切”更接近真实:
它是一层保险,不是万能药。绝大多数问题粗筛就排对了,重排的价值恰好体现在剩下的少数上——
而那少数往往就是线上用户投诉的那几个查询。
五、三种排序放一起看
同一批 6 条候选,同一个问题,三种排序并列(ES 侧实测):
问题“年假有多少天”
| 块 | 向量名次 | 向量分 | 融合名次 | 重排名次 | 重排分 |
|---|---|---|---|---|---|
| 01-员工手册#0 | 1 | 0.5679 | 1 | 1 | 0.9251 |
| 01-员工手册#1 | 2 | 0.3796 | 2 | 2 | 0.3252 |
| 03-运维值班规范#1 | 3 | 0.2982 | 5 | (未进前 5) | - |
| 02-知识库#0 | 4 | 0.2979 | 3 | 4 | 0.1962 |
| 03-运维值班规范#0 | 5 | 0.2798 | 4 | 5 | 0.0946 |
| 02-知识库#1 | 6 | 0.2661 | 6 | 3 | 0.2476 |
问题“数据订正超过一万行由谁执行”
| 块 | 向量名次 | 向量分 | 融合名次 | 重排名次 | 重排分 |
|---|---|---|---|---|---|
| 03-运维值班规范#1 | 1 | 0.6088 | 1 | 1 | 1.0000 |
| 01-员工手册#1 | 2 | 0.3365 | 2 | 4 | 0.1686 |
| 01-员工手册#0 | 3 | 0.3270 | 4 | 3 | 0.2154 |
| 03-运维值班规范#0 | 4 | 0.2905 | 3 | 2 | 0.4066 |
| 02-知识库#1 | 5 | 0.2710 | 6 | (未进前 5) | - |
| 02-知识库#0 | 6 | 0.2571 | 5 | 5 | 0.1655 |
三件事一眼可见:
- 融合名次永远夹在两路原始名次之间。它是“名次的加权”,不引入新信息。
看03-运维值班规范#1(第 1 行):向量第 3、融合第 5——因为全文那一路没排它; - 重排名次可以跳出两路给出的顺序。
02-知识库#1向量第 6、融合第 6,
重排直接给到第 3。融合永远做不到这件事:两路都排最后的块,融合只会继续把它排最后; - 重排分数出现 1.0000。第二张表里讲数据订正的那块拿了满分——
它确实是唯一直接回答“由 DBA 执行”的段落。这种明确的分数是向量余弦给不出来的。
六、换掉向量库,重排结果会不会变?
这是这一篇最值得单独立一节的结果。
同样的问题、同样这批候选,分别在Elasticsearch与Qdrant上跑一遍
(切 profile 重启应用即可,检索代码一行没改):
| 块 | 向量分(ES) | 向量分(Qdrant) | 重排分(ES) | 重排分(Qdrant) | 重排分差值 |
|---|---|---|---|---|---|
| 01-员工手册#0 | 0.5679 | 0.5669 | 0.9251 | 0.9251 | 一致 |
| 01-员工手册#1 | 0.3796 | 0.3756 | 0.3252 | 0.3252 | 一致 |
| 03-运维值班规范#1 | 0.2982 | 0.2953 | -(未进前 5) | -(未进前 5) | - |
| 02-知识库#0 | 0.2979 | 0.3008 | 0.1962 | 0.1962 | 一致 |
| 03-运维值班规范#0 | 0.2798 | 0.2833 | 0.0946 | 0.0946 | 一致 |
| 02-知识库#1 | 0.2661 | 0.2769 | 0.2476 | 0.2476 | 一致 |
向量分数两库不同(这正是第 07 篇量过的分数噪声),重排分数逐位相同。
四个问题的重排名次也逐一核对过,全部一致:
| 问题 | ES 重排名次 | Qdrant 重排名次 |
|---|---|---|
| 年假有多少天 | 01#0, 01#1, 02#1, 02#0, 03#0 | 同 |
| 数据订正超过一万行由谁执行 | 03#1, 03#0, 01#0, 01#1, 02#0 | 同 |
| 云枢知识库支持哪些文件格式 | 02#0, 02#1, 01#1, 01#0, 03#0 | 同 |
| 180 天 | 01#1, 01#0, 03#0, 02#0, 02#1 | 同 |
原因不复杂:重排模型的输入只有 query 与文本,它根本不知道向量库是谁。
前面所有的分数差异都发生在“文本如何变成向量、向量如何比距离”这一段,
而重排压根不在这一段里。
6.1 顺带得到的第二个结论:融合在 Qdrant 上直接不可用
同一批实验在 Qdrant 上跑“三路对照”时,融合那一列整列是空的:
问题 [年假有多少天] 融合可用=False 重排 token=2494 耗时=158 ms 提升 1 条 块 向量名次 向量分 融合名次 重排名次 重排分 01-员工手册#0 1 0.5669 None 1 0.9251 ... 03-运维值班规范#1 4 0.2953 None None -这正是第 08 篇的结论在两处能力上的分界:
| 能力 | 依赖什么 | ES | Qdrant | 跨后端可复用 |
|---|---|---|---|---|
| 混合检索(向量 + 全文) | 后端自身的全文检索能力 | ✔ | ✘ 拿不到ElasticsearchClient | 否 |
| 重排 | 一个独立的重排接口 | ✔ | ✔ | 是 |
判据很简单:这一步是在“用后端的能力”,还是在“用文本”?
混合检索用的是后端的倒排索引,所以换库就断;
重排只吃文本,所以换库无感。这个判据可以直接拿来做架构决策——
哪些能力该封装成独立服务、哪些必须绑在某个存储上。
七、代价:重排不是免费的
7.1 候选越多越贵
问题“数据订正超过一万行由谁执行”,逐步加大候选数:
| 请求候选数 | 实际池大小 | token | 接口耗时 | 每条候选 | 返回条数 |
|---|---|---|---|---|---|
| 3 | 3 | 1058 | 123 ms | 41 ms | 3 |
| 5 | 5 | 1819 | 133 ms | 27 ms | 5 |
| 10 | 6 | 2530 | 128 ms | 21 ms | 5 |
| 20 | 6 | 2530 | 124 ms | 21 ms | 5 |
两点:
- token 与耗时随候选数上升,这是“逐对计算”的直接体现。本库只有 6 块,
所以请求 10 和 20 实际都只送了 6 条(池大小那列就是证据)——语料太小,
这个实验只能看出趋势,看不出线性; - 每条候选约21~41 ms。按这个量级推算:50 条候选约 1~2 秒。
这个延迟对同步接口是可以接受的,但它决定了候选池不能随便放大,
更不可能拿它替代粗筛。
7.2 真正的代价在提示词上(实测)
这是最容易忽略的一处。同一个问题,走两条路径问答:
| 问题 | 路径 | 池 | 检索/重排 | prompt token | 上下文 | 答案 |
|---|---|---|---|---|---|---|
| 年假有多少天 | 不重排 | 1 | 154 ms / 0 | 685 | 900 字 | 年假按司龄计算… [1] |
| 年假有多少天 | 重排 | 10 | 321 ms / 174 ms | 2001 | 3173 字 | 年假天数按司龄计算… [1] |
| 数据订正超过一万行由谁执行 | 不重排 | 1 | 154 ms / 0 | 248 | 204 字 | 数据订正超过一万行由 DBA 执行 [1] |
| 数据订正超过一万行由谁执行 | 重排 | 10 | 278 ms / 128 ms | 2077 | 3301 字 | 由 DBA 执行 [1] |
答案两两几乎一样,但 prompt token 涨了 3~8 倍(685 → 2001,248 → 2077)。
原因是我在实现里做的一个选择:重排路径取消了相似度阈值(为了让重排有料可挑),
于是上下文从“阈值筛剩下的 1 条”变成“重排要的前 5 条”。
这不是重排本身的问题,而是重排必须配一个截断策略:
- 要么把
topK降下来(比如重排前 10 条、只取 2 条进上下文); - 要么用重排分数做二级阈值——本例里 0.5 就够:
第一名 0.9251 稳稳过线,第二名 0.3252 与以下全部被挡掉,上下文只留真正相关的那一条。
重排真正的收益场景是“粗筛把答案排在第 3~10 名”:
那时重排把它提到第 1,同时把陪跑的块挡在截断线外,既提质量又省 token。
如果粗筛本来就只返回 1 条,重排只是徒增成本。
7.3 共享池网关的限流:错误码会被包装
跑实验时连续问答很快撞上限流。日志里的原始错误是:
HTTP 429 - {"error":{"message":"inference exceeds tpm/rpm limit", "type":"rate_limit_error","code":"insufficient_quota"}}但接口对外返回的是 HTTP 500(异常被统一包装过)。这个差别很容易带偏排查方向:
看到 500 会去翻代码,实际原因在日志里,是模型侧限流。
第一次写的重试逻辑只匹配429,于是永远不触发重试,实验直接中断。
改成“任何失败都退避重试”之后,等 35 秒重来即可通过。
这条对生产同样成立:限流要按“业务失败”兜底,而不是按错误码兜底——
错误码在到达你手里之前可能已经被包装过一轮。
八、工程规则
- 重排只做精排。候选池按 10~50 条设计,别拿它替代粗筛;
- 粗筛阶段不设阈值。先给重排一个完整的池子,截断留给重排之后;
- 重排之后一定要截断。实测不截断会让 prompt token 涨 3~8 倍而答案不变;
- 重排分数可以当二级阈值用。它 0~1、区分度是余弦的 3 倍以上,
本例 0.5 就能把陪跑块全部挡掉; - 接新协议先探测。同一家厂商 embedding 有兼容端点、rerank 没有,
兼容端点还会返回 404 空响应体——不探测就只能在启动时报错里猜; - 回填要按
index,不能按返回顺序。未进top_n的候选根本不会返回; - 限流按失败退避,不按错误码。错误码可能已被框架包装成 500;
- 判据:这一步用后端的能力还是用文本?用文本的(重排)可以跨后端复用,
用后端能力的(全文检索)换库即断。
小结
这一篇把“从候选到上下文之间”那一步填上了:
- 重排换了个做法:粗筛是 query 与文档各自压成向量再比距离,重排是把两者放在一起过模型。
更准,但代价是逐对计算,只能做精排; - 它确实改动了排序:五个问题里四个各提升 1 条,前两名一次没动过——
动的是决定上下文质量的中后段; - 分数分布的变化比名次更实用:向量第一名只领先 0.1911,重排领先 0.5999,
区分度 3.1 倍,这种分布才敢拿来卡阈值; - 它救回了字面串:
Redis从“输给不含它的块”被纠正为第一名;
五个探测词里只有一个原本是错的,重排是保险不是万能药; - 三种排序的定位清楚了:融合只在两路已有名次之间做加权、不引入新信息;
重排能跳出两路顺序(把向量第 6 名提到第 3); - 它不吃后端:换向量库后向量分数变了,重排分数逐位相同——
重排的输入只有 query 与文本。这条判据比结论本身更值钱; - 代价在提示词上:不配截断时 prompt token 涨 3~8 倍而答案不变;
重排分数当二级阈值(本例 0.5)是最省事的收口方式; - 限流会把错误码包装掉:网关 429 到了接口层变成 500,重试要按失败兜底。
到这里,“检索 → 重排 → 取前几条”这条挑选链路是完整的了。
但把这几条拼成提示词还有它自己的问题:总共能放多少字、超了砍谁、资料不够时怎么让模型坦白说不知道——
那属于上下文与生成那一侧的事,按下不表。