avatar

一条在知识海洋的咸鱼

这个家伙很懒,啥也没有留下😋

  • 首页
  • Linux
  • OCJP
  • Java核心技术卷
  • J2EE相关标准
  • 深入理解Java虚拟机
  • NIO与SOcket编程技术指南
  • Java多线程编程核心技术
  • Redis开发与运维
  • Spring Cloud Alibaba 微服务原理与实践
  • DevOps
  • Docker
  • MySQL必知必会
  • AI自学路线
  • Spring Boot 编程思想(核心篇)
主页 29 混合检索:结合语义召回和关键词精确匹配
文章

29 混合检索:结合语义召回和关键词精确匹配

发表于 11天前 更新于 11天前
作者 Administrator
46~59 分钟 阅读

混合检索:结合语义召回和关键词精确匹配

混合检索把向量检索与关键词检索结合起来。向量检索理解语义,关键词检索擅长错误码、接口路径、产品编号和专有名词,融合后可以减少单一路线的召回盲区。

[TOC]

1. 为什么需要混合检索

向量检索适合:

同义表达  
自然语言问题  
概念解释  
语义相近但字面不同的内容  

关键词检索适合:

ERR_10027  
/api/upload/max-size  
QWEN_MODEL  
订单号  
类名和函数名  
版本号  
人名和产品名  

例如:

用户:ERR_FILE_TOO_LARGE 是什么意思?  

向量模型可能把错误码看作普通符号,关键词检索可以精确命中包含该字符串的 chunk。

混合检索目标:

向量召回语义相关结果  
关键词召回精确匹配结果  
合并、去重、融合排序  
返回稳定候选集  

这里的思考路径是:

先看问题类型  
 -> 如果问题是自然语言,向量检索更容易命中同义表达  
 -> 如果问题包含错误码、路径、编号,关键词检索更稳定  
 -> 如果只选其中一路,就会留下明显盲区  
 -> 所以两路都召回,再把候选合并排序  

也就是说,混合检索不是为了“技术更多”,而是为了解决两类问题:

只用向量:容易漏掉精确字符串  
只用关键词:容易漏掉同义表达和自然语言描述  

第一版要证明的是:

混合检索是否比单独向量检索更容易找到正确来源  

如果评测集里没有错误码、接口路径、产品编号这类问题,混合检索的收益可能并不明显。


2. 整体流程

graph TD;  
 A["用户问题"] --> B["生成问题向量"];  
 A --> C["关键词和全文检索查询"];  
 B --> D["向量检索候选"];  
 C --> E["关键词检索候选"];  
 D --> F["统一候选结构"];  
 E --> F; F --> G["按 chunkId 去重"];  
 G --> H["融合排名"];  
 H --> I["取最终 TopK"];  

第一版参数示例:

vectorTopK = 20  
keywordTopK = 20  
finalTopK = 8  

后续如果增加 Rerank:

混合检索取20到50个候选  
Rerank后取5到8个上下文  

这个流程故意分成“召回”和“融合”两步。

原因是:

召回阶段只关心不要漏掉可能相关的 chunk融合阶段再决定哪些 chunk 更靠前  

如果在召回阶段就过早丢弃候选,后面的 RRF 或 Rerank 都救不回来。

所以第一版参数不要只设一个 topK。

更清楚的写法是:

vectorTopK:向量召回候选数量  
keywordTopK:关键词召回候选数量  
finalTopK:融合后真正交给后续链路的数量  

3. 统一候选结构

public record SearchCandidate(  
 String chunkId, String documentId, String content, double vectorScore, double keywordScore, Integer vectorRank, Integer keywordRank, String hitType, Map<String, Object> metadata) {  
}  

hitType:

VECTOR  
KEYWORD  
BOTH  

不要只保留最终分数。调试混合检索时必须知道:

候选来自哪一路  
原始排名是多少  
原始分数是多少  
融合后排名如何变化  

4. PostgreSQL 简单关键词检索

最简单的包含查询:

SELECT  
 -- chunk 主键,用来和向量检索结果合并去重  
 c.id, -- 来源文档,后面返回 sources 时需要用到  
 c.document_id, -- 命中的片段正文  
 c.contentFROM rag_chunk c  
JOIN rag_document d ON d.id = c.document_id  
WHERE d.knowledge_base_id = :knowledge_base_id  
 -- 只查已经完成向量化和入库的文档  
 AND d.status = 'EMBEDDED' -- ILIKE 表示大小写不敏感的模糊匹配  
 -- 适合第一版验证流程,但大数据量下不能只靠它  
 AND c.content ILIKE '%' || :keyword || '%'LIMIT :top_k;  

