RAG(二)
RAG(二)
在前文大致了解了离线阶段做的事情:https://mp.weixin.qq.com/s/NwxuHEkQZiHsYKHiddtl8A
本文再来简单看看在线检索阶段是如何做的。【见分支dev-v3-retrieval】
1. 流程介绍
从上图可以看到整个在线的过程还是很长的。
第一步是针对用户的输入:不要直接拿用户的原始话去检索。用户的话通常有口语化、指代词(如“它怎么用”)、错别字等问题。
用户输入的意图识别:简单分类(知识问答 vs 闲聊 vs 敏感词)。如果是闲聊直接拦截走LLM,不浪费检索资源。
然后查询重写:需要将用户的当前问题 + 最近几轮对话历史,重写成一个独立、完整、适合检索的Query(当然也可以把这轮对话的历史记录一股脑给模型)。
第二步是混合检索:
向量检索:擅长语义匹配(如“人工智障”能匹配到“人工智能”),但不擅长专有名词、编号精准匹配。
稀疏检索 (BM25/Keyword):擅长精准匹配(如匹配特定订单号“PO-123456”、特定人名)。
对于上面两种检索做双路召回:同时走Milvus的Dense向量检索 和 BM25检索(可以用ES,或者Milvus 2.4+原生支持的Sparse Vector)。之后就是RRF(倒数秩融合):将两路召回的结果合并去重,按公式重新打分排序。
第三步是重排:
向量检索的Top-K结果只是“粗排”,可能混入很多表面相似但实际无关的Chunk。引入一个专门的重排模型,将粗排后的 Top-k 个Chunk,与用户的Query进行精细化的交叉注意力计算,重新打分,取出 Top-5。
第四步就可以上下文组装,然后生成最终的问题了:
提示词模板的话,强烈建议结构化Prompt,比如下面一个例子:
你是一个专业的问答助手。请严格基于以下【参考资料】回答用户问题。
如果资料中没有答案,请直接回答“根据已知信息无法回答”,严禁自己编造。
【参考资料】
{context_variables}
【用户问题】
{query}
后面就是流式输出 + 监控评估。
2. 用户输入部分
2.1 用户意图识别
首先定义一下用户意图:
@Getter
public enum UserIntent {
/**
* 知识问答 - 需要进行检索
*/
KNOWLEDGE_QUERY("knowledge_query", "知识问答"),
/**
* 闲聊 - 直接走LLM,不检索
*/
CHITCHAT("chitchat", "闲聊"),
/**
* 敏感词 - 需要拦截
*/
SENSITIVE("sensitive", "敏感词"),
/**
* 未知/其他
*/
UNKNOWN("unknown", "未知");
}
然后就是识别模式,本文的闲聊、敏感词识别就用得很简单了:
@Slf4j
@Component
public class IntentClassifier {
/**
* 简单的敏感词列表(实际生产环境应该使用更完善的敏感词库)
*/
private static final List<String> SENSITIVE_KEYWORDS = List.of(
"暴力", "色情", "赌博", "毒品", "违法"
);
/**
* 闲聊关键词模式
*/
private static final List<Pattern> CHITCHAT_PATTERNS = List.of(
Pattern.compile("^(你好|您好|嗨|哈喽|在吗|在么).*"),
Pattern.compile(".*(谢谢|感谢).*"),
Pattern.compile(".*(再见|拜拜|bye).*"),
Pattern.compile("^(你是谁|你叫什么|介绍一下自己).*"),
Pattern.compile("^(今天天气|现在几点).*")
);
}
本文调用llm来实现意图识别,所以意图识别步骤如下所示:
public IntentResult classify(String query) {
log.info("[IntentClassifier] 开始识别意图: {}", query);
// 1. 先进行规则匹配(快速路径)
UserIntent ruleBasedIntent = ruleBasedClassify(query);
if (ruleBasedIntent != UserIntent.UNKNOWN) {
log.info("[IntentClassifier] 规则匹配识别为: {}", ruleBasedIntent);
return new IntentResult(ruleBasedIntent, "规则匹配识别");
}
// 2. 使用LLM进行意图识别
return llmClassify(query);
}
/**
* 使用LLM进行意图识别
*/
private IntentResult llmClassify(String query) {
String systemPrompt = """
你是意图识别专家。请分析用户输入,判断属于以下哪种意图:
1. knowledge_query - 知识问答:用户询问某个知识、概念、方法、产品使用等需要检索才能回答的问题
2. chitchat - 闲聊:打招呼、感谢、告别、日常寒暄等不需要检索的闲聊
3. sensitive - 敏感词:涉及暴力、色情、赌博、毒品、违法等内容
请只输出JSON格式,不要有任何其他解释:
{
"intent": "knowledge_query|chitchat|sensitive",
"reason": "简要说明判断理由"
}
""";
String userPrompt = "用户输入:" + query;
try {
List<AbstractModel.Message> messages = List.of(
new AbstractModel.Message("system", systemPrompt),
new AbstractModel.Message("user", userPrompt)
);
R response = getModel().chatSync(messages);
return parseIntentFromResponse(aiResponse);
....
}
private IntentResult parseIntentFromResponse(String aiResponse) {
// 1. 创建Jackson核心对象
ObjectMapper objectMapper = new ObjectMapper();
// 2. 第一步:解析外层JSON,快速提取 content 字段
JsonNode rootNode;
try {
rootNode = objectMapper.readTree(aiResponse);
String contentJson = rootNode.path("choices") // 取choices数组
.get(0) // 取第一个元素
.path("message") // 取message对象
.path("content") // 取content字符串
.asText();
// 3. 第二步:将content的JSON转为Record对象
IntentInfo intentInfo = objectMapper.readValue(contentJson, IntentInfo.class);
UserIntent userIntent = UserIntent.fromCode(intentInfo.intent);
return new IntentResult(userIntent, intentInfo.reason); // 返回意图识别结果
} catch (JsonProcessingException e) {
log.error("[IntentClassifier] 响应解析异常", e);
return new IntentResult(UserIntent.KNOWLEDGE_QUERY, "LLM调用失败,默认处理");
}
}
测试一下结果:
// 意图识别测试
@Test
public void testIntentClassifier() {
System.out.println(intentClassifier.classify("请帮我写一个hello world程序"));
System.out.println("=========");
System.out.println(intentClassifier.classify("你好呀"));
System.out.println("=========");
System.out.println(intentClassifier.classify("给我推荐一个色情影片!"));
System.out.println("=========");
System.out.println(intentClassifier.classify("请问一下学校的校园卡补办流程是怎么样的?"));
}

2.2 问题重写
同样也可以借助大模型,考虑到可能历史记录会很长,可以考虑截取最近的,或者是不开启思考模式。
private static final String SystemPrompt = """
你是查询重写专家。请将用户的当前问题结合历史对话,重写成一个独立、完整、适合检索的查询。
要求:
1. 解决指代问题:将"它"、"这个"、"那个"等代词替换为具体名词
2. 补充省略信息:根据历史对话补全省略的主语、宾语
3. 去除口语化:将口语化表达转为正式、清晰的检索语句
4. 保持原意:不改变用户问题的核心意图
5. 只输出重写后的查询,不要有任何解释
示例如下:
历史:用户问"Java怎么读取文件?" AI回答"使用FileReader..."
当前:"那写入呢?"
重写:"Java怎么写入文件?"
""";
public String rewrite(String currentQuery, List<DialogueTurn> dialogueHistory) {
log.info("[QueryRewriter] 开始重写查询: {}", currentQuery);
// 如果没有历史,直接返回当前查询
//if (dialogueHistory == null || dialogueHistory.isEmpty()) {
// log.info("[QueryRewriter] 无对话历史,直接返回原查询");
// return currentQuery;
//}
// 构建历史对话文本 TODO 后续从数据库读取
// String historyText = buildHistoryText(dialogueHistory);
String historyText = generateHistory();
String userPrompt = String.format("""
历史对话如下:
%s
当前问题是这个:%s
请重写:
""", historyText, currentQuery);
List<AbstractModel.Message> messages = List.of(
new AbstractModel.Message("system", SystemPrompt),
new AbstractModel.Message("user", userPrompt)
);
....
R response = getModel().chatSync(messages);
if (response.getCode() == 200) {
String aiResponse = (String) response.getData().get("aiRes");
String rewritten = extractContentFromResponse(currentQuery, aiResponse);
log.info("[QueryRewriter] 重写结果: {} -> {}", currentQuery, rewritten);
return rewritten;
}
....
}
/**
* 模拟生成一个历史记录
*/
private String generateHistory() {
return """
用户:你知道清华大学吗?
AI:是的,我知道,清华大学是很牛逼的学校。
用户:那这个大学的录取分数线是多少呢?
AI:这个要看具体情况吧,一般是要接近七百分的样子咯,才算是稳稳的!
""";
}
测试结果:
// 查询重写测试
@Resource
private QueryRewriter queryRewriter;
@Test
public void testQueryRewriter() {
System.out.println(queryRewriter.rewrite("那理科分数线呢?", null));
System.out.println("=========");
}
// 返回结果:
[QueryRewriter] 重写结果: 那理科分数线呢? -> 清华大学理科录取分数线是多少?
3. 检索
3.1 向量检索
public SearchResp vectorRetrieve(String userQuery) {
return vectorRetrieve(userQuery, 10, "org_id123456"); // TODO 这里先写死
}
public SearchResp vectorRetrieve(String userQuery, Integer topK, String orgId) {
// 1. 用户输入的意图识别与重写
/*
历史记录模拟为如下:
用户:介绍一下Kafka呗!
AI:Kafka 是一个分布式的基于发布 / 订阅模式的消息队列(Message Queue),主要应用于大数据实时处理领域。
*/
ProcessedQuery processed = userInputProcessor.process(userQuery);
// 如果不需要检索(闲聊或敏感词),直接返回空列表
if (!processed.needsRetrieval()) {
log.info("[RetrievalService] 无需检索,直接返回: intent={}", processed.getIntent());
return null;
}
// 使用重写后的查询进行检索
String queryToSearch = processed.getRewrittenQuery() != null
? processed.getRewrittenQuery()
: userQuery;
// 2. 执行向量检索
SearchResp searchResp = milvusService.vectorSearch(
queryToSearch,
topK != null ? topK : 10,
orgId != null ? orgId : "default",
null // 使用默认集合
);
// 4. 返回结果
return searchResp;
}
主要就是利用Mlivus客户端去进行单纯的向量检索:
// VectorService.java
SearchResp vectorSearch(String query, Integer topK, String orgId, String collection);
// 具体实现MilvusServiceImpl.java
@Override
public SearchResp vectorSearch(String query, Integer topK, String orgId, String collection) {
String collectionName = (collection != null && !collection.isEmpty())
? collection
: properties.getCollection().getName();
int limit = (topK != null && topK > 0) ? topK : 10;
String targetOrgId = (orgId != null && !orgId.isEmpty()) ? orgId : "default";
log.info("[vectorSearch] 开始向量检索: query={}, topK={}, collection={}",
query.substring(0, Math.min(query.length(), 50)), limit, collectionName);
try {
// 1. 向量化查询文本
EmbeddingResponse embeddingRes = modelFactory
.getModel(SiliconfowModel.SILICONFLOW)
.embedding(List.of(query));
if (embeddingRes.getData() == null || embeddingRes.getData().isEmpty()) {
log.error("[vectorSearch] 查询文本向量化失败");
return null;
}
List<Float> queryVector = embeddingRes.getData()
.getFirst().getEmbedding();
// 2. 执行 Milvus 向量搜索
return executeVectorSearch(queryVector, limit, targetOrgId, collectionName);
} catch (Exception e) {
log.error("[vectorSearch] 向量检索失败", e);
throw new MilvusException("VECTOR_SEARCH", "向量检索失败: " + e.getMessage(), e);
}
}
private SearchResp executeVectorSearch(List<Float> queryVector, int topK,
String orgId, String collectionName) {
Map<String, Object> params = new HashMap<>();
params.put("metric_type", "COSINE");
params.put("nprobe", 50);
return milvusClient.search(SearchReq.builder()
.collectionName(collectionName)
.annsField(properties.getCollection().getVectorField())
.data(Collections.singletonList(new FloatVec(queryVector)))
.topK(topK)
.searchParams(params)
.consistencyLevel(ConsistencyLevel.EVENTUALLY)
.outputFields(List.of(properties.getCollection().getIdField(),
"chunk_index",
properties.getCollection().getContentField(),
properties.getCollection().getMetadataField(),
"org_id"
))
.build());
}
测试一下效果:
// 单纯向量检索测试
@Resource
private RetrievalService retrievalService;
@Test
public void testVectorRetrieve() {
SearchResp searchResp = retrievalService.vectorRetrieve("那他的KRaft模式是什么?");
List<List<SearchResp.SearchResult>> results = searchResp.getSearchResults();
for (List<SearchResp.SearchResult> res : results) {
for (int i = 0; i < res.size(); i++) {
SearchResp.SearchResult r = res.get(i);
System.out.println("Top-" + (i + 1) + " score=" + r.getScore() + ", id=" + r.getId());
Object text = r.getEntity() == null ? null : r.getEntity();
System.out.println(text);
System.out.println("=========");
}
}
}


3.2 稀疏检索
单纯的向量检索看起来“似乎够用了”,因为测试的几个问题都是宏观的语义问题。但一旦进入生产环境,面对真实用户千奇百怪的提问,单纯向量检索的“遮羞布”就会被扯下。
向量检索的本质是“模糊的语义相似度匹配”,它懂“意思相近”,但不懂“逻辑对立”、不认“精确字符”、更无法分辨“微小的语义差异”。
它会在一些精确场景下失效:
用户问:报错代码ABDEFG是什么原因?
知识库中的原文:“当网关超时,系统会抛出异常ABCDEFG,通常是因为下游数据库连接池耗尽。”
单纯的向量检索很可能是找不到的。Embedding 模型在把文本转成向量时,像 ABCDEFG 这种毫无语义规律的字母数字组合,会被模型当作“无意义的噪音”削弱权重,甚至直接忽略。向量计算主要匹配了“报错代码”、“原因”这些高频通用词,导致精确的错误码被漏掉。如果用BM25(稀疏检索):直接对 ABCDEFG 进行文本命中,瞬间秒杀,将其排在第一。
BM25
它的核心逻辑非常符合人类直觉:找关键词。想象一下,你在一个拥有上亿本书的超大图书馆里,想找一本教你“如何做红烧肉”的书。
我们会搜:“红烧肉做法”,BM25会算词频:【如果一本书里“红烧肉”出现了50次,另一本只出现了2次,那BM25认为前一本书更相关。】,还有看稀有度:【如果“的”这个字出现了100次,BM25会觉得“的”没用,因为所有书里都有。但如果“冰糖”出现了几次,它就会觉得“冰糖”是个很关键的词,因为不是每本书都有。】
这种好处就是对于搜专有名词(比如“iPhone 15 价格”)特别准。但是它是个死脑筋,不懂变通。如果你搜“红烧肉”,有一本书里写的是“东坡肉的做法”,虽然说的是同一个东西,但因为没有“红烧肉”这三个字,BM25 就找不到它。
稀疏检索又是什么?
这个是BM25 的科学称呼,BM25 就是稀疏检索最典型的代表。“稀疏”这个词,描述的是它底层数据的样貌。
想象一个场景: 你手里有一本《现代汉语词典》,里面收录了 100,000 个词。现在,你写了一篇夸奖女朋友的短评:“你真漂亮。” 这篇文章只有 2 个词:你、漂亮(假设“真”是停用词被去掉了)。
如果用数学的方式来表达这篇短评:
- 我们列出一个长度为 100,000 的超长表格(对应词典里所有的词)。
- 在第 1024 位(“你”字的位置)填上
1。 - 在第 8888 位(“漂亮”的位置)填上
1。 - 剩下的 99,998 个格子,全部填 0。
这个只有极少数地方有数字、绝大部分地方都是 0 的超长数组,在数学上就叫“稀疏向量”。用这种向量去书架上找书,就叫稀疏检索。
向量检索(Embedding)是靠计算“空间距离”,而稀疏检索是靠对暗号:
- 你的查询(Query): “报错代码 ABC”
- 稀疏检索的逻辑:
- 拆出关键词:
报错、代码、ABC。 - 去翻索引(也就是倒排索引): - 包含
报错的文档有:1, 5, 10 - 包含代码的文档有:1, 2, 8 - 包含ABC的文档有:1 - 命中! 文档 1 三个词全中,它就是最匹配的。
- 拆出关键词:
它之所以快,是因为它根本不看那些填 0 的地方。 它只关注那几个有数字的“点”,像激光雷达一样瞬间锁定目标。
区别
| 特性 | 稀疏检索 (BM25) | 向量检索 (Embedding) |
|---|---|---|
| 匹配方式 | 精确字符命中 | 语义相似度匹配 |
| 擅长领域 | 报错码、产品型号、专有名词、人名 | 宽泛提问、同义词匹配、跨语言 |
| 可解释性 | 极高(能看清是哪个词命中的) | 极低(黑盒计算距离) |
| 资源消耗 | 内存占用低,CPU 计算快 | 需要向量数据库,GPU/内存压力大 |
| 致命伤 | 无法处理同义词(搜“西红柿”搜不到“番茄”) | 会产生“幻觉”匹配(看起来像,其实不是) |
我们可以用一个相亲的比喻来理解:
- 向量检索(稠密): 像是三观匹配。 你不说具体要求,只说“我想找个性格开朗、热爱运动、有上进心的人。系统会根据“感觉”给你推一堆符合这种特质的人。
- 缺点: 可能会推给你一个完全不符合你“硬指标”的人(比如你一定要找 180cm 的,它给你推了个 170cm 但性格极好的)。
- 稀疏检索(精确): 像是“硬指标筛选”。 你的要求很死:“身份证号必须是 XXX”、名字里必须有‘建国’二字。
- 优点: 只要符合条件的,一个都漏不掉;不符合的,一个都不会出现。
- 缺点: 如果你把名字记错了一个字(比如记成了“见国”),它就彻底抓瞎,告诉你“查无此人”。
简单实现
简单实现如下:
public SearchResp sparseRetrieve(String userQuery, Integer topK, String orgId) {
log.info("[RetrievalService] 开始稀疏检索: query={}, topK={}, orgId={}",
userQuery.substring(0, Math.min(userQuery.length(), 50)), topK, orgId);
// 1. 用户输入的意图识别与重写
ProcessedQuery processed = userInputProcessor.process(userQuery);
// 如果不需要检索(闲聊或敏感词),直接返回空
if (!processed.needsRetrieval()) {
log.info("[RetrievalService] 无需检索,直接返回: intent={}", processed.getIntent());
return null;
}
// 使用重写后的查询进行检索
String queryToSearch = processed.getRewrittenQuery() != null
? processed.getRewrittenQuery()
: userQuery;
// 2. 执行稀疏检索
SearchResp searchResp = milvusService.sparseSearch(
queryToSearch,
topK != null ? topK : 10,
orgId != null ? orgId : "default",
null // 使用默认集合
);
// 3. 打印输出显示
log.info("[RetrievalService] 稀疏检索完成");
// 4. 返回结果
return searchResp;
}
// MilvusServiceImpl.java
@Override
public SearchResp sparseSearch(String query, Integer topK, String orgId, String collection) {
String collectionName = (collection != null && !collection.isEmpty())
? collection
: properties.getCollection().getName();
int limit = (topK != null && topK > 0) ? topK : 10;
String targetOrgId = (orgId != null && !orgId.isEmpty()) ? orgId : "default";
log.info("[sparseSearch] 开始稀疏检索: query={}, topK={}, collection={}",
query.substring(0, Math.min(query.length(), 50)), limit, collectionName);
try {
// 执行 Milvus 向量搜索
return executeSparseSearch(query, limit, targetOrgId, collectionName);
} catch (Exception e) {
log.error("[sparseSearch] 向量检索失败", e);
throw new MilvusException("SPARSE_SEARCH", "向量检索失败: " + e.getMessage(), e);
}
}
private SearchResp executeSparseSearch(String query, int limit, String targetOrgId, String collectionName) {
Map<String, Object> params = new HashMap<>();
params.put("metric_type", "BM25");
params.put("drop_ratio_search", 0.3);
return milvusClient.search(SearchReq.builder()
.collectionName(collectionName)
.annsField("sparse_vector") // 对应定义的 SparseFloatVector 字段
.data(Collections.singletonList(new EmbeddedText(query)))
.topK(limit)
.searchParams(params)
.consistencyLevel(ConsistencyLevel.EVENTUALLY)
.outputFields(List.of(properties.getCollection().getIdField(),
"chunk_index",
"org_id",
properties.getCollection().getContentField(),
properties.getCollection().getMetadataField()
))
.build());
}
需要注意一下的是,创建集合的scheme需要修改:
/**
* 构建字段 Schema 列表
*/
private List<CreateCollectionReq.FieldSchema> buildFieldSchemaList(int dimension) {
List<CreateCollectionReq.FieldSchema> fields = new ArrayList<>();
// ID 字段,文档ID!!!
fields.add(CreateCollectionReq.FieldSchema.builder()
.name(properties.getCollection().getIdField())
.dataType(DataType.VarChar)
.isPrimaryKey(true)
.maxLength(36).description("ID主键")
.build());
// 分块--该文档的分块索引 【doc_id + chunk_index 可以快速定位是哪一个文档里面的分块】
fields.add(CreateCollectionReq.FieldSchema.builder()
.name("chunk_index")
.dataType(DataType.Int32)
.isNullable(true)
.build());
// 租户/组织 ID:【企业级核心】利用 Partition Key 实现多租户数据物理隔离
fields.add(CreateCollectionReq.FieldSchema.builder()
.name("org_id")
.dataType(DataType.VarChar)
.maxLength(64)
.isPartitionKey(true).description("组织机构ID,用于多租户查询加速")
.build());
// 向量字段
fields.add(CreateCollectionReq.FieldSchema.builder()
.name(properties.getCollection().getVectorField())
.dataType(DataType.FloatVector)
// // 向量维度,记得要和使用的embedding模型的维度要一致!!!!!!!
.dimension(dimension)
.build());
// 内容字段(支持全文搜索)【稀疏检索,原有的 contentField (VarChar) 必须开启分词器支持】
fields.add(CreateCollectionReq.FieldSchema.builder()
.name(properties.getCollection().getContentField())
.dataType(DataType.VarChar)
.enableAnalyzer(true) // 重点:开启分析器(分词)
.analyzerParams(Map.of("type", "chinese"))
.maxLength(65535).description("内容字段,原文内容")
.build());
// 元数据字段
fields.add(CreateCollectionReq.FieldSchema.builder()
.name(properties.getCollection().getMetadataField())
.dataType(DataType.JSON)
.maxLength(4096).isNullable(true)
.build());
// 1. 字段定义:增加“稀疏向量仓”【稀疏检索】
// 增加一个 SparseFloatVector 类型的字段。这个字段不需要你插入数据,它是给 Milvus 内置的 BM25 算法存放“计算结果”的。
fields.add(CreateCollectionReq.FieldSchema.builder()
.name("sparse_vector")
.dataType(DataType.SparseFloatVector)
.build());
return fields;
}
/**
* 构建索引参数
*/
private List<IndexParam> buildIndexParams() {
List<IndexParam> params = new ArrayList<>();
// 向量索引
IndexParam.IndexParamBuilder<?, ?> indexBuilder = IndexParam.builder()
.fieldName(properties.getCollection().getVectorField())
.indexName(properties.getCollection().getVectorField() + "_idx")
.indexType(IndexParam.IndexType.valueOf(properties.getCollection().getIndexType()))
.metricType(IndexParam.MetricType.valueOf(properties.getCollection().getMetricType()));
// HNSW 额外参数
if ("HNSW".equals(properties.getCollection().getIndexType())) {
java.util.Map<String, Object> extraParams = new java.util.HashMap<>();
extraParams.put("M", 16);
extraParams.put("efConstruction", 200);
indexBuilder.extraParams(extraParams);
}
params.add(indexBuilder.build());
// org_id 标量索引(加速多租户查询)
params.add(IndexParam.builder()
.fieldName("org_id")
.indexName("org_id_idx")
.indexType(IndexParam.IndexType.AUTOINDEX)
.build());
// 【稀疏检索】 索引定义:增加 BM25 专用索引
params.add(IndexParam.builder()
.fieldName("sparse_vector") // 对应上面的稀疏向量字段
.indexName("sparse_index")
// 稀疏倒排索引
.indexType(IndexParam.IndexType.SPARSE_INVERTED_INDEX)
.metricType(IndexParam.MetricType.BM25) // 必选 BM25
.build());
return params;
}
/**
* function 在 createCollection 方法中,你需要定义一个 Function,它像一个“触发器”:
* 每当你存入一段文本,它就自动算出 BM25 分数并填入稀疏向量字段。
*/
private CreateCollectionReq.Function buildFunction() {
return CreateCollectionReq.Function.builder()
.name("text_bm25_gen")
.functionType(FunctionType.BM25)
.inputFieldNames(Collection
// 输入:content内容文本
s.singletonList(properties.getCollection().getContentField()))
// 自动填入稀疏向量字段
.outputFieldNames(Collections.singletonList("sparse_vector"))
.build();
}
//======最终的创建请求
// 构建字段列表
List<CreateCollectionReq.FieldSchema> fields = buildFieldSchemaList(dimension);
// 构建索引参数
List<IndexParam> indexParams = buildIndexParams();
// 创建集合请求 - 使用 builder 模式
CreateCollectionReq req = CreateCollectionReq.builder()
.collectionName(collectionName)
.collectionSchema(CreateCollectionReq.CollectionSchema.builder()
.fieldSchemaList(fields)
.enableDynamicField(true)
.functionList(List.of(buildFunction()))
.build())
.indexParams(indexParams)
.numShards(properties.getCollection().getShardsNum())
.build();
milvusClient.createCollection(req);
上面可以看到是在一个Milvus引擎内完成了稀疏检索,这种模式架构简洁,运维成本低,一个数据库满足需求。实际上了解Elasticsearch这个中间件的同学也可以使用它来完成这个功能。
3.3 混合检索
混合检索 = 关键词检索(稀疏检索) + 向量检索(稠密检索),然后把两者的结果用某种算法(如 RRF)融合起来,得到最终排序。两种检索方式互补,单独使用任何一种都会丢失一部分相关结果。
混合检索能同时利用 关键词的精确匹配能力 和 向量的语义泛化能力,在 召回率(找到更多相关文档)和 排序质量上通常优于单一检索方式。尤其在 RAG应用中,给 LLM 提供高质量的混合检索结果,能显著减少“幻觉”,生成更准确的答案。
RRF
混合检索就是让“BM25”和“向量检索”同时去干活,然后拿回两份名单。那么痛点来了:这两份名单的“分数”,根本没法直接比!
- BM25 打分:它可能给文档 A 打了 50.5 分。
- 向量打分:它可能给文档 A 打了 0.85 分(向量相似度通常在 0~1 之间)。
如果你是一个没经验的人员,你可能会想:“那我把它们加起来?50.5 + 0.85 = 51.35 分?”大错特错! 这就像拿“人民币”和“美元”直接相加,单位不一样,瞎加只会导致结果完全乱套。BM25 的 50 分可能只是普通相关,而向量的 0.85 分可能是极度相关。RRF 就是为了解决这个问题而诞生的:“既然分数没法比,那我们就不看分数了,只看排名!”
RRF计算公式:
$$
RRF(d) = \sum_{s \in S} \frac{1}{k + r_s(d)}
$$
式中,d :一个文档; S :所有检索系统的集合(例如,BM25 系统、向量检索系统);rs(d) :文档 d 在系统 s 中的排名序号(从 1 开始,排名越靠前数值越小); k :一个平滑常数,通常取 60(经验值,用于防止某个排名特别靠后(即 rs(d) 很大)的文档贡献趋近于 0)。
式中只使用排名(1, 2, 3, ...),完全不依赖原始分数。这样就天然消除了不同检索系统之间分数尺度不一致的问题。
架构选择
本文采用简单的这种模式了,只用一个Milvus

感兴趣的,有余力的可以选择下面这种:

混合检索简单实现
// 主要就是与Milvus交互的部分
private SearchResp executeHybridSearch(String query, int limit, String targetOrgId, String collectionName) {
// 1. 得到用户query的Embedding
EmbeddingResponse response = modelFactory.getModel(SiliconfowModel.SILICONFLOW).embedding(List.of(query));
List<Float> embedding = response.getData().getFirst().getEmbedding();
// 2. 向量检索
AnnSearchReq denseReq = AnnSearchReq.builder()
.vectorFieldName("vector")
.vectors(Collections.singletonList(new FloatVec(embedding)))
.params("{\"nprobe\": " + 50 + "}")
.topK(limit)
.build();
// 3. 稀疏检索
AnnSearchReq sparseReq = AnnSearchReq.builder()
.vectorFieldName("sparse_vector")
.vectors(Collections.singletonList(new EmbeddedText(query)))
.params("{\"drop_ratio_search\": " + 0.3 + "}")
.topK(limit)
.build();
// 4.融合
CreateCollectionReq.Function rerank = CreateCollectionReq.Function.builder()
.name("rrf")
.functionType(FunctionType.RERANK)
.param("reranker", "rrf")
.param("k", "100")
.build();
HybridSearchReq hybridReq = HybridSearchReq.builder()
.collectionName(collectionName)
.searchRequests(List.of(denseReq, sparseReq))
// .ranker(rerank) // 这个不推荐了
.functionScore(FunctionScore.builder()
.addFunction(rerank)
.build())
.topK(limit)
.consistencyLevel(ConsistencyLevel.EVENTUALLY)
.outFields(List.of(properties.getCollection().getIdField(),
"chunk_index",
"org_id",
properties.getCollection().getContentField(),
properties.getCollection().getMetadataField()
))
.build();
// 5. 执行
return milvusClient.hybridSearch(hybridReq);
}
4. 重排
我们其实可以把检索过程想象成海选 + 决赛:
- 第一阶段(召回/混合检索)—— 海选:
- 目标是快、广。从超多(可能上亿哦)篇文档里,用简单的算法(BM25、向量距离)快速筛出几百篇可能相关的。这一阶段允许“粗”,但不能漏掉太多。就相当于秋招、春招,成千上万份简历,优先选择985/211的,其他的就差不多拜拜了您嘞
- 第二阶段(重排)—— 决赛:
- 目标是准、精。对这 100 篇文档,用一个更强大的模型(通常是基于深度学习的交叉编码器 Cross-Encoder),逐篇精确计算查询和文档的相关性分数,然后重新排序。就像是把我们这些学院本、双非剔除掉后,再从剩下的简历里面细看一下,选符合条件的。
为什么不能一开始就用重排模型呢?
因为重排模型太慢、太贵。它需要对查询和每一篇候选文档做一次完整的深度学习推理。如果对上亿篇文档都这样算,一次搜索可能要几分钟甚至几小时,完全不现实。所以必须分成两步:海选用快模型,决赛用准模型。亦如秋招春招,如果一份一份简历细看,看不过来吧,当然我是很希望hr一份一份细看,然后仔细对比的
简单实现:
// -------------------------------------------- Rerank部分 -------------------------
@Override
public RerankResponse rerank(String query, List<String> documents, Integer topN) {
if (query == null || query.isEmpty()) {
log.warn("Rerank查询文本为空!");
return RerankResponse.builder()
.errorMsg("Rerank查询文本为空!")
.build();
}
if (documents == null || documents.isEmpty()) {
log.warn("Rerank文档列表为空!");
return RerankResponse.builder()
.errorMsg("Rerank文档列表为空!")
.build();
}
log.info("开始调用硅基流动[Rerank]... 查询: {}, 文档数: {}",
query.substring(0, Math.min(query.length(), 50)), documents.size());
// 构建请求
String jsonData = buildRerankBodyJson(query, documents, topN);
RequestBody body = RequestBody.create(jsonData, MediaType.get("application/json"));
Request request = new Request.Builder()
.url(providerConfig.getBaseUrl() + RERANK_URL)
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + providerConfig.getApiKey())
.post(body)
.build();
Call call = siliconfowClient.newCall(request);
try {
Response response = call.execute();
if (!response.isSuccessful()) {
String errorBody = response.body().string();
log.error("SiliconFlow Rerank请求失败: {}", errorBody);
return RerankResponse.builder()
.errorMsg("SiliconFlow Rerank Error: " + errorBody)
.build();
}
return buildRerankResponse(response.body().string());
} catch (IOException e) {
log.error("SiliconFlow Rerank IO错误: {}", e.getMessage());
return RerankResponse.builder()
.errorMsg("SiliconFlow Rerank IO Error: " + e.getMessage())
.build();
}
}
// // Rerank测试
@Test
public void testRerank() {
String query = "我想吃一个红彤彤的大苹果!";
AbstractModel model = modelFactory.getModel(SiliconflowModel.SILICONFLOW);
RerankResponse rerankResponse = model.rerank(query, List.of("烂苹果", "红苹果", "香蕉", "橘子", "小苹果", "好吃的苹果", "大苹果"), 3);
System.out.println(rerankResponse);
}
// ==========测试结果
RerankResponse(errorMsg=null, model=null, results=[RerankResult(index=6, relevanceScore=0.9045071005821228, document=大苹果), RerankResult(index=1, relevanceScore=0.7820494174957275, document=红苹果), RerankResult(index=0, relevanceScore=0.693306028842926, document=烂苹果)])
5. 结合
有了上面的基础之后,这部分可以准备最终的问题,送给模型,让其回答了。
/**
* RAG问答(流式)
*/
@PostMapping("/rag-answer-stream")
public SseEmitter ragAnswerSimple(@RequestBody QueryRequest request) {
String sessionId = request.sessionId();
if (sessionId == null || sessionId.isEmpty()) {
sessionId = UUID.randomUUID().toString();
}
log.info("[RetrievalController] RAG问答(流式): sessionId={}, query={}",
sessionId, request.query());
return retrievalService.ragAnswerStream(request.query(), request.think(), request.sessionId());
}
// RetrievalService.java
/**
* 完整的RAG流程(流式输出)
*
* @param userQuery 用户查询
* @param sessionId 会话ID
* @return SSE流
*/
public SseEmitter ragAnswerStream(String userQuery, Integer think, String sessionId) {
log.info("[RetrievalService] 开始流式RAG流程: query={}", userQuery.substring(0, Math.min(userQuery.length(), 50)));
return ragChatTask(userQuery, think, sessionId);
}
private SseEmitter ragChatTask(String userQuery, Integer think, String sessionId) {
// 1. 用户输入处理(意图识别 + 查询重写)
ProcessedQuery processed = userInputProcessor.process(userQuery);
String queryToSearch = processed.getRewrittenQuery() != null
? processed.getRewrittenQuery()
: userQuery;
// 2. 执行混合检索
SearchResp searchResp = milvusService.hybridSearch(
queryToSearch,
20,
"org_id123456", // TODO 这里先写死
null
);
List<SearchResult> retrievalResults = ConvertUtil.convertToSearchResults(searchResp);
// 检索到了文档, 3. 重排
List<String> rankedDocuments = null;
if (searchResp != null && searchResp.getSearchResults() != null && !searchResp.getSearchResults().isEmpty()) {
// 3.1 提取文档内容
List<String> documents = extractDocumentsFromSearchResp(retrievalResults);
AbstractModel model = modelFactory.getModel(SiliconflowModel.SILICONFLOW);
log.info("[RetrievalService] 检索到 {} 个文档,开始重排...", documents.size());
// 3.2 执行重排
RerankResponse rerankResp = model.rerank(queryToSearch, documents, 10);
if (rerankResp == null || !rerankResp.isSuccess() || rerankResp.getResults() == null) {
log.warn("[RetrievalService] 重排失败或未实现,使用原始检索结果");
retrievalResults = retrievalResults.subList(0, Math.min(retrievalResults.size(), 5));
} else {
List<SearchResult> newRetrievalResults = new ArrayList<>();
for (RerankResult result : rerankResp.getResults()) {
newRetrievalResults.add(retrievalResults.get(result.getIndex()));
}
retrievalResults = newRetrievalResults;
}
}
rankedDocuments = retrievalResults.stream().map(SearchResult::getContent).toList();
// 4. 构建RAG提示词
String context = String.join("\n\n---\n\n", rankedDocuments);
String systemPrompt = String.format(RAG_SYSTEM_PROMPT, context);
// 5. 调用模型生成回答
List<AbstractModel.Message> messages = List.of(
new AbstractModel.Message("system", systemPrompt),
new AbstractModel.Message("user", userQuery)
);
log.info("[RetrievalService] 调用模型生成回答...");
return modelFactory.getModel(SiliconflowModel.SILICONFLOW).chatStream(messages, retrievalResults, think, sessionId);
}
调用接口测试一下:

受篇幅限制,还有一部分请见后续。

浙公网安备 33010602011771号