技术博客

RAG 开发入门(四):从关键词到向量,再回到混合检索

关键词检索负责精确定位,向量检索负责语义召回。真正可用的 RAG 检索层,通常会走向 BM25、向量、多模态召回和重排。

发布时间

阅读信息

约 17 分钟

主题标签

RAG / Java / 向量检索

第三篇把重点放在文档预处理上:解析、清洗、切块、元数据、权限、版本和增量更新。做完这些,系统里就有了一批相对可靠的 KnowledgeChunk

现在轮到检索层。

检索层要解决的问题很直接:用户需要答案时,系统怎么从这些知识单元里找出相关内容。

第二篇里我们先用了关键词匹配,因为它最容易理解,也最容易调试。真实系统继续往前走,很快会遇到两个方向:一边是关键词检索对字段名、配置项、类名、错误码这类精确内容很有效;另一边是用户问题和文档原文经常表达不一致,需要按语义召回。

所以这篇把路径讲完整一点:从关键词检索出发,引入向量检索解决语义召回,然后再回到混合检索。生产里的 RAG 检索层,通常会把多种召回方式组合起来。

RAG 检索层总览

1. 关键词检索擅长精确命中

关键词检索的优势非常明确:它适合精确定位。

字段名、配置项、类名、方法名、枚举值、表名、版本号,这些内容都适合用关键词查。比如用户明确问 refundStatus 的取值,或者问 enableRetry 这个配置项怎么生效,系统最好能原样命中文档里的对应位置。

对 Java 后端来说,这条链路也很熟悉。Elasticsearch、OpenSearch、Lucene、数据库全文索引,本质上都在做倒排索引、分词、匹配和排序。BM25 这类排序算法会考虑词频、文档长度和稀有词权重,比简单的 contains 强很多。

关键词检索的边界也很清楚:它依赖词面重合。

文档里写的是“退款流水状态仍处于处理中”,用户可能问“钱退回去了页面还显示处理中怎么办”。文档里写的是“通知发送需要使用业务事件 ID 做幂等”,用户可能问“为什么同一笔订单发了两次短信”。两边表达接近,词却不一定重合。

同义词词典、分词器、业务词库可以缓解这个问题,但维护成本会越来越高。业务系统多了以后,一个词在不同场景里的含义也可能不同。

2. 向量检索引入语义召回

向量检索的核心变化是:先把文本转成向量,再按向量距离找相近内容。

这个“转成向量”的过程通常叫 Embedding。Embedding 模型不会直接回答问题,它负责把一段文字编码成一组数字。语义接近的文本,在向量空间里的距离通常也更近。

所以向量化经常可以理解成语义化。它把“退款失败”“钱没退回来”“退款渠道确认失败”这类表面文字不同、意思接近的表达,映射到相近的位置。检索时,系统会把词面匹配之外的语义距离也纳入排序。

把大学线性代数里的知识捡回来就很好理解。二维向量可以写成 (x, y),三维向量可以写成 (x, y, z),Embedding 只是把维度拉高了。一个文本向量可能有 1024、1536 或 3072 个维度,对 Java 代码来说就是一个 float[]

这些维度通常没有人工可读的固定含义。我们很难说第 17 维代表“退款”,第 83 维代表“失败”。但整个向量的位置和方向,表达了模型从大量文本里学到的语义特征。

向量之间怎么判断“近”?常见做法是看余弦相似度。两个向量的方向越接近,夹角越小,余弦值越大,语义就越可能接近。

线性代数里,余弦相似度可以写成:

cosine(a, b) = (a · b) / (|a| * |b|)

这里的 a · b 是点积,|a||b| 是向量长度。代码里的 dotleftNormrightNorm,对应的就是这几个量。

很多向量库会把相似度转换成距离,比如 distance = 1 - cosine。相似度越高,距离越小,排序时就越靠前。如果向量已经归一化到长度为 1,点积和余弦相似度会变成同一件事,向量库也更容易做高效计算。

离线阶段,系统把每个 KnowledgeChunk 的标题、标题路径、正文和必要元数据拼成一段可向量化文本,调用 Embedding 模型,得到一个 float[],再把向量和 chunk ID 存起来。