为什么先讲 ILIKE?

因为它最容易验证关键词检索这件事是否成立:

不需要额外扩展  
SQL 很直观  
适合用少量测试数据确认命中逻辑  
方便和向量检索结果做对比  

但它只适合学习流程,存在:

大数据量性能差  
无法良好分词和排序  
前导百分号难以使用普通B-tree索引  

可以启用 pg_trgm 改善模糊匹配:

-- 启用三元组扩展,用于提升 ILIKE / 相似字符串匹配能力  
CREATE EXTENSION IF NOT EXISTS pg_trgm;  
  
-- 给 chunk 正文建立 trigram GIN 索引  
-- 目的是让包含查询、拼写相近查询不必每次全表扫描  
CREATE INDEX rag_chunk_content_trgm_idx  
ON rag_chunk  
USING gin (content gin_trgm_ops);  

pg_trgm 主要解决的是:

包含查询太慢  
用户输入可能有轻微拼写差异  
接口路径、错误码、英文标识符需要模糊命中  

它不等于完整搜索引擎。

仍需要根据中文、英文标识符和数据规模测试效果。


5. PostgreSQL 全文检索

ILIKE 和 pg_trgm 更像是“字符串包含和模糊匹配”。

全文检索更进一步,它会先把文本转成可检索的词项结构,再用倒排索引查询。

这里的 tsvector 可以理解为:

原始正文 content -> PostgreSQL 分词和规范化  
 -> search_vector -> GIN 索引  
 -> tsquery 查询  

为什么要增加 search_vector 列?

1. 不想每次查询时都临时对 content 做 to_tsvector2. 可以给预处理后的搜索向量建立 GIN 索引  
3. 可以把标题、正文、标签等字段合并成统一检索字段  
4. 可以在关键词检索时得到 ts_rank_cd 这样的相关性分数  
5. 后续方便把关键词检索结果和向量检索结果做融合排序  

注意:

search_vector 不是给大模型看的正文  
search_vector 是给 PostgreSQL 全文检索用的索引输入  

可以增加搜索向量列:

-- 给 rag_chunk 增加全文检索字段  
-- tsvector 是 PostgreSQL 用来保存“可全文检索词项”的类型  
-- 它不是原文,而是由 title_path、content 等文本转换后的搜索向量  
ALTER TABLE rag_chunk  
ADD COLUMN search_vector tsvector;  

把已有 chunk 的标题和正文写入 search_vector:

-- 把标题路径和正文拼起来,转换成全文检索向量  
-- coalesce(title_path, '') 用来避免标题为空时整个拼接结果变成 NULL-- simple 是 PostgreSQL 的简单文本搜索配置,适合先验证英文、错误码、接口路径等场景  
UPDATE rag_chunk  
SET search_vector = to_tsvector('simple', coalesce(title_path, '') || ' ' || content);  

建立 GIN 索引:

-- 给 search_vector 建倒排索引  
-- 目的:让 @@ 全文检索查询可以走索引,而不是扫描所有 chunk
CREATE INDEX rag_chunk_search_vector_gin_idx  
ON rag_chunk  
USING gin (search_vector);  

执行全文检索查询:

WITH query AS (  
 -- 把用户问题转换成 tsquery -- websearch_to_tsquery 比 plainto_tsquery 更适合普通搜索框输入  
 SELECT websearch_to_tsquery('simple', :question) AS value)  
SELECT  
 -- chunk ID,后面用于与向量召回结果合并去重  
 c.id, -- 文档 ID,后面用于返回来源  
 c.document_id, -- chunk 正文,后面进入候选上下文  
 c.content, -- 关键词相关性分数,越大表示全文检索认为越相关  
 ts_rank_cd(c.search_vector, query.value) AS keyword_scoreFROM rag_chunk c  
CROSS JOIN query  
JOIN rag_document d ON d.id = c.document_id  
WHERE d.knowledge_base_id = :knowledge_base_id  
 -- 只检索已经完成入库的文档  
 AND d.status = 'EMBEDDED' -- @@ 表示 search_vector 是否匹配 query.value AND c.search_vector @@ query.valueORDER BY keyword_score DESC  
LIMIT :top_k;  

websearch_to_tsquery 适合处理普通用户输入,并支持部分搜索语法。

这条 SQL 的执行逻辑是:

1. 把用户问题转成 tsquery2. 用 search_vector @@ tsquery 找到关键词命中的 chunk3. 用 ts_rank_cd 计算关键词相关性  
4. 按 keyword_score 倒序取前 topK5. 返回给混合检索融合器  

