做 RAG 的人,应该都有过这么一瞬间:文档明明在知识库里,检索也返回了,可大模型就是抓不住重点,回答得像没听过这回事。问题通常不是大模型不行,而是你喂给它的上下文里,真正有用的段落被淹没在了一堆“看着相关”的段落里。
我前段时间帮某团队的售后知识库做了一次体检,发现最典型的场景就是:用户问“保修期内换屏要多少钱”,向量检索返回的Top 5里,前两名是营销活动页和旧版价目表,第三条才真正提到保修换屏政策。如果这时候直接把Top 5全部打包给大模型,它会倾向于把前几条里的信息也揉进回答,幻觉就这么来了。
这次我基于 LangChain4j 做了一版带“检索重排序”的 RAG 管线,核心思路很简单:先用向量召回一批候选段落,再用一个专门的排序模型对候选段落重新打分,最后只把高分段落交给大模型。整套改动下来,我们这边售后问答的命中率从大约 65% 提到了 90% 上下,答案的稳定性也明显变好。这篇文章我会把完整代码、参数调法、踩过的坑全写出来,适合已经跑通基础 RAG、但对检索质量不满意的 Java 技术栈同学。
1. 先搞清楚重排序在RAG里的位置
1.1 标准RAG流程
RAG 的完整链路大家应该不陌生:文档切分,段落向量化,写入向量库;用户提问时把问题向量化,从向量库里按相似度召回 Top K 个段落;这些段落拼进 Prompt,连同问题一起交给大模型生成答案。
这个链路里,真正决定答案质量的不是最后那一步生成,而是召回。如果召回回来的段落本身不对,你再怎么优化 Prompt,大模型也只能在错误的上下文里“编”。重排序要干预的,就是“召回”和“生成”之间那段夹缝。
1.2 向量召回的两个典型翻车现场
向量检索擅长的是“语义相近”,但它有个老毛病:它衡量的是 Query 和文档在向量空间里的距离,不是“这句话是不是真的能回答这个问题”。我实际排查时发现了两类高频翻车。
第一类是词面重叠误导。比如用户问“苹果保修政策”,文档里有一整段讲“苹果种植基地的病虫害防治”,因为都出现了“苹果”和“政策”这些词,它的向量距离可能意外地近,于是被排进了 Top 5。大模型拿到之后,不知道哪段才是用户想要的“苹果”,不产生幻觉才怪。
第二类是长文档里多主题混排。假设一页产品手册里,先写规格参数,再写故障排查,最后写售后条款。按段落切分后,如果切分粒度太大,一个段落里塞了三个主题,向量化出来的向量是个“四不像”,跟真实问题匹配时总差那么点意思,排名忽高忽低,很不稳定。
1.3 两阶段检索:粗召回加精排序
重排序本质上就是搜索引擎玩了很多年的两阶段思路。第一阶段用廉价手段,从海量文档里快速捞一批“可能相关”的候选;第二阶段用一个更强的模型,对这批候选做细粒度的相关性判断,把真正有用的排到最前面。
第一阶段用的通常是双塔结构,Query 和 Document 各自过一遍编码器,算向量夹角。速度快,能提前把候选范围缩小,但对词与词之间的深层交互关系建模不够。
第二阶段的交叉编码器则是把 Query 和 Document 拼成一个序列,一次性喂进模型,让每个 token 都能互相看到对方。这种深度交互能捕捉“这俩到底在不在说同一件事”,准确率高不少,但没法对全库文档一个个跑,太贵了,所以只能排在第二阶段用。
我做项目时给团队翻译成人话:向量召回是 HR 筛简历,按关键词和职位描述打个粗分;重排序是部门主管面试,聊几句才知道这人到底行不行。两道关卡各干各的活。
2. 项目设计与技术选型
2.1 演示场景与预期效果
这次落地的场景是某团队的产品售后知识库,混合了几类材料:产品说明书、FAQ、内部工单处理记录、售后政策公告。文档总数大概两千份,每份长短不一,全部转成纯文本之后开始做分块。
改造前的管线比较“质朴”:向量召回 Top 5,直接拼 Prompt。实测问题集大概 100 条,人工标注“应该在哪个文档里能找到答案”,Top 5 命中率 65%,也就是有三分之一的问题,正确文档根本不在候选里,后续怎么优化 Prompt 都白搭。
加了重排序之后,我把候选召回数放大到 20,再用重排序器取 Top 5,Top 5 命中率提升到 90%,Top 3 也稳定在 80% 左右。更关键的变化是,正确文档从原来的“排在第八九位”被捞回到了第一位,大模型拿到的上下文质量完全不同,回答里胡说八道的情况少了很多。
2.2 技术栈约定
先说版本约定,后面代码都基于这个环境:
| 组件 | 选择 | 说明 |
|---|---|---|
| 语言 | Java 17 | 用了 HttpClient 和 Stream,更老版本要换写法 |
| 构建 | Maven | 常规配置,无特殊插件 |
| RAG 框架 | LangChain4j 1.x | 以 1.x 系列的 API 为准 |
| 嵌入模型 | 你接入的 Embedding 服务 | 云端或私有化部署均可,代码里通过接口对接 |
| 向量库 | 先用 InMemoryEmbeddingStore | 演示和数据量不大时够用;生产量级再换外部向量库 |
| 重排序 | 自建 HTTP 接口或本地模型服务 | 见 2.3 的三种接入方式 |
这里稍微多说一句向量库。InMemoryEmbeddingStore 启动简单、零运维,适合验证流程和写 Demo。但文档量上来之后,内存里的线性比对会越来越慢,建议换用支持索引加速的外部向量数据库。好在 LangChain4j 把 EmbeddingStore 抽象成了接口,上层代码不用改,换一个实现类就行。
2.3 重排序器的三种接入方式
重排序模型怎么接入,是第一步要想清楚的。我整理了三种常用方式,各有取舍。
| 方式 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| SaaS 重排序 API | 接入最快,效果有保障,不用管模型部署 | 数据和查询要发到外部,延迟受网络影响,按量计费 | 原型验证、数据不敏感、团队没有模型部署能力 |
| 私有化部署开源交叉编码模型 | 数据不出内网,可自定义部署规格,延迟可控 | 要维护推理服务,机器成本在那 | 业务数据敏感,或调用量很大 |
| 本地推理库直接加载模型 | 极低延迟,离线可用 | 对 Java 生态和机器配置要求高,调优复杂 | 模型小、单机可担的场景 |
团队当时赶进度,先用第一种方式验证效果,验证通过后把模型换成了私有化部署的推理服务,接口协议保持一致,代码层面几乎没动。这也是我的建议:先用简单方式跑通流程,确认重排序真的带来收益,再考虑数据合规和成本问题。
3. 核心代码落地:在LangChain4j里接上重排序
3.1 依赖与配置
Maven 依赖里核心是 langchain4j 主包,以及我用的 JSON 解析库。嵌入模型和对话模型对应的接入模块,用哪个服务就加哪个,我这边的代码面向接口,不绑死具体实现。
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.2</version> </dependency>配置项我用 environment 注入,避免密钥写死在代码里。YAML 风格如下:
app: embedding-api: url: ${EMBEDDING_API_URL} api-key: ${EMBEDDING_API_KEY} rerank-api: url: ${RERANK_API_URL} api-key: ${RERANK_API_KEY} top-n: 5 min-score: 0.35 batch-size: 10重排序的 top-n 是最终要保留几条,min-score 是相关性下限,batch-size 是单次请求最多传多少条候选文档,后面代码里会用到。
3.2 文档切分与向量化入库
先把原始文档读进来,按段落切分。LangChain4j 里可以用 DocumentByParagraphSplitter,我给的参数是单个段落最大长度 600 个字符、重叠 80 个字符。字符数和 token 数不是一个概念,中文场景下 600 字符大约对应 200 到 300 个 token,后面重排序模型通常也卡在这个量级。
Document document = Document.from(text); DocumentSplitter splitter = new DocumentByParagraphSplitter(600, 80); List<TextSegment> segments = splitter.split(document);为什么必须做重叠?段落一旦被切分,边界上的句子很容易被硬生生切断。比如“保修期自激活之日起计算”,如果“自激活之日起计算”被切到下一段,前一段的“保修期”就成了无主信息。重叠 80 个字符能保证前后段落都保留这句话的完整上下文,代价是相邻段落会有重复内容,检索时可能出现两条重复命中,这在重排序阶段会被分数排序自然处理掉。
接下来建嵌入模型、向量库,把段落全部向量化入库:
EmbeddingModel embeddingModel = createEmbeddingModel(); EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); store.addAll(embeddings, segments);createEmbeddingModel 这个方法需要你自己对接嵌入服务。LangChain4j 的官方模块里有各家的 EmbeddingModel 实现,挑一个合适的用即可。嵌入模型建议固定版本,不要频繁更换,因为不同模型的向量空间不一致,换模型之后全库向量都得重新生成,代价不小。
3.3 自定义HTTP重排序器
这是整个改动的核心。我实现了一个 HttpClient 调用重排序接口的组件,候选文档传过去,服务端返回每个文档与 Query 的相关性分数。
先定义一个承载打分结果的对象:
record ScoredContent(int index, double score, Content content) { }index 是候选列表里的原始位置,score 是相关性分数,content 是原始的 Content 对象。记录 index 很关键,因为服务端返回的结果是乱序索引,我们得靠它找回原始段落。
核心逻辑如下:
public class HttpReranker { private final HttpClient httpClient; private final String apiUrl; private final String apiKey; private final int topN; private final double minScore; private final int batchSize; private final ObjectMapper objectMapper = new ObjectMapper(); public HttpReranker(String apiUrl, String apiKey, int topN, double minScore, int batchSize) { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .build(); this.apiUrl = apiUrl; this.apiKey = apiKey; this.topN = topN; this.minScore = minScore; this.batchSize = batchSize; } public List<Content> rerank(String query, List<Content> candidates) { if (candidates == null || candidates.isEmpty()) { return List.of(); } List<String> documents = candidates.stream() .map(content -> content.textSegment().text()) .toList(); List<ScoredContent> scored = new ArrayList<>(); // 分批调用,避免单次请求体过大 for (int batchStart = 0; batchStart < documents.size(); batchStart += batchSize) { List<String> batch = documents.subList(batchStart, Math.min(batchStart + batchSize, documents.size())); List<ScoredContent> batchScored = callRerankApi(query, batch, batchStart); scored.addAll(batchScored); } // 先按分数过滤,再按分数倒序取 topN List<ScoredContent> filtered = scored.stream() .filter(s -> s.score() >= minScore) .sorted(Comparator.comparingDouble(ScoredContent::score).reversed()) .limit(topN) .toList(); return filtered.stream() .sorted(Comparator.comparingInt(ScoredContent::index)) .map(ScoredContent::content) .toList(); } private List<ScoredContent> callRerankApi(String query, List<String> batch, int offset) { try { Map<String, Object> payload = new LinkedHashMap<>(); payload.put("query", query); payload.put("documents", batch); String body = objectMapper.writeValueAsString(payload); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(apiUrl)) .timeout(Duration.ofSeconds(5)) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new RuntimeException("Rerank API error, status=" + response.statusCode() + ", body=" + response.body()); } return parseResponse(response.body(), offset); } catch (IOException | InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("Rerank API call failed", e); } } private List<ScoredContent> parseResponse(String responseBody, int offset) throws JsonProcessingException { JsonNode root = objectMapper.readTree(responseBody); JsonNode results = root.path("results"); List<ScoredContent> list = new ArrayList<>(); for (JsonNode item : results) { int idx = item.path("index").asInt(); double score = item.path("relevance_score").asDouble(); list.add(new ScoredContent(offset + idx, score, null)); } return list; } }这段代码有几个细节值得展开说。
第一,parseResponse 里索引偏移量 offset 的处理。因为我把候选文档分批传了,第二批的 index 是从 0 开始的,所以要把 offset 加上,才能映射到完整的候选列表。我第一次写的时候漏了它,结果重排后的文档张冠李戴,检索记录一片混乱。
第二,注意请求超时。检索链路是同步调用,一旦重排序服务响应慢,整个问答接口都会被拖垮。我设置了连接超时 3 秒、请求超时 5 秒,线上环境根据实际情况再调。
第三,批次大小 batchSize 不要拉太大。有些重排序服务对单次请求的文档数量有限制,保守一点设 10 条,候选召回 20 条时也就是两次 HTTP 请求,成本可以接受。
回传 Content 对象的恢复逻辑在这段代码里省略了,因为 HttpReranker 只接收 Content,而回调里拿到的是候选列表,需要在主流程中根据 index 映射。我在 3.4 里的包装层会处理这一步,把分数和原始 Content 成对绑定。
3.4 包装ContentRetriever并接入AiServices
有了重排序器,下一步是把“召回候选”和“重排序截断”两个动作串起来。这里我直接包装 ContentRetriever,比修改链路内部更直观,也不容易被版本升级影响。
ContentRetriever baseRetriever = EmbeddingContentRetriever.builder() .embeddingModel(embeddingModel) .embeddingStore(store) .maxResults(20) .build(); HttpReranker reranker = new HttpReranker( rerankApiUrl, rerankApiKey, 5, // topN 0.35, // minScore 10 // batchSize ); ContentRetriever rerankedRetriever = query -> { List<Content> candidates = baseRetriever.retrieve(query); if (candidates.isEmpty()) { return List.of(); } List<scoredContent> scored = reranker.rerankWithScore(query.text(), candidates); return scored.stream() .filter(s -> s.score() >= reranker.getMinScore()) .limit(5) .map(s -> candidates.get(s.index())) .toList(); };注意两点。
第一,baseRetriever 的 maxResults 从原来的 5 调大到了 20。这是重排策略的关键:给第二阶段喂更多候选,让好内容有机会从“本来排不进前五”的位置被捞回来。如果召回窗口还是 5,重排序就没有操作空间。
第二,最终返回给 Prompt 的段落顺序,我会按原始召回顺序或重排后的分数顺序都行。实践下来,按重排分数倒序组装 Prompt,对模型更友好;但也有人习惯保持原文逻辑顺序,担心乱序影响阅读。我建议做个小实验,两组各测一次,哪个答案稳用哪个。
接着把包装好的 retriever 挂到 RetrievalAugmentor 上。我先加了多轮对话压缩,因为售后场景里用户极爱用代词:“它坏了”“这个怎么弄”。如果不先处理,向量检索拿到的 query 是“它坏了”,神仙模型也召回不了正确文档。
ChatModel chatModel = createChatModel(); QueryTransformer queryTransformer = CompressingQueryTransformer.builder() .chatModel(chatModel) .build(); RetrievalAugmentor augmentor = RetrievalAugmentor.builder() .queryTransformer(queryTransformer) .contentRetriever(rerankedRetriever) .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatModel(chatModel) .retrievalAugmentor(augmentor) .build(); String answer = assistant.answer("你好,我的机器开不了机,请问怎么排查?");Assistant 接口定义很简单:
interface Assistant { String answer(String message); }到这一步,整个 RAG 链路已经变成:问题进来,先压缩多轮对话,再向量召回 20 条候选,紧接着重排序器打一遍分,过滤掉低分段落,最后按分数取前 5 条进 Prompt。大模型看到的上下文,是经过精排的高质量段落,回答质量自然不一样。
3.5 验证重排序是否真的生效
接入之后别急着上线,先打日志做对比。我在包装层里加了两行日志:
List<Content> candidates = baseRetriever.retrieve(query); log.info("召回Top{}: {}", candidates.size(), candidates.stream() .map(c -> c.textSegment().text().substring(0, Math.min(50, c.textSegment().text().length()))) .toList()); List<Content> finalContents = rerankedRetriever.retrieve(query); log.info("重排后Top{}: {}", finalContents.size(), finalContents.stream() .map(c -> c.textSegment().text().substring(0, Math.min(50, c.textSegment().text().length()))) .toList());我用几条有代表性的问题做对照测试,包括:明确的问法、口语化问法、带错别字的问法、含代词的追问。每跑一条,人工看一下重排前后的顺序变化。
我整理一次典型的验证结果:
| 测试问题 | 未重排 Top5 第一条 | 重排后 Top5 第一条 | 答案质量 |
|---|---|---|---|
| 保修期内换屏多少钱 | 产品介绍页面 | 保修换屏政策段落 | 明显改善 |
| 设备无法开机怎么办 | 参数规格 | 故障排查步骤 | 明显改善 |
| 退货运费谁出 | 商城活动描述 | 售后物流政策 | 明显改善 |
日志里如果发现重排后的第一条还不如原来,别急着怀疑模型,先检查是不是候选数量太少、分段过大、或者多轮对话没压缩好。这些问题在第 5 节展开。
4. 参数调优与效果评估
4.1 召回窗口和保留数怎么定
重排序不是魔法,它的天花板由第一阶段召回质量决定。如果正确内容压根没被召回,重排序再准也捞不回来。
我建议把 baseRetriever 的 maxResults 从默认 5 提到 20 或 30。理论上窗口越大越好,但窗口越大,重排序请求的文档数越多,延迟和费用都会上升。实测下来,文档类型比较杂的知识库,20 条比 10 条有明显收益;再往上到 30 条,收益增长就明显变缓,甚至没有。
最终保留下来的条数,也就是 topN,我强烈建议不要超过 5。给大模型塞 8 条、10 条相关段落,看起来信息丰富,实际上会把真正核心的答案淹没在冗余文字里,模型反而更容易跑偏。
参数组合建议用一个简单矩阵:
| 阶段 | 推荐值 | 依据 |
|---|---|---|
| 向量召回条数 | 20 起步 | 太少没得重排,太多延迟高 |
| 重排序保留数 | 3 到 5 | 取够答主需要的核心段落即可 |
| 分数阈值 | 0.3 到 0.5 之间试 | 见 4.2 |
| 单文档最大字符 | 600 左右 | 匹配重排序模型输入上限 |
4.2 分数阈值怎么定
重排序返回的分数不是“概率”,不同模型分数分布差异很大。有的模型给分很松,普遍 0.8 以上;有的模型给分很紧,最高才 0.5。所以不要照搬别人的阈值,一定要看你自己的实际分数分布。
我的做法是跑一遍 100 条测试问题,把重排后的分数全部收集起来,按“正确答案是否排在第一”分两组,统计两组分数范围。阈值可以取“正确答案能覆盖住、噪声段落被挡在门外”的那个折中点。
阈值设太高还有个风险:用户问题太偏的时候,所有候选段落分数都低于阈值,最后没有内容可注入。我建议做一层兜底:如果过滤后为空,就把阈值放宽到最低值的 70%,或者直接返回“未找到相关资料”。宁可不答,也不要硬塞不相关内容让它编。
4.3 快速评测方法和指标
聊效果不能靠感觉。我平时用一套轻量评估法:准备 20 到 50 条真实用户问题,人工标注“应该在哪个文档/段落里能找到答案”,然后写个小脚本跑一遍检索,分别记录重排前和重排后的 Top K 命中情况。
主要看三个指标:
| 指标 | 含义 | 怎么算 |
|---|---|---|
| Recall@K | 正确答案是否在 Top K 里 | 命中的问题数 / 总问题数 |
| MRR | 第一个正确答案排多靠前 | 依次求和 1/排名 再平均 |
| 答案质量 | 生成回答是否准确完整 | 人工打分或模型打分辅助 |
我最关注的是 MRR。假设正确文档在重排前排在第八,MRR 只贡献 0.125;重排后提到第一,MRR 贡献变成 1。MRR 对“提位”非常敏感,正好能反映重排序器的价值。
如果你想让评估更自动,可以让大模型做裁判,给生成回答打准确度分,再和人工标注的命中结果交叉验证。注意模型打分只能做辅助参考,最终上线前还是得人工抽几条看一遍。
5. 常见问题与避坑指南
5.1 重排序之后答案反而变差
我遇到过一个特别典型的案例:重排后第一条文档确实和问题高度相关,但它只是一个碎片化的小段落,缺少前后语境。大模型看到这段孤立文本后,虽然有相关词,却没有足够条件推导出完整结论。
后来我在分块时把每个段落所属的“文档标题 + 二级标题”拼进段落最前面,作为头部元数据。比如“《售后政策》> 保修期规定:保修期为一年……”,这样重排序模型在打分时能看到更多上下文,大模型拿到的信息也更完整。这招对说明书类的层级文档尤其有效。
5.2 响应变慢,线上超时
重排序是在向量检索之后又加了一次模型推理,整体增加 100 到 300 毫秒延迟,如果服务部署在外网,可能更高。排查时先不要急着降级,按顺序做三件事。
第一,压缩召回窗口,从 30 降到 20,观察命中率是否有明显下降。第二,给重排序请求加超时和熔断,超时就跳过重排,直接用向量召回结果兜底。第三,针对高频相似问题做结果缓存,命中了就直接用上次的重排结果,不再重新计算。缓存要注意维护失效策略,文档更新后要把相关缓存清掉。
5.3 多轮对话代词导致召回失败
“它坏了”“这个怎么弄”“那运费呢”这类问法,向量检索基本必死。CompressingQueryTransformer 会结合历史消息把当前问题重写成一个独立、完整的查询。比如“那运费呢”可能会被重写成“退货时运费由谁承担”,这样检索才能命中。
如果不想依赖大模型做压缩,也可以用启发式方案:把最近两轮用户消息拼起来作为检索 Query。简单,省 token,效果在多数场景下够用。但遇到跨了好几轮才追问的场景,简单的拼接就容易失效,所以我目前还是倾向用模型压缩的方案。
5.4 版本和依赖坑
LangChain4j 版本迭代挺活跃的,RAG 相关接口在几个大版本间调整过不止一次。我第一次参考旧博客写代码时,ContentAggregator 的接口签名就和本版对不上,编译直接挂。建议项目里锁定一个具体版本,升级时先查官方迁移说明,别盲目升。
依赖冲突方面,最常见的是 Jackson 版本打架。嵌入服务 SDK、重排序 SDK、主框架各自传递依赖了不同版本的 Jackson,可能导致启动时 JsonMappingException 之类的诡异问题。用 mvn dependency:tree 查一下,把重复的传递依赖用 exclusions 去掉。
5.5 重排序模型长度限制与分块矛盾
交叉编码器模型的输入长度一般有限制,常见的是 512 个 token 左右。如果你把段落切到 1000 字符,传给重排序模型时可能会被截断,那么模型看的其实是残缺的文本,打出来的分数可信度会打折扣。
解决思路可以从两个方向走:一是把分块大小压到与模型输入长度匹配;二是保留超长段落,但重排序时只取每个段落的前 200 个字和元数据做打分,等确定入选后再把完整段落传给大模型。方案二精度上有点风险,但能省事,适合时间紧的时候先用。
5.6 成本控制与数据隐私
重排序是按调用量产生费用的,候选数量越大成本越高。召回 20 条,每次问答就是一批重排序请求,量大了不是个小数目。控制批次大小、加缓存、对长尾低价值问题跳过重排序,都是在成本和质量之间切一刀。
数据隐私方面,如果团队文档里有合同、工单、客户信息这类敏感内容,先把数据脱敏再对外调用重排序服务。条件允许的话,优先把模型部署到内网。我当时就是验证完效果后,立刻切到了私有化部署,接口协议不变,只是把 API 地址和密钥换了一下。
最后说几句个人体会
重排序不是银弹,它解决的是“召回管道最后一公里的排序精度”问题。如果切分粗糙、Embedding 模型选得随意、文档本身质量差,重排序只能救回来一点点,救不了全部。我的建议是先把分块、元数据、检索窗口这些基础工夫做扎实,再上重排序,效果才会明显。
调试重排序那段时间,我养成了习惯:每次改参数,都把“召回前 20 条 + 重排后 5 条 + 用户原始问题 + 最终答案”整理成一份测试报告,存成 Markdown,方便回看对比。这样做的好处是,过两周回头看改动时,不会凭感觉说“好像变好了”,而是有数据摆在眼前。
如果后续想继续扩展,这条路也值得试:把重排序分数沉淀成“文档优先级”信号,结合真实用户反馈做增量学习,让检索系统越用越准。总之,先把今天这套重排序跑通,再谈优化,方向不会错。