在线阶段,检索器仍然只接收关键词列表。至于关键词由谁生成,是上游的事:可以是规则,也可以是 Agent。检索器只负责搜索。

为了做向量检索,可以把关键词拼成查询文本,再调用同一个 Embedding 模型:

import java.util.List;

public interface EmbeddingClient {

    float[] embed(String text);
}

public record EmbeddedChunk(
        KnowledgeChunk chunk,
        float[] vector
) {
}

public record VectorSearchHit(
        KnowledgeChunk chunk,
        double score
) {
}

public class VectorRetriever {

    private final EmbeddingClient embeddingClient;
    private final List<EmbeddedChunk> chunks;

    public VectorRetriever(EmbeddingClient embeddingClient, List<EmbeddedChunk> chunks) {
        this.embeddingClient = embeddingClient;
        this.chunks = chunks;
    }

    public List<VectorSearchHit> search(List<String> keywords, int topK) {
        String queryText = String.join(" ", keywords);
        float[] queryVector = embeddingClient.embed(queryText);

        return chunks.stream()
                .map(chunk -> new VectorSearchHit(
                        chunk.chunk(),
                        cosine(queryVector, chunk.vector())
                ))
                .sorted((left, right) -> Double.compare(right.score(), left.score()))
                .limit(topK)
                .toList();
    }

    private double cosine(float[] left, float[] right) {
        double dot = 0;
        double leftNorm = 0;
        double rightNorm = 0;

        for (int i = 0; i < left.length; i++) {
            dot += left[i] * right[i];
            leftNorm += left[i] * left[i];
            rightNorm += right[i] * right[i];
        }

        if (leftNorm == 0 || rightNorm == 0) {
            return 0;
        }
        return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
    }
}

这段代码还没有向量数据库,只是在内存里算余弦相似度。它表达的是向量检索的最小形态:查询向量和文档向量越接近,分数越高。

真实系统里,向量不会全部放在内存里逐条扫描。数据量上来以后,需要用向量索引做近似最近邻搜索。常见选择包括 pgvector、Milvus、Elasticsearch dense vector、OpenSearch k-NN、Redis Vector Search 等。具体选型可以后面再看,核心链路不变。

3. 常见的向量模型

Embedding 模型更新很快,选型不要只看榜单。对 RAG 来说,更重要的是你的资料语言、部署方式、成本、延迟、向量维度和真实召回效果。

可以先从几类常见模型看起。

类型常见模型适合用法
API 文本向量text-embedding-3-smalltext-embedding-3-largegemini-embedding-001接入快,适合先验证 RAG 链路,或者不想维护推理服务的团队
开源文本向量Qwen3-Embedding-0.6BQwen3-Embedding-4BQwen3-Embedding-8Bbge-m3适合中文、中英混合资料和私有化部署
重排模型Qwen3-Reranker-0.6BQwen3-Reranker-4Bbge-reranker放在召回之后,对 topK 候选片段重新排序
多模态向量Cohere embed-v4.0jina-embeddings-v4适合文本和图片混合检索,尤其是截图、PPT、复杂 PDF 和产品手册
视觉文档多向量ColQwen2ColQwen2.5适合把 PDF 页面、扫描件或截图当图片检索,保留版式、表格和空间关系

第一版工程验证可以从 text-embedding-3-smallQwen3-Embedding-0.6B 开始。前者接 API 快,后者适合私有化试验。等链路跑通以后,再用真实问题对比 text-embedding-3-largeQwen3-Embedding-4BQwen3-Embedding-8B

bge-m3 适合作为开源基线。它覆盖 dense、sparse 和 multi-vector 思路,后面做混合检索时也容易衔接。不过 Java 后端通常不会直接在 JVM 里跑这些模型,更常见的是单独部署一个 Python 推理服务,Java 通过 HTTP 调用。

多模态向量模型适合处理第三篇提到的页面图片路线。比如把 Office、HTML、Markdown 或网页先转成 PDF,再把页面渲染成图片,交给多模态 embedding 模型生成向量。普通文本问题仍然走文本 embedding,版式、图表和视觉关系重要时,再走多模态索引。

