搜索质量是 RAG(Retrieval-Augmented Generation,检索增强生成)应用绕不开的核心问题。很多团队在搭建 Agent 与大模型应用时,前期把精力都放在 Prompt 编排和模型选型上,结果上线后发现:回答经常“牛头不对马嘴”,或是模型一本正经地引用无关文档。问题往往不在大模型本身,而在检索这一环——相关性没有做好。
本文将围绕“相关性”展开,梳理传统检索算法的局限、向量检索的新问题,以及在 Agent 与 RAG 架构下如何重新设计相关性链路。文章会给出完整的混合检索 + 重排序 + Agent 工具封装代码,并附上评测方法与工程避坑清单。无论你是刚接触 RAG 的新手,还是正在优化线上检索效果的后端开发者,都可以从中找到可落地的思路。
1. 为什么智能搜索的瓶颈在“相关性”
1.1 从一次失败的 Agent 问答说起
先看一个典型场景。内部知识库接入了大模型问答助手,团队把几千篇产品文档做了向量化,存入向量数据库。用户提问:“退款申请提交后多久能到账?”助手返回的答案是:“退款到账时间取决于银行处理速度,一般 1 到 3 个工作日。”
看起来没毛病。但如果知识库中真正想表达的是“提交退款后,预计 24 小时内到账,节假日顺延”,那这个回答就存在明显的信息偏差。再追问一句“支持撤回退款申请吗?”,助手可能从某篇过时文档中找到“不支持撤回”,给出错误答案。
这类问题的根源不在于大模型不会生成,而在于它“没找到对的内容”。模型只能基于检索回来的片段作答,检索结果里没有正确答案,再强的生成能力也无济于事。这就是“相关性”问题——它是 RAG 应用的命门。
1.2 什么是相关性:搜索引擎的老问题
相关性(Relevance)是信息检索领域最古老的概念之一。简单说,就是“给定一个查询,返回的文档与这个查询的匹配程度”。传统搜索引擎用关键词命中率、网页权重、用户点击行为来综合判断相关性。
到了大模型时代,相关性的定义发生了一个微妙变化:
- 传统搜索的相关性:查询词与文档内容的词汇匹配程度。
- 大模型 RAG 的相关性:查询意图与文档语义信息的匹配程度。
换句话说,用户通过 Agent 提问时,往往不是输入几个关键词,而是输入一段自然语言描述。模型需要理解“他要什么”,而不是“他说了什么词”。这就让相关性从“字符串匹配”升级成了“意图匹配”。
1.3 大模型时代相关性为什么更重要
大模型时代,相关性直接影响三个关键指标:
| 影响维度 | 相关性差的表现 | 后果 |
|---|---|---|
| 回答准确率 | 模型引用无关段落 | 生成答案错误,甚至幻觉 |
| 首轮命中率 | 需要多轮检索才能找到内容 | 用户等待时间长,体验差 |
| 上下文窗口利用率 | 无关文档占用大量 token | 有效信息被稀释,成本上升 |
一个高相关性的检索链路,能让 Agent 在第一次检索时就找到最关键的 2 到 3 个片段。这不仅提升了准确率,还降低了重复检索带来的延迟和成本。因此,重新审视“相关性”这个基础概念,是优化 Agent 搜索的第一步,也是最关键的一步。
2. 传统相关性算法到底差在哪里
2.1 BM25 与 TF-IDF:关键词匹配的局限性
先回顾两个经典算法。
TF-IDF(词频-逆文档频率)的核心思想是:一个词在文档中出现的次数越多越重要,但在整个语料库中出现的次数越多越不重要。公式可以简化为:
TF-IDF = 词频(TF) × 逆文档频率(IDF)BM25 是在 TF-IDF 基础上改进的排序函数,引入了文档长度归一化和词频饱和机制,避免长文档因为词多而占便宜。直到今天,BM25 仍然是文本检索的强基线,很多搜索引擎的召回阶段仍在用它。
但这两类算法有一个共同的硬伤:词面匹配。它们只能判断“查询里出现了哪些词”,无法理解“这些词组合起来表达什么意思”。
举个例子:
- 查询:“如何关闭自动续费”
- 文档 A:“用户可以在设置中手动关闭续费服务,关闭后不再扣费”
- 文档 B:“续费功能说明:系统将在到期前自动扣费,如需取消请联系客服”
用 BM25 计算,文档 A 和文档 B 都命中了“关闭”和“续费”,但文档 B 的实际意思更接近“如何取消续费”。而对“关闭”与“取消”这类同义词,BM25 完全无法识别。
2.2 向量检索:语义匹配带来的新问题
为了解决同义词和语义理解问题,业界引入了向量检索。流程是:
- 将文档切块后用 Embedding 模型编码成向量。
- 将用户查询编码成向量。
- 计算查询向量与文档向量的余弦相似度,返回 Top K 结果。
向量检索的优势非常明显:它能捕捉语义相似性。“关闭”和“取消”的向量距离很近,因此即使文档里没有出现查询中的词,也能被检索到。
但向量检索并不是灵丹妙药,它引入了新的问题:
- 语义漂移:向量空间里“相似”不等于“相关”。两个句子可能用了相近的表达,但语境完全不同。
- 缺乏精确匹配能力:查询“API 版本 v2.3 接口变更”,向量检索很可能被“v2.4 接口说明”吸引走,因为语义上有重叠,但版本号对不上。
- Embedding 模型偏见:不同模型对领域术语的理解差异很大,通用模型在专业领域往往表现不佳。
2.3 一段代码对比:关键词检索与语义检索的差异
下面用一个简单的 Python 示例演示两种检索的差异。这里我们使用rank_bm25做关键词检索,使用sentence-transformers做向量检索。
# 文件路径:examples/compare_search.py # 依赖:pip install rank-bm25 sentence-transformers numpy from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer import numpy as np # 示例文档集合 documents = [ "用户可以在设置中手动关闭自动续费服务,关闭后不再扣费。", "续费功能说明:系统将在到期前自动扣费,如需取消请联系客服。", "本产品支持按月付费和按年付费两种模式,付费周期可随时调整。", "更新日志:v2.4 版本修复了支付回调偶发超时的问题。", ] query = "如何关闭自动续费" # ---------- BM25 检索 ---------- tokenized_docs = [list(doc) for doc in documents] bm25 = BM25Okapi(tokenized_docs) tokenized_query = list(query) bm25_scores = bm25.get_scores(tokenized_query) top_bm25 = np.argsort(bm25_scores)[::-1][:2] print("=== BM25 Top2 ===") for idx in top_bm25: print(f"score={bm25_scores[idx]:.2f} doc={documents[idx]}") # ---------- 向量检索 ---------- model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") query_vec = model.encode(query, normalize_embeddings=True) doc_vecs = model.encode(documents, normalize_embeddings=True) cos_scores = doc_vecs @ query_vec top_vec = np.argsort(cos_scores)[::-1][:2] print("\n=== 向量检索 Top2 ===") for idx in top_vec: print(f"score={cos_scores[idx]:.4f} doc={documents[idx]}")运行结果大致如下:
=== BM25 Top2 === score=2.38 doc=用户可以在设置中手动关闭自动续费服务,关闭后不再扣费。 score=1.88 doc=续费功能说明:系统将在到期前自动扣费,如需取消请联系客服。 === 向量检索 Top2 === score=0.8211 doc=用户可以在设置中手动关闭自动续费服务,关闭后不再扣费。 score=0.7812 doc=续费功能说明:系统将在到期前自动扣费,如需取消请联系客服。在这个简单例子里两者表现接近。但如果把文档改成“如何解约订阅服务”,BM25 因为缺少“关”“续费”等字词会直接失配,而向量检索仍然能通过语义相关性找到答案。反过来,如果查询是“v2.4 版本更新内容”,向量检索可能因为“更新内容”语义相近而把 v2.3 的文档排到前面,精准度下降。
这告诉我们一个道理:关键词检索与语义检索不是替代关系,而是互补关系。这也是后面混合检索方案的基础。
3. 重新定义相关性:从关键词到意图
3.1 相关性的三个层次
在 Agent 搜索场景中,我们可以把相关性拆成三个层次来理解。
第一层:词面相关性
查询中的关键词在文档中是否出现,出现频率如何。这是 BM25 擅长的部分,适合处理产品型号、订单号、报错码等精确类信息。
第二层:语义相关性
查询与文档在语义空间中是否接近。这是向量检索擅长的部分,适合处理同义替换、口语化表达、长句描述。
第三层:意图相关性
文档是否真正回答了查询背后的用户意图。这是最容易被忽略的一层。例如用户问“退款要多久”,他的意图是“想知道一个时间预期”,那么一篇介绍退款政策历史沿革的文档即使提到了“退款”二字,也不如一篇直接写“1 到 3 个工作日”的 FAQ 相关。
“重新定义相关性”的核心,就是把相关性从“词面匹配”升级到“意图匹配”,并通过工程手段将这三种相关性有机整合。
3.2 Agent 搜索中的相关性链路
在一个典型的 Agent 搜索链路中,相关性发生在多个环节:
用户问题 ↓ Query 理解(改写、扩展、意图识别) ↓ 召回阶段(BM25 + 向量检索并行) ↓ 融合阶段(RRF 或加权融合) ↓ 重排序阶段(CrossEncoder / LLM Rerank) ↓ 上下文组装(过滤、截断、排序) ↓ 大模型生成回答这个链路中,每一层都在重新定义“什么是相关内容”。Query 理解阶段负责把口语化问题改写成更适合检索的形式;召回阶段保证“不遗漏”;融合阶段处理两种检索结果的排序冲突;重排序阶段用更精细的模型从粗召回结果中挑出真正相关的片段。
很多 RAG 项目只做了“向量检索 + 直接返回”,跳过了融合和重排序,结果就是召回率尚可、精确率很差。这也是“为什么我的 RAG 回答总是不准确”最常见的原因。
3.3 Agentic RAG 与工具调用对搜索的影响
最近“Agentic RAG”概念比较火。与传统 RAG 的单轮检索不同,Agentic RAG 让模型具备调用检索工具、观察结果、决定是否再检索的能力。这实际上把“相关性”判断从一次性的检索环节,延展到了多轮决策环节。
在这种架构里,相关性链路变得更加复杂:
- Agent 需要判断“当前检索结果是否足够回答问题”。
- Agent 需要决定“是否需要换一个关键词重新检索”。
- Agent 需要综合多轮检索结果,剔除重复和矛盾信息。
因此,Agent 搜索中相关性的定义不能只看“这一次检索返回了什么东西”,还要看“整体检索策略能否高效逼近目标”。一个优秀的 Agent 应具备自我纠偏能力:第一轮检索结果相关性不足时,能主动改写 Query 再搜一轮,而不是硬着头皮基于低质量结果生成答案。
4. 完整实战:构建一个高精度智能搜索链路
接下来进入实战环节。我们构建一个轻量级的“混合检索 + 重排序”搜索链路,并把它封装成 Agent 可调用的工具。
4.1 系统架构与流程
整体流程分四步:
- 对用户查询做预处理(可选 Query 改写)。
- 并行执行 BM25 检索和向量检索,各取 Top 20。
- 使用 RRF(Reciprocal Rank Fusion,倒数排名融合)合并结果。
- 使用 CrossEncoder 重排序模型对融合结果打分,取 Top 3 作为上下文。
这个结构在保证召回率的同时,通过重排序提升了精确率。
4.2 环境准备与项目结构
本文示例以 Python 3.9+ 为例,主要依赖如下:
pip install rank-bm25 sentence-transformers cross-encoder numpy版本说明:
sentence-transformers和cross-encoder升级较快,不同版本 API 可能有细微差异。本文代码以常见稳定用法为例,如果你使用的版本较新,请以官方文档为准。
项目结构:
agent-search/ ├── data/ │ └── docs.txt # 文档语料 ├── search/ │ ├── __init__.py │ ├── hybrid_search.py # 混合检索 │ ├── reranker.py # 重排序 │ └── agent_tool.py # Agent 工具封装 ├── examples/ │ └── run_search.py # 运行示例 └── requirements.txt4.3 混合检索实现
混合检索的核心是同时执行关键词检索和语义检索,然后合并结果。
# 文件路径:search/hybrid_search.py from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer import numpy as np class HybridSearch: def __init__(self, docs, model_name="paraphrase-multilingual-MiniLM-L12-v2"): """ docs: list[str],文档列表 model_name: 用于语义检索的 Embedding 模型 """ self.docs = docs self.model = SentenceTransformer(model_name) self.doc_vectors = self.model.encode(docs, normalize_embeddings=True) # 构建 BM25 索引 tokenized_docs = [list(doc) for doc in docs] self.bm25 = BM25Okapi(tokenized_docs) def search(self, query, top_k=20): """ 混合检索:BM25 与向量检索并行,RRF 融合 """ # BM25 检索 tokenized_query = list(query) bm25_scores = self.bm25.get_scores(tokenized_query) bm25_top = np.argsort(bm25_scores)[::-1][:top_k] # 向量检索 query_vec = self.model.encode(query, normalize_embeddings=True) cos_scores = self.doc_vectors @ query_vec vec_top = np.argsort(cos_scores)[::-1][:top_k] # RRF 融合 rrf_scores = {} for rank, idx in enumerate(bm25_top): rrf_scores[idx] = rrf_scores.get(idx, 0) + 1 / (60 + rank + 1) for rank, idx in enumerate(vec_top): rrf_scores[idx] = rrf_scores.get(idx, 0) + 1 / (60 + rank + 1) # 按 RRF 分数排序 sorted_idx = sorted(rrf_scores.keys(), key=lambda x: rrf_scores[x], reverse=True) return [(idx, self.docs[idx], rrf_scores[idx]) for idx in sorted_idx[:top_k]]这里需要解释几个关键点:
normalize_embeddings=True让向量归一化,后续直接用点积计算余弦相似度,速度更快。- RRF 的公式是
1 / (60 + rank),其中 60 是经验常数,可以按数据规模调整。 - RRF 不依赖分数绝对值,只依赖排序位置,因此能规避 BM25 分数和余弦分数量纲不同的问题。
4.4 重排序模块
召回阶段的目标是“不遗漏”,重排序阶段的目标是“精确”。我们使用 CrossEncoder 模型对召回的候选片段重新打分。
# 文件路径:search/reranker.py from cross_encoder import CrossEncoder class Reranker: def __init__(self, model_name="cross-encoder/ms-marco-MiniLM-L-6-v2"): self.model = CrossEncoder(model_name) def rerank(self, query, candidates, top_k=3): """ candidates: list[tuple(idx, doc, rrf_score)] """ pairs = [(query, doc) for _, doc, _ in candidates] scores = self.model.predict(pairs) # 按 CrossEncoder 分数排序 scored = [] for (idx, doc, rrf_score), ce_score in zip(candidates, scores): scored.append((idx, doc, ce_score)) scored.sort(key=lambda x: x[2], reverse=True) return scored[:top_k]注意,这里用的是cross_encoder直接预测,它和 BiEncoder 的区别在于:CrossEncoder 会把查询和文档拼接成一个输入序列,让模型在深层交互中判断相关性,精度更高,但计算成本也更高。因此它只适合对粗召回结果(几十条)做重排序,不适合全量文档打分。
4.5 Agent 工具封装
为了对接 Agent 框架,我们把搜索能力封装成一个工具函数。这里以函数调用风格为例,不绑定具体 Agent 框架,方便你迁移到 LangChain、Dify 或其他框架。
# 文件路径:search/agent_tool.py from .hybrid_search import HybridSearch from .reranker import Reranker class SearchTool: def __init__(self, docs): self.hybrid = HybridSearch(docs) self.reranker = Reranker() def search(self, query: str, top_k: int = 3) -> list: """ Agent 可调用的搜索工具。 参数: query: 用户问题或经过改写后的检索式 top_k: 返回结果数量 返回: list[dict]: 每个元素包含 doc 和 score """ # 1. 粗召回 candidates = self.hybrid.search(query, top_k=20) # 2. 重排序 results = self.reranker.rerank(query, candidates, top_k=top_k) # 3. 规范输出格式 return [ {"content": doc, "score": float(score)} for idx, doc, score in results ]封装成工具后,Agent 的调用方式会变成:
用户: 退款多久到账? Agent 思考: 需要查询知识库,调用 search 工具。 Agent 调用: search(query="退款到账时间") 工具返回: [{"content": "提交退款后24小时内到账,节假日顺延", "score": 8.32}, ...] Agent 生成: 根据搜索结果生成回答。4.6 运行与验证
我们先准备一个小型语料库,模拟常见 FAQ。
# 文件路径:data/docs.txt 用户提交退款申请后,系统将在24小时内审核,审核通过后原路返回。 退款到账时间取决于银行处理速度,一般1到3个工作日,节假日顺延。 本产品支持按月付费和按年付费,付费周期可在设置中随时调整。 自动续费服务默认开启,用户可以在设置中手动关闭。 关闭自动续费后,当前周期结束后不再扣费。 v2.4 版本修复了支付回调偶发超时的问题。 v2.3 版本新增了发票申请功能。 发票申请入口位于个人中心-财务中心,支持普通发票和增值税专用发票。 如果用户忘记密码,可以点击登录页面的"忘记密码"链接,通过邮箱重置。 账号注销后,用户数据将保留30天,30天后永久删除。运行示例:
# 文件路径:examples/run_search.py import sys sys.path.append("..") from search.agent_tool import SearchTool # 加载文档 with open("../data/docs.txt", "r", encoding="utf-8") as f: docs = [line.strip() for line in f if line.strip()] tool = SearchTool(docs) queries = [ "退款多久到账?", "怎么关闭自动续费", "如何申请发票", "v2.3 版本有什么新功能", ] for q in queries: print(f"\n===== 查询: {q} =====") results = tool.search(q, top_k=2) for r in results: print(f"score={r['score']:.4f} {r['content']}")预期输出效果:对于“退款多久到账”,第一条结果应该是“退款到账时间取决于银行处理速度,一般1到3个工作日”。对于“怎么关闭自动续费”,系统应优先返回“自动续费服务默认开启,用户可以在设置中手动关闭”,而不是其他无关文档。
这套链路虽然简单,但已经具备生产级搜索的基本骨架:混合召回保证覆盖,重排序保证精度,工具封装方便 Agent 集成。
5. 相关性评测:不要凭感觉调优
5.1 构建评测集
搜索链路搭建完成后,最忌讳的一件事是“凭感觉调参”。今天觉得这个文档匹配了,明天觉得那个没匹配,全靠主观判断,无法持续优化。
正确做法是构建一个最小的评测集。每条评测数据包含三个字段:
[ { "query": "退款多久到账", "relevant_doc_ids": [0, 1], "irrelevant_doc_ids": [3, 4] }, { "query": "怎么关闭自动续费", "relevant_doc_ids": [3, 4], "irrelevant_doc_ids": [0, 2] } ]其中relevant_doc_ids是人工判断“真正回答了问题”的文档编号。评测集规模不用大,30 到 50 条就能覆盖主要场景,关键是保证标注质量。
5.2 核心指标:Recall@k、MRR、NDCG
推荐三个指标:
Recall@k(召回率)
表示前 k 个结果中命中了多少相关文档。
Recall@k = 前 k 个结果中的相关文档数 / 总相关文档数MRR(Mean Reciprocal Rank,平均倒数排名)
只关心第一个相关结果的位置。第一个命中越靠前,MRR 越高。
MRR = 1 / rank_of_first_relevant_docNDCG(Normalized Discounted Cumulative Gain,归一化折损累计增益)
考虑相关文档的排序位置,且支持多级相关性打分(如 0 不相关、1 部分相关、2 高度相关)。
# 文件路径:examples/evaluate.py import numpy as np def recall_at_k(ranked_ids, relevant_ids, k): """计算前 k 个结果中相关文档的召回率""" hit = set(ranked_ids[:k]) & set(relevant_ids) return len(hit) / len(relevant_ids) def mrr(ranked_ids, relevant_ids): """计算第一个相关结果的倒数排名""" for i, doc_id in enumerate(ranked_ids): if doc_id in relevant_ids: return 1.0 / (i + 1) return 0.0 def ndcg_at_k(ranked_ids, relevance_scores, k=10): """计算 NDCG@k,relevance_scores 是 doc_id -> 相关性分数 的映射""" dcg = 0.0 for i, doc_id in enumerate(ranked_ids[:k]): rel = relevance_scores.get(doc_id, 0) dcg += (2 ** rel - 1) / np.log2(i + 2) # 理想排序下的 DCG sorted_rel = sorted(relevance_scores.values(), reverse=True)[:k] idcg = sum((2 ** rel - 1) / np.log2(i + 2) for i, rel in enumerate(sorted_rel)) return dcg / idcg if idcg > 0 else 0.0 # 示例验证 ranked_ids = [3, 0, 1, 2] # 搜索返回的文档顺序 relevant_ids = {0, 1} # 真正相关的文档 relevance_scores = {0: 2, 1: 2, 2: 0, 3: 0} print(f"Recall@2 = {recall_at_k(ranked_ids, relevant_ids, 2):.2f}") print(f"MRR = {mrr(ranked_ids, relevant_ids):.2f}") print(f"NDCG@4 = {ndcg_at_k(ranked_ids, relevance_scores, 4):.2f}")每次调整检索链路后,跑一遍评测集,对比指标变化。指标提升了再上线,指标回退了就回滚配置。这样优化才有章法。
5.3 回归测试与线上监控
除了离线评测集,线上监控同样重要。
推荐收集两类数据:
- 用户反馈数据:用户在 Agent 回答后点击“有帮助 / 没帮助”,这些信号可以反推检索质量。
- 检索日志:记录每次查询的 Top K 结果和最终答案,定期抽样人工复核。
有条件的团队可以建立“bad case 库”,把用户反馈差的问题沉淀下来,定期补充进评测集,形成“评测集扩充 → 模型调优 → 回归验证 → 线上监控”的闭环。
6. 常见问题与排查思路
在实际落地过程中,开发者经常会遇到下面几个问题。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 检索返回了语义相似但内容错误的结果 | 仅用向量检索,缺少精确匹配能力 | 引入 BM25 混合检索,或增加关键词过滤条件 |
| 多个文档内容相近,顺序不稳定 | RRF 融合参数或重排序模型不稳定 | 调整 RRF 常数,检查重排序模型是否适合领域 |
| 专业术语查询效果差 | Embedding 模型不够懂领域 | 更换领域微调模型,或在 Query 改写阶段补充同义词 |
| 长文档包含答案但被切碎 | 分块策略不合理,答案被截断 | 调整分块大小与重叠度,采用父子分块策略 |
| Agent 回答引用过时内容 | 缺少时间维度的相关性判断 | 在元数据中记录时间,重排序时加入时间衰减 |
| 检索延迟过高 | 重排序模型计算量过大 | 缩小粗召回数量,或使用更轻量的重排序模型 |
这里单独说一下分块问题。分块是影响相关性的隐藏因素。如果每块太长,向量表示会被平均化,关键信息被稀释;如果每块太短,上下文不完整,模型无法理解完整逻辑。
推荐策略是“父子分块”:父块保存完整上下文,子块用于检索。检索命中子块后,把父块内容作为上下文交给大模型。
# 父块示例(用于模型生成) parent_chunk = "第三章 退款政策。用户提交退款申请后,系统将在24小时内审核,审核通过后原路返回。退款到账时间取决于银行处理速度,一般1到3个工作日。" # 子块示例(用于向量检索) child_chunk = "退款到账时间取决于银行处理速度,一般1到3个工作日。"这样既保证了检索精度,又让模型获得完整的上下文语义。
7. 最佳实践与工程建议
7.1 分块策略直接影响相关性
分块不是随意为之。建议遵循以下原则:
- 按语义边界分块,优先段落而不是固定字符数。
- 保留标题层级信息,例如“第三章 - 退款政策 - 到账时间”,让块自带上下文。
- 合理设置重叠度,避免关键句恰好被切在边界上。
- 对代码文档和自然语言文档采用不同的分块策略。
7.2 元数据过滤是召回阶段的神器
很多团队只做纯向量检索,忽略了元数据过滤。实际上,给文档打上标签(如产品线、文档类型、更新时间、所属模块),在召回前先过滤,能显著提升相关性。
# 示例:先按元数据过滤,再执行检索 def search_with_filter(query, product_line="payment", top_k=10): # 伪代码示意 filtered_docs = filter_by_metadata(docs, product_line=product_line) results = hybrid_search.search_in(filtered_docs, query, top_k=top_k) return results过滤减少了噪声,也能降低向量检索的干扰。
7.3 在线反馈让相关性持续进化
相关性调优不是一次性工作。线上跑一段时间后,务必建立反馈闭环:
- 用户在 Agent 回答下方的“点赞/点踩”是天然的标注数据。
- 定期导出低分 case,人工判断是检索问题还是模型问题。
- 把高价值的 bad case 补充进评测集,防止回归。
7.4 成本与延迟控制
相关性和成本往往是矛盾的。重排序模型精度高,但计算开销大;向量模型更复杂,效果更好,但推理更慢。
工程建议:
- 粗召回阶段控制在 20 到 50 条,不要贪多。
- 重排序只对 Top 20 做精排即可,没必要全量打分。
- 对高频 Query 可以做结果缓存,减少重复计算。
- Embedding 模型和重排序模型可以分开选型,召回用轻量模型,精排用重量模型,兼顾效果与成本。
7.5 安全与合规边界
如果搜索链路接入的是企业私有知识库,需要注意权限隔离。不同角色的用户只能检索到有权限的文档,这个过滤必须在检索之前完成,而不是检索之后靠模型判断。
# 安全设计要点 # 1. 检索前注入用户身份,执行权限过滤 # 2. 检索结果脱敏后再进入上下文 # 3. 大模型输出前再次校验文档访问权限权限过滤放检索前,核心原因是避免“无权限文档被召回后,相关内容残留在日志或缓存中”。
8. 总结
本文围绕“相关性”这个核心概念展开,梳理了传统关键词检索与向量检索各自的优劣,提出了从“词面匹配”到“意图匹配”的相关性升级思路。随后给出了一套完整的混合检索 + 重排序实现代码,并把搜索能力封装成了 Agent 可调用的工具。最后介绍了评测方法、常见问题排查和工程最佳实践。
如果你正在搭建 Agent 搜索链路,建议从这三件事开始做:
- 先建立 30 条左右的评测集,量化当前检索效果。
- 在向量检索基础上加入 BM25 混合召回,用 RRF 融合。
- 对粗召回结果做重排序,再交给大模型生成回答。
相关性的优化没有终点,但它有清晰的路径:评测、定位、优化、回归。把这套闭环跑起来,你的 Agent 搜索效果会稳步提升。如果本文对你有帮助,可以收藏备用,也欢迎在实践中留言交流你的检索优化经验。