学习阶段可以先手动 UPDATE 一次 search_vector。

正式项目需要在 chunk 内容变化时同步维护它:

新增 chunk 时写入 search_vector更新 content/title_path 时重新计算 search_vector重新切片时删除旧 chunk 并重建 search_vector  

中文全文检索需要额外评估分词方案。simple 配置不会自动提供成熟中文分词。

所以第一版可以这样分工:

英文单词、错误码、接口路径、类名:PostgreSQL 全文检索或 trigram中文自然语言同义表达:向量检索  
更复杂中文关键词搜索:评估中文分词扩展或 Elasticsearch/OpenSearch  

第一版可以组合:

错误码、接口路径、英文标识符:ILIKE或trigram  
自然语言:向量检索  

6. 关键词提取

第一版可以从问题中提取明显的精确标识符:

大写错误码:ERR_[A-Z0-9_]+  
接口路径:/api/...  
版本号:v1.2.3  
配置名:大写字母和下划线  
订单号:符合业务格式的数字或字母串  

不要让聊天模型随意改写精确标识符。

例如:

原问题:ERR_10027是什么?  
关键词:ERR_10027  

停用词、标点和大小写处理要可配置。


7. 为什么不能直接相加分数

向量分数可能是:

cosine similarity:0到1附近,越大越好  
cosine distance:0开始,越小越好  

全文检索分数可能是:

ts_rank_cd:没有与向量分数相同的量纲  

直接计算:

vectorScore + keywordScore  

可能让某一路长期压制另一路。

推荐先使用排名融合。


8. Reciprocal Rank Fusion

RRF 只关心各路排名,不要求原始分数同尺度。

公式:

RRF(d) = Σ 1 / (k + rank_i(d))  

其中:

d:候选chunk  
rank_i:候选在第i路检索中的排名,从1开始  
k:平滑常数,常见取60,也应通过评测调整  

示例:

chunk A:向量第1,关键词第4  
chunk B:向量第3,关键词第1  
A = 1/(60+1) + 1/(60+4)  
B = 1/(60+3) + 1/(60+1)  

Java 示例:

double rrfScore(Integer vectorRank, Integer keywordRank, int k) {  
 double score = 0.0; if (vectorRank != null) { score += 1.0 / (k + vectorRank); } if (keywordRank != null) { score += 1.0 / (k + keywordRank); } return score;}  

同一 chunk 两路都命中,通常会获得更高融合排名。


9. 归一化加权

另一种做法是先把分数归一化:

normalizedVectorScore  
normalizedKeywordScore  

再计算:

finalScore = 0.7 * normalizedVectorScore  
 + 0.3 * normalizedKeywordScore  

需要明确:

距离如何转换成相似度  
每一路如何归一化  
某一路没有结果时怎么办  
权重如何通过评测确定  

学习阶段优先 RRF,解释更简单,也不容易受分数量纲影响。


10. 去重

两路可能返回同一个 chunk。

必须按稳定 ID 去重:

chunkId  

不要只按内容文本去重,因为不同文档可以合法包含相同段落。

合并时保留:

vectorRank  
keywordRank  
vectorScore  
keywordScore  
hitType  

如果同一文档相邻 chunk 高度重叠,可以在最终上下文阶段再做相邻合并,不要在候选融合阶段过早删除。


11. 查询路由

第一版可以所有问题都执行两路检索。

后续可以根据问题特征路由:

包含错误码、路径、编号 -> 提高关键词权重  
自然语言解释 -> 提高向量权重  
短而精确的标识符 -> 关键词优先  
长问题或同义表达 -> 向量优先  

路由不能只有单一路径且没有回退。

如果分类错误,可能完全丢失正确候选。

稳妥方式:

保留两路检索  
根据问题类型调整候选数或融合权重  

12. 权限过滤

向量和关键词两路都必须使用同一授权范围。

不能:

向量检索带tenant过滤  
关键词检索却查询全库  

统一查询条件:

tenantId  
knowledgeBaseId  
documentStatus  
用户可访问范围  

融合前的每一条候选都必须已经授权。


13. 返回结构

调试接口可以返回:

{  
 "query": "ERR_FILE_TOO_LARGE是什么意思?",  
 "candidates": [ { "chunkId": "chunk_003", "hitType": "BOTH", "vectorRank": 4, "keywordRank": 1, "vectorScore": 0.81, "keywordScore": 0.72, "fusionScore": 0.032, "content": "ERR_FILE_TOO_LARGE表示文件超过大小限制。"  
 } ]}  