ColQwen 系列更进一步,它走的是视觉文档检索里的多向量路线。一个页面会生成一组 token 级向量,再用类似 ColBERT 的 late interaction 方式计算相关性。它适合 PDF、扫描件、截图、表格和带流程图的资料,尤其适合 OCR 容易丢版式关系的场景。

这类模型的工程代价也更高。索引体积会变大,检索服务更复杂,Java 后端更适合把它封装成独立检索服务。比较稳的落地方式是:文本 chunk 先走普通 embedding,页面图片或高价值复杂文档再补 ColQwen 这类视觉检索。

模型选定后,还有一个工程细节要记录下来:模型名、向量维度、距离算法和向量化文本模板。换 Embedding 模型通常不能直接复用旧向量,维度不同更是无法混用。线上系统要把 embedding 版本写进索引元数据,后续升级时按版本重建或双写迁移。

4. 向量检索也有边界

向量检索解决的是语义召回,但它不会自动理解你的业务范围。

用户问“退款状态一直处理中怎么办”,向量检索可能召回退款回调文档,也可能召回对账文档、客服工单、支付渠道说明。这些内容语义上都接近,但不一定都适用于当前问题。

技术系统里还有大量精确标识符。字段名、接口路径、枚举值、配置项、类名、表名,这些内容更适合关键词命中。向量检索在这些场景里可能会找出语义相近的内容,却漏掉那个必须原样命中的对象。

所以元数据过滤依然很重要。systemversiontenantvisibility 这类字段应该在检索前参与过滤。用户没有权限看的 chunk,不进入候选集;问题明确属于某个系统时,也不要把其他系统的相似片段混进来。

向量检索把 RAG 从词面匹配带到了语义召回,但检索层还需要精确词、业务过滤和排序策略一起工作。

5. 混合召回是更常见的形态

向量化之后,关键词会重新回到检索链路里。

更准确地说,关键词检索从来没有离开。只是当向量检索出现后,关键词检索从“唯一检索方式”变成了“多路召回之一”。

一条更接近生产系统的检索链路通常是这样:

关键词检索:BM25 / Lucene / OpenSearch
向量检索:Embedding + Vector Index
多模态检索:图片页、截图、PPT、扫描件
元数据过滤:系统、版本、租户、权限
候选合并:去重、合并分数、保留来源
重排:reranker 对 topK 候选重新排序

在 Java 代码里,可以先把这些能力拆成几个接口。接口不需要一开始就复杂,先把职责边界留下来。

import java.util.List;

public interface Retriever {

    List<SearchHit> search(RetrievalQuery query);
}

public record RetrievalQuery(
        List<String> keywords,
        int topK,
        RetrievalFilter filter
) {
}

public record RetrievalFilter(
        String system,
        String version,
        String tenant
) {
}

关键词检索器、向量检索器、多模态检索器都可以实现 Retriever。上层服务负责并发调用多个 retriever,再把结果合并。

import java.util.Comparator;
import java.util.List;

public class HybridRetriever {

    private final List<Retriever> retrievers;

    public HybridRetriever(List<Retriever> retrievers) {
        this.retrievers = retrievers;
    }

    public List<SearchHit> search(RetrievalQuery query) {
        List<SearchHit> hits = retrievers.stream()
                .flatMap(retriever -> retriever.search(query).stream())
                .toList();

        return mergeAndNormalize(hits).stream()
                .sorted(Comparator.comparingDouble(SearchHit::score).reversed())
                .limit(query.topK())
                .toList();
    }

    private List<SearchHit> mergeAndNormalize(List<SearchHit> hits) {
        // 按 chunk id 去重,并把不同检索器的分数归一化后再合并。
        return hits;
    }
}

这里的 mergeAndNormalize 可以做去重和分数合并。真实实现里还要处理不同检索器的分数归一化。BM25 的分数、向量相似度、多模态分数通常不在同一个尺度上,不能简单相加。

混合召回的价值在于互补。关键词检索能抓住精确标识符,向量检索能抓住语义相近的表达,多模态检索能保留页面版式和视觉关系。它们共同产出一批候选片段,再交给后面的重排,最后作为候选证据返回给上层。

6. 重排负责最后排序

多路召回之后,候选片段会变多。这个时候只靠各路检索器自己的分数,排序很难稳定。

Reranker 的位置就在这里。它拿到用户问题和候选片段,重新判断每个片段对当前问题的相关性。Embedding 更像是大范围召回,reranker 更像是对候选集做精排。

重排模型的输入通常是一组 (query, chunk) 对,输出每个 chunk 对当前 query 的相关性分数。它不负责从全量知识库里找资料,只处理已经召回的候选集。所以它可以看得更细,也可以把“语义接近但没回答问题”的片段往后排。

常见的重排模型包括 Qwen3-Reranker-0.6BQwen3-Reranker-4Bbge-rerankerCohere RerankJina Reranker。如果知识库主要是中文和中英混合技术资料,可以先从 Qwen3-Reranker 或 bge-reranker 试起;如果已经在用 API 方案,也可以直接接 Cohere 或 Jina 的 rerank 接口。

比如向量检索召回了 50 个 chunk,BM25 又召回了 30 个 chunk,去重后还有 60 个候选。可以先取前 50 个交给 reranker,最后留下 5 到 10 个返回给上层。

常见组合是:

BM25 topK=30
Vector topK=50
Merge + deduplicate
Reranker topN=8
Return SearchHit list

重排会增加延迟和模型调用成本,所以不一定所有请求都要走完整链路。简单问题可以只走关键词或向量检索;复杂问题、召回数量多的问题、需要高准确率的问题,再走 reranker。

7. 检索结果要能回看

关键词检索比较好排查。某个标题包含“退款”,某段正文包含 refundStatus,为什么被召回,日志里一看就明白。

向量检索就没这么直观。两个文本为什么接近,模型不会给出规则说明。分数 0.82 和 0.76 看起来差了一点,但这一点在你的知识库里到底重不重要,很难凭感觉判断。

混合召回之后,排查更需要日志。一次检索至少要能回看到:当时用了哪些关键词,拼出来的查询文本是什么,各路检索器分别命中了哪些 chunk,它们的标题路径和来源是什么,原始分数是多少,合并后的分数是多少,最后哪些片段返回给了上层。

线上回答错了,先别急着改 prompt。先看召回结果。如果相关资料根本没被召回,问题在检索;如果召回了但没有排到前面,问题在排序、去重或重排;如果证据已经返回给上层,才轮到模型回答边界。

阈值也要靠样本调。可以准备一组真实问题,人工标出每个问题应该命中的资料,再观察不同 topK、不同相似度阈值、不同向量化文本拼接方式下,正确 chunk 有没有进候选集。

RAG 的检索质量很少靠一次参数调整解决。它更像搜索系统,需要样本、日志、回放和持续调参。

8. 下一篇进入 Agentic RAG

到这里,第四篇把检索层讲完整一点了。

关键词检索负责精确命中,向量检索负责语义召回,多模态检索补视觉资料,元数据过滤控制范围,reranker 负责最后排序。真正可用的 RAG 检索层,往往是这些能力的组合。

下一步可以把这些能力收成一个稳定的方法,交给上层调用:

List<SearchHit> searchDocs(List<String> keywords, int topK);

方法内部可以继续做 BM25、向量检索、多模态召回、元数据过滤、合并去重和 reranker。调用方只需要给关键词,拿到排好序、带来源的候选证据。

这就是下一篇要进入的 Agentic RAG。Agent 不需要知道检索层内部怎么组合,它只需要一个工具:什么时候查、用哪些关键词查、查完以后要不要换一组关键词继续查。检索逻辑封装成方法以后,Agent 才能把它当成一个工具反复使用。

RAGJava向量检索Embedding混合检索知识库
上一篇 RAG 开发入门(五):把检索工具交给 Agent 下一篇 RAG 开发入门(三):文档预处理质量决定 RAG 能不能用