摘要
知识库问答的质量不只取决于大模型,向量化、召回和重排序决定了模型最终能看到哪些证据。如果召回阶段漏掉正确文档,后续 Prompt 写得再好也无法生成可靠答案;如果召回结果包含大量噪声,模型又会被无关内容干扰。
本文继续"企业智能客服与知识库助手"项目,设计向量化、召回和重排序服务,覆盖:
- 文档 Chunk 如何批量向量化;
- Embedding Profile 和索引代次如何管理;
- 向量召回、关键词召回和混合召回;
- Metadata、租户和权限过滤;
- Reranker 的作用和调用链;
- 超时、批量、缓存、重试和降级;
- Recall@K、MRR、NDCG 等评估指标;
- 如何将检索链路拆成可观测、可替换的服务。
一、背景与问题
1. 召回错了,生成就没有依据
RAG 链路可以表示为:
text
用户问题
↓
查询向量
↓
候选文档召回
↓
重排序
↓
上下文组装
↓
模型回答
如果正确文档没有进入候选集合,重排序和生成都无法补救。
2. 只使用向量召回的局限
向量召回擅长语义相似,但对以下内容可能不稳定:
- 订单号;
- 错误码;
- 产品型号;
- 法规编号;
- 精确日期;
- 特殊缩写。
关键词召回擅长精确匹配,却可能漏掉同义表达。企业知识库通常需要混合召回。
3. 为什么需要重排序
初次召回通常追求覆盖率,可能取 Top 20 或 Top 50;模型上下文又不适合放这么多内容。Reranker 可以对"问题---文档片段"二元关系重新打分:
text
向量 / 关键词召回 30 条
↓
Reranker 重新判断相关性
↓
保留前 5 条
重排序增加延迟和成本,需要通过评估集验证收益。
二、核心概念
1. Embedding Profile
Embedding Profile 描述一组固定向量化配置:
text
profile_id
model_name
provider
dimension
normalization
max_input_tokens
version
一个知识库索引必须绑定一个 Profile。更换 Profile 通常意味着重建向量。
2. Recall Candidate
Candidate 是初次召回结果,包含:
- Chunk ID;
- 文本;
- 来源;
- 召回方式;
- 原始分数;
- Metadata;
- 索引代次。
不要在召回阶段丢掉原始分数,否则无法分析阈值和排序问题。
3. Hybrid Retrieval
混合检索同时使用:
text
向量检索:语义相似
关键词检索:精确匹配
Metadata 过滤:权限和业务范围
结果合并时可以使用加权分数、RRF 或重排序模型。
4. Reranker
Reranker 接收问题和候选文档,输出相关性分数:
json
{
"query": "差旅报销多久提交",
"document": "出差结束后十个工作日内提交报销申请",
"score": 0.94
}
它不是生成模型,也不负责回答问题。
三、工作原理
1. 离线向量化流程
text
读取有效文档版本
↓
读取 Chunk
↓
按批次调用 Embedding Model
↓
校验向量维度
↓
写入向量库
↓
更新索引进度
↓
标记 generation 可用
2. 在线检索流程
text
用户问题
↓
权限和租户校验
↓
查询改写或规范化
↓
Embedding
↓
向量召回
↓
关键词召回
↓
结果合并和去重
↓
Reranker
↓
上下文压缩
3. 过滤顺序
推荐先做安全过滤,再做相关性排序:
text
租户和知识库范围
↓
文档版本和状态
↓
用户权限
↓
向量 / 关键词召回
↓
重排序
不能先从全库召回 Top-K 再过滤,因为其他租户文档可能挤占结果,也可能出现在日志和中间数据中。
4. 服务边界
text
EmbeddingService
└─ 文本 → 向量
CandidateRetriever
└─ 问题 → 候选 Chunk
RerankService
└─ 问题 + 候选 → 排序结果
ContextBuilder
└─ 排序结果 → 模型上下文
每个服务都可以单独替换和评估。
四、实战示例
1. 数据模型
java
public record EmbeddingProfile(
String id,
String model,
int dimension,
String version) {
}
public record RetrievalCandidate(
String chunkId,
String content,
double score,
String source,
String retrievalMethod,
Map<String, Object> metadata) {
}
public record RankedCandidate(
RetrievalCandidate candidate,
double rerankScore,
int rank) {
}
2. 批量向量化
java
public class EmbeddingService {
private final EmbeddingModel embeddingModel;
private final EmbeddingProfile profile;
public List<float[]> embedBatch(List<String> texts) {
List<float[]> vectors = embeddingModel.embed(texts);
for (float[] vector : vectors) {
if (vector.length != profile.dimension()) {
throw new IllegalStateException(
"embedding dimension mismatch"
);
}
}
return vectors;
}
}
批量大小要根据 Provider 限制、网络延迟、内存和失败重试成本调整。
3. 向量召回
java
public List<RetrievalCandidate> vectorRecall(
UUID tenantId,
UUID knowledgeBaseId,
String question,
int topK) {
String filter = "tenant_id == '" + tenantId
+ "' && knowledge_base_id == '" + knowledgeBaseId
+ "' && index_status == 'ACTIVE'";
SearchRequest request = SearchRequest.builder()
.query(question)
.topK(topK)
.similarityThreshold(0.55)
.filterExpression(filter)
.build();
return vectorStore.similaritySearch(request)
.stream()
.map(documentMapper::toCandidate)
.toList();
}
过滤值必须来自服务端可信上下文。非 UUID 字符串应通过过滤表达式构建器处理,不能直接拼接未校验输入。
4. 关键词召回
java
public List<RetrievalCandidate> keywordRecall(
UUID tenantId,
UUID knowledgeBaseId,
String question,
int topK) {
return searchRepository.fullTextSearch(
tenantId,
knowledgeBaseId,
question,
topK
);
}
关键词检索可以使用 PostgreSQL Full Text Search、Elasticsearch 或其他搜索引擎。关键是返回统一的 Candidate 结构。
5. 合并候选
java
public List<RetrievalCandidate> merge(
List<RetrievalCandidate> vector,
List<RetrievalCandidate> keyword) {
Map<String, RetrievalCandidate> unique =
new LinkedHashMap<>();
Stream.concat(vector.stream(), keyword.stream())
.forEach(candidate ->
unique.merge(
candidate.chunkId(),
candidate,
this::preferHigherScore
));
return unique.values()
.stream()
.limit(30)
.toList();
}
实际混合排序可以使用 RRF:
text
RRF(chunk) = Σ 1 / (k + rank_i)
RRF 不要求不同召回器的分数在同一尺度上,适合作为简单的候选合并方法。
6. 调用 Reranker
java
public List<RankedCandidate> rerank(
String question,
List<RetrievalCandidate> candidates) {
List<RerankRequestItem> items = candidates.stream()
.map(candidate -> new RerankRequestItem(
candidate.chunkId(),
question,
candidate.content()
))
.toList();
List<RerankScore> scores =
rerankClient.score(items);
return scores.stream()
.sorted(Comparator.comparingDouble(
RerankScore::score
).reversed())
.limit(6)
.map(score -> new RankedCandidate(
findCandidate(candidates, score.id()),
score.score(),
score.rank()
))
.toList();
}
Reranker 超时时应有降级策略,例如使用初次召回排序,但要记录 rerank_status=DEGRADED。
7. 上下文构建
java
public String buildContext(
List<RankedCandidate> ranked) {
return IntStream.range(0, ranked.size())
.mapToObj(i -> {
RankedCandidate item = ranked.get(i);
return "[资料 " + (i + 1) + "]\n"
+ item.candidate().content();
})
.collect(Collectors.joining("\n\n"));
}
上下文构建时要限制:
- 最大 Chunk 数;
- 最大字符数或 Token 数;
- 单文档最大片段数;
- 重复内容;
- 敏感字段;
- 引用 Metadata。
8. 统一检索服务
java
public RetrievalResult retrieve(
RetrievalRequest request) {
List<RetrievalCandidate> vector =
vectorRecall(
request.tenantId(),
request.knowledgeBaseId(),
request.question(),
20
);
List<RetrievalCandidate> keyword =
keywordRecall(
request.tenantId(),
request.knowledgeBaseId(),
request.question(),
20
);
List<RetrievalCandidate> candidates =
merge(vector, keyword);
List<RankedCandidate> ranked =
rerank(request.question(), candidates);
return new RetrievalResult(
ranked,
metrics(vector, keyword, ranked)
);
}
9. 索引代次
sql
CREATE TABLE knowledge_index_generation (
id UUID PRIMARY KEY,
knowledge_base_id UUID NOT NULL,
embedding_profile VARCHAR(128) NOT NULL,
splitter_version VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
activated_at TIMESTAMPTZ
);
查询只使用当前 Active Generation:
text
Generation 001:旧 Embedding,INACTIVE
Generation 002:新 Embedding,ACTIVE
10. 召回指标
text
Recall@K = Top-K 中包含正确证据的问题比例
MRR = 第一个正确结果排名的倒数平均值
NDCG = 考虑结果相关等级和排序位置的指标
准备人工标注集:
json
{
"question": "差旅报销多久提交",
"relevantChunkIds": ["chunk-1001", "chunk-1002"]
}
每次调整切片、Embedding、召回、重排或阈值后重新评估。
五、常见问题与实践建议
1. 为什么向量召回没有找到正确内容
排查:
- 原文是否解析成功;
- Chunk 是否包含完整事实;
- Embedding 模型是否一致;
- 查询是否需要改写;
- Top-K 是否过小;
- Metadata 是否过滤过严;
- 向量距离和阈值是否匹配。
2. 为什么加入重排序后更慢
重排序通常需要额外模型请求。优化方法:
- 只对 20 到 50 条候选排序;
- 批量发送;
- 设置短超时;
- 缓存重复查询;
- 对简单问题跳过重排;
- 使用更轻量的 Reranker。
3. Reranker 失败是否要让问答失败
低风险问答可以降级到初次召回,高风险领域可以直接返回"检索服务暂时不可用",避免用未经重排的低置信证据生成结论。降级策略需要按业务等级配置。
4. 向量和关键词结果如何去重
使用稳定的 chunkId 去重,不要只比较文本内容,因为同样的文字可能来自不同文档版本和不同租户。
5. 如何处理长问题
长问题可以先做:
- 问题摘要;
- 子问题拆分;
- 查询改写;
- 去除无关背景;
- 多路检索后合并。
改写查询不能带入用户无权限的历史信息。
6. 召回服务是否应该同步执行
在线问答可以同步执行,但文档批量向量化应放到异步 Worker。向量化失败不应阻塞文件上传接口。
7. 如何避免跨租户泄露
每次检索必须从认证上下文获取租户和用户,并将权限过滤写进底层查询。引用返回前再次校验,不要只信任向量库返回的 Metadata。
8. 如何缓存 Embedding
可以使用内容哈希缓存:
text
cache_key =
embedding_profile + content_hash
更换 Profile 时自动失效,避免不同模型的向量混用。
六、进阶思考
1. 查询改写和多路召回
一个复杂问题可以生成多个检索查询:
text
原始问题
↓
查询改写
├─ 业务术语查询
├─ 关键词查询
└─ 语义问题查询
↓
分别召回
↓
合并和重排
多路召回增加成本,应只对复杂或低置信问题启用。
2. Contextual Compression
重排序后可以进一步压缩 Chunk,只保留与当前问题相关的句子或段落,减少上下文 Token。但压缩过程可能丢失限定条件,重要制度和法律条款需要保留完整上下文。
3. 召回缓存
对相同知识库、同一索引代次和相同查询,可以缓存候选结果:
text
tenant + knowledgeBase + generation + normalizedQuery
缓存必须绑定权限和索引版本,不能跨租户共享。
4. 线上反馈
可以收集:
- 用户是否点击引用;
- 是否追问;
- 是否手动改写问题;
- 是否标记答案无帮助;
- 人工选择的正确文档。
这些反馈可以用于调整 Chunk、Embedding、阈值和 Reranker。
5. 检索服务的高可用
向量库、关键词搜索和 Reranker 需要分别设置:
- 超时;
- 并发上限;
- 重试;
- 熔断;
- 降级;
- 监控;
- 资源配额。
不能让 Reranker 故障拖垮全部问答请求。
结论
向量化、召回与重排序服务是 RAG 系统的证据层。稳定实现需要:
- 用固定 Embedding Profile 管理向量空间;
- 用索引代次支持模型和切片策略迁移;
- 将租户、权限和文档状态过滤前置;
- 通过向量和关键词混合召回提升覆盖;
- 用 Reranker 提升候选相关性;
- 对超时、失败和成本设计降级;
- 使用 Recall@K、MRR 和 NDCG 建立评估基线;
- 保留候选、分数、引用和检索配置,支持问题排查。
先建立可观测的基础召回,再根据评估结果逐步加入查询改写、混合检索、重排和上下文压缩。