普通问答响应不必暴露全部内部排名,但日志和评测报告应保留这些信息。


14. 超时与降级

两路检索可能独立失败。

降级策略:

向量检索失败,关键词成功 -> 可降级到关键词结果  
关键词检索失败,向量成功 -> 可降级到向量结果  
两路都失败 -> 返回检索失败,不调用模型自由回答  

降级必须记录:

degraded=true  
failedChannel=VECTOR或KEYWORD  
错误原因  

不能悄悄降级,否则团队会误以为混合检索一直正常。


15. 评测

准备不同问题类型:

同义表达  
错误码  
接口路径  
产品编号  
自然语言事实  
中英文混合术语  

分别比较:

仅向量检索 Recall@K仅关键词检索 Recall@K混合检索 Recall@K最终来源命中率  
查询P95延迟  

不要只看最终答案。必须知道提升来自哪一路。


16. 可观测性

记录:

vectorCandidateCount  
keywordCandidateCount  
overlapCount  
vectorOnlyCount  
keywordOnlyCount  
fusionTopK  
两路耗时  
降级状态  

异常信号:

重合率长期为0  
关键词结果从不进入最终TopK  
向量结果总被关键词压制  
某一路候选长期为空  

这些通常意味着分词、索引、归一化或融合配置存在问题。


17. 常见问题

17.1 混合检索一定更好吗

不一定。

融合策略错误会把不相关关键词结果排到前面,所以必须评测。

17.2 第一版必须使用 Elasticsearch 吗

不必。

可以先用 PostgreSQL 的向量、trigram 和全文检索理解流程。数据规模和搜索需求增加后再评估 Elasticsearch/OpenSearch。

17.3 中文全文检索怎么处理

需要专门的中文分词或外部搜索引擎评估。不能假设 PostgreSQL 默认配置已经具备理想中文分词效果。

17.4 混合检索能替代 Rerank 吗

不能完全替代。

混合检索提高召回覆盖,Rerank 对候选做更精细的 query-passage 相关性排序。

17.5 RRF 的 k 固定为60吗

不是。

60 是常见起点,最终应由评测决定。


18. 练习清单

实现向量和关键词两路检索  
提取错误码和接口路径关键词  
为关键词检索建立合适索引  
统一两路候选结构  
按chunkId去重  
实现RRF融合  
记录VECTOR、KEYWORD、BOTH命中类型  
两路使用相同权限过滤  
实现单路失败降级  
用固定问题集比较三种检索效果  

19. 小结

混合检索链路:

query  
 -> vector retrieval -> keyword retrieval -> merge -> deduplicate -> rank fusion -> final candidates  

本节最重要的结论:

向量检索解决“意思相近”,关键词检索解决“字面必须命中”。混合检索的关键不只是两路查询,而是可解释、可评测的融合排序。

关键词检索:把关键词抽取出来,单独检索
向量检索:检索用户的所有输入

20. 参考资料

  • PostgreSQL Text Search
  • PostgreSQL Text Search Functions
  • Spring AI and Elasticsearch
  • pgvector
AI自学路线
许可协议: 
分享

相关文章

7月 13, 2026

55 异常兜底:工具失败、非法输出、空检索与超时

异常兜底:工具失败、非法输出、空检索与超时 Agent 的可靠性主要体现在失败时。工ࠥ

7月 13, 2026

54 Agent Memory:短期记忆、用户上下文与摘要

Agent Memory:短期记忆、用户上下文与摘要 Memory 的目标是保持任务连续性,不是永久保存所有聊天。记忆Š

7月 13, 2026

53 Human-in-the-loop:让关键动作暂停、确认与恢复

Human-in-the-loop:让关键动作暂停、确认与恢复 HITL 不是弹一个“确定吗”,而是让工作流在关键动作前持久

下一篇

30 Rerank 重排序:从候选召回到高质量上下文

上一篇

28 RAG 第一版交付:从功能拼接到可演示系统

最近更新

  • 55 异常兜底:工具失败、非法输出、空检索与超时
  • 54 Agent Memory:短期记忆、用户上下文与摘要
  • 53 Human-in-the-loop:让关键动作暂停、确认与恢复
  • 52 工单 Tool:字段校验、创建结果与失败补偿
  • 51 订单 Tool:Mock、鉴权、状态解析与脱敏

热门标签

java基础 微服务 maven Spring Tomcat DDD Linux Linux基础 SQL基础 数据结构算法

目录

©2026 一条在知识海洋的咸鱼. 保留部分权利。

使用 Halo 主题 Chirpy