RAG系统从0到1:向量数据库选型、分块策略、召回率优化完整方案
RAG系统从0到1:向量数据库选型、分块策略、召回率优化完整方案
适读人群:Java后端工程师、AI应用开发者 | 阅读时长:约20分钟 | 依赖:Spring AI 1.0、LangChain4j 0.36
开篇故事
去年年底,我们团队接到一个需求:给公司内部法务系统搭一个合同智能问答功能。业务方的要求很直接——上传合同文件,然后问任何关于合同条款的问题,系统给出准确答案。听起来不难,RAG嘛,不就是"切文档、入库、检索、生成"四步走?
我当时也是这么想的,所以第一版用了最朴素的方案:按固定长度500字切块,用OpenAI的text-embedding-ada-002做向量,Chroma存储,余弦相似度Top5召回。上线之后,业务方反馈很快来了:
"老张,为什么我问'违约金条款是多少',它给我返回的是合同的甲方信息?"
"为什么问'合同有效期',答案里没有具体日期,但文档里明明有?"
我跑去看了下召回内容,问题一目了然:固定字数切块把一个完整的违约金条款劈成了两半,前半截在一个chunk里,后半截在另一个chunk里,两半单独看都不完整,召回哪半段都答不准。有效期那个更离谱,时间点被切到了下一个chunk的开头,和上下文完全割裂。
就这样,我被迫把RAG召回率优化这件事从"做完"提升到了"做好"。花了将近两个月,踩了一地坑,最终把召回准确率从不到60%推到了87%。今天把这套完整方案整理出来,从选型到调优,一步步讲清楚。
一、核心问题分析
RAG系统召回质量差的根本原因,通常集中在三个环节:
1. 文档分块策略不合理
最常见的错误是无脑固定字数切块。合同、法规、技术文档这类结构化文档,天然有段落、条款、章节的语义边界。强行按字数截断,必然产生大量语义不完整的碎片。碎片进了向量库,检索时任何查询都很难和它精确匹配。
2. Embedding模型与语料不匹配
用英文预训练模型做中文法律文本,这是第二个常见坑。text-embedding-ada-002对中文其实支持不错,但遇到专业领域词汇(比如"不可抗力"、"违约责任承担方式"),泛化能力不足,相似度计算会偏差很大。
3. 单路检索信息不够全
只用向量相似度检索,对关键词精确匹配的场景天然劣势。"合同编号CF-2024-0033"这种精确字符串,向量检索反而不如BM25关键词检索准确。
问题量化分析:
| 问题类型 | 发生频率 | 对准确率影响 |
|---|---|---|
| 分块切断语义 | 35% 的查询 | -25个百分点 |
| Embedding不匹配 | 20% 的查询 | -15个百分点 |
| 单路检索遗漏 | 15% 的查询 | -10个百分点 |
| 其他(噪声、格式等) | 10% 的查询 | -5个百分点 |
二、原理深度解析
2.1 完整RAG架构
2.2 分块策略原理
分块的本质是在"块足够小(检索精准)"和"块足够大(语义完整)"之间找平衡。
递归字符分块(RecursiveCharacterTextSplitter)的核心逻辑是按照优先级尝试分隔符:\n\n > \n > 。 > , > 空格 > 字符。这样能在满足最大长度限制的前提下,尽量保留语义完整性。
语义分块(SemanticChunking)更激进:对相邻句子分别做embedding,计算余弦相似度,当相似度突然下降(说明话题切换),就在这里切断。这个方法效果很好,但每次建索引的计算成本较高。
Overlap(重叠)策略:每个chunk和下一个chunk共享一段文字(通常50-100字)。这样即使关键信息在边界附近,也不会完全丢失。
2.3 混合检索与RRF融合
倒数排名融合(Reciprocal Rank Fusion,RRF)是把向量检索和BM25检索结果合并的最简单有效方法:
RRF_Score(doc) = Σ 1 / (k + rank_i(doc))其中 k 通常取60,rank_i(doc) 是文档在第i路检索结果中的排名。
这个公式的妙处在于:它不依赖各路检索的原始分数(避免了不同检索系统分数不可比的问题),只看排名,最终综合排名靠前的文档得分高。
三、完整代码实现
3.1 Maven依赖配置
<dependencies>
<!-- Spring AI核心 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- pgvector向量存储 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- PDF解析 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Apache Lucene for BM25 -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>9.10.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analysis-smartcn</artifactId>
<version>9.10.0</version>
</dependency>
</dependencies>3.2 智能文档分块器
@Component
public class SmartDocumentChunker {
private static final Logger log = LoggerFactory.getLogger(SmartDocumentChunker.class);
// 最大chunk字数(中文字符)
private static final int MAX_CHUNK_SIZE = 800;
// 重叠字数
private static final int OVERLAP_SIZE = 100;
// 语义分割相似度阈值——低于此值认为话题切换
private static final double SEMANTIC_BREAK_THRESHOLD = 0.75;
private final EmbeddingModel embeddingModel;
public SmartDocumentChunker(EmbeddingModel embeddingModel) {
this.embeddingModel = embeddingModel;
}
/**
* 递归字符分块——适合结构化文档(合同、规章制度)
*/
public List<String> recursiveChunk(String text) {
List<String> chunks = new ArrayList<>();
// 分隔符优先级
String[] separators = {"\n\n", "\n", "。", ";", ",", " ", ""};
recursiveSplit(text, separators, 0, chunks);
// 对过短的chunk进行合并
return mergeShortChunks(chunks, MAX_CHUNK_SIZE / 4);
}
private void recursiveSplit(String text, String[] separators,
int sepIndex, List<String> result) {
if (text.length() <= MAX_CHUNK_SIZE) {
if (!text.trim().isEmpty()) {
result.add(text.trim());
}
return;
}
if (sepIndex >= separators.length) {
// 兜底:强制按最大长度截断
for (int i = 0; i < text.length(); i += MAX_CHUNK_SIZE - OVERLAP_SIZE) {
int end = Math.min(i + MAX_CHUNK_SIZE, text.length());
result.add(text.substring(i, end));
}
return;
}
String sep = separators[sepIndex];
if (sep.isEmpty()) {
recursiveSplit(text, separators, sepIndex + 1, result);
return;
}
String[] parts = text.split(Pattern.quote(sep));
StringBuilder currentChunk = new StringBuilder();
for (String part : parts) {
if (currentChunk.length() + part.length() + sep.length() <= MAX_CHUNK_SIZE) {
if (currentChunk.length() > 0) {
currentChunk.append(sep);
}
currentChunk.append(part);
} else {
if (currentChunk.length() > 0) {
recursiveSplit(currentChunk.toString(), separators, sepIndex + 1, result);
// 添加重叠
String overlap = getLastNChars(currentChunk.toString(), OVERLAP_SIZE);
currentChunk = new StringBuilder(overlap);
if (!part.isEmpty()) {
currentChunk.append(sep).append(part);
}
} else {
recursiveSplit(part, separators, sepIndex + 1, result);
}
}
}
if (currentChunk.length() > 0) {
recursiveSplit(currentChunk.toString(), separators, sepIndex + 1, result);
}
}
/**
* 语义分块——适合叙述性文档(研报、新闻)
*/
public List<String> semanticChunk(String text) {
// 1. 按句子切分
List<String> sentences = splitIntoSentences(text);
if (sentences.size() <= 1) {
return recursiveChunk(text);
}
// 2. 计算相邻句子的embedding相似度
List<float[]> embeddings = sentences.stream()
.map(s -> embeddingModel.embed(s))
.collect(Collectors.toList());
// 3. 找到相似度断点
List<Integer> breakPoints = new ArrayList<>();
for (int i = 0; i < embeddings.size() - 1; i++) {
double similarity = cosineSimilarity(embeddings.get(i), embeddings.get(i + 1));
if (similarity < SEMANTIC_BREAK_THRESHOLD) {
breakPoints.add(i + 1);
}
}
// 4. 按断点聚合句子成chunk
List<String> chunks = new ArrayList<>();
int start = 0;
for (int bp : breakPoints) {
String chunk = String.join("", sentences.subList(start, bp));
if (chunk.length() > MAX_CHUNK_SIZE) {
chunks.addAll(recursiveChunk(chunk));
} else {
chunks.add(chunk);
}
start = bp;
}
// 最后一段
String lastChunk = String.join("", sentences.subList(start, sentences.size()));
if (!lastChunk.trim().isEmpty()) {
if (lastChunk.length() > MAX_CHUNK_SIZE) {
chunks.addAll(recursiveChunk(lastChunk));
} else {
chunks.add(lastChunk);
}
}
return chunks;
}
private List<String> splitIntoSentences(String text) {
// 中文句子分隔:句号、问号、感叹号
return Arrays.asList(text.split("(?<=[。!?])"));
}
private List<String> mergeShortChunks(List<String> chunks, int minSize) {
List<String> merged = new ArrayList<>();
StringBuilder buf = new StringBuilder();
for (String chunk : chunks) {
buf.append(chunk);
if (buf.length() >= minSize) {
merged.add(buf.toString());
// 保留overlap
buf = new StringBuilder(getLastNChars(buf.toString(), OVERLAP_SIZE));
}
}
if (buf.length() > 0) {
merged.add(buf.toString());
}
return merged;
}
private String getLastNChars(String s, int n) {
if (s.length() <= n) return s;
return s.substring(s.length() - n);
}
private double cosineSimilarity(float[] a, float[] b) {
double dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-10);
}
}3.3 混合检索服务(向量+BM25)
@Service
public class HybridRetrieverService {
private static final Logger log = LoggerFactory.getLogger(HybridRetrieverService.class);
private static final int RRF_K = 60;
private static final int TOP_N = 20; // 每路各取Top20,融合后取Top5
private final VectorStore vectorStore;
private final BM25IndexService bm25Service;
public HybridRetrieverService(VectorStore vectorStore,
BM25IndexService bm25Service) {
this.vectorStore = vectorStore;
this.bm25Service = bm25Service;
}
/**
* 混合检索入口
* @param query 用户查询
* @param topK 最终返回文档数
* @return 重排后的文档列表
*/
public List<Document> hybridSearch(String query, int topK) {
// 1. 向量检索
List<Document> vectorResults = vectorSearch(query, TOP_N);
// 2. BM25关键词检索
List<Document> bm25Results = bm25Service.search(query, TOP_N);
// 3. RRF融合
Map<String, Double> rrfScores = new HashMap<>();
Map<String, Document> docMap = new HashMap<>();
// 向量检索结果贡献RRF分数
for (int i = 0; i < vectorResults.size(); i++) {
Document doc = vectorResults.get(i);
String id = doc.getId();
docMap.put(id, doc);
rrfScores.merge(id, 1.0 / (RRF_K + i + 1), Double::sum);
}
// BM25检索结果贡献RRF分数
for (int i = 0; i < bm25Results.size(); i++) {
Document doc = bm25Results.get(i);
String id = doc.getId();
docMap.put(id, doc);
rrfScores.merge(id, 1.0 / (RRF_K + i + 1), Double::sum);
}
// 4. 按RRF分数降序排列,取topK
return rrfScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(e -> docMap.get(e.getKey()))
.collect(Collectors.toList());
}
private List<Document> vectorSearch(String query, int topN) {
SearchRequest request = SearchRequest.builder()
.query(query)
.topK(topN)
.similarityThreshold(0.5)
.build();
return vectorStore.similaritySearch(request);
}
}3.4 BM25索引服务(基于Lucene)
@Service
public class BM25IndexService {
private static final String INDEX_PATH = "/tmp/rag-bm25-index";
private Directory indexDirectory;
private Analyzer analyzer;
@PostConstruct
public void init() throws IOException {
// 使用智能中文分析器
analyzer = new SmartChineseAnalyzer();
indexDirectory = FSDirectory.open(Paths.get(INDEX_PATH));
}
/**
* 索引文档
*/
public void indexDocuments(List<Document> documents) throws IOException {
IndexWriterConfig config = new IndexWriterConfig(analyzer);
config.setSimilarity(new BM25Similarity(1.2f, 0.75f)); // k1=1.2, b=0.75
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
try (IndexWriter writer = new IndexWriter(indexDirectory, config)) {
for (Document springDoc : documents) {
org.apache.lucene.document.Document luceneDoc =
new org.apache.lucene.document.Document();
luceneDoc.add(new TextField("content", springDoc.getText(), Field.Store.YES));
luceneDoc.add(new StringField("id", springDoc.getId(), Field.Store.YES));
// 存储元数据
String metadata = JsonUtils.toJson(springDoc.getMetadata());
luceneDoc.add(new StoredField("metadata", metadata));
writer.addDocument(luceneDoc);
}
writer.commit();
}
}
/**
* BM25检索
*/
public List<Document> search(String queryText, int topN) {
try (DirectoryReader reader = DirectoryReader.open(indexDirectory)) {
IndexSearcher searcher = new IndexSearcher(reader);
searcher.setSimilarity(new BM25Similarity(1.2f, 0.75f));
QueryParser parser = new QueryParser("content", analyzer);
Query query = parser.parse(QueryParser.escape(queryText));
TopDocs topDocs = searcher.search(query, topN);
List<Document> results = new ArrayList<>();
for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
org.apache.lucene.document.Document luceneDoc =
searcher.storedFields().document(scoreDoc.doc);
String id = luceneDoc.get("id");
String content = luceneDoc.get("content");
String metadataJson = luceneDoc.get("metadata");
Map<String, Object> metadata = JsonUtils.fromJson(metadataJson, Map.class);
Document springDoc = new Document(id, content, metadata);
results.add(springDoc);
}
return results;
} catch (Exception e) {
log.error("BM25检索失败: {}", e.getMessage(), e);
return Collections.emptyList();
}
}
}3.5 RAG问答主服务
@Service
public class RagQaService {
private final HybridRetrieverService retrieverService;
private final ChatClient chatClient;
private final SmartDocumentChunker chunker;
private static final String QA_PROMPT_TEMPLATE = """
你是一个专业的合同分析助手。请严格根据以下参考文档内容回答用户问题。
如果参考文档中没有相关信息,请明确告知用户,不要编造内容。
参考文档:
{context}
用户问题:{question}
请给出准确、简洁的回答:
""";
public RagQaService(HybridRetrieverService retrieverService,
ChatClient.Builder chatClientBuilder,
SmartDocumentChunker chunker) {
this.retrieverService = retrieverService;
this.chatClient = chatClientBuilder.build();
this.chunker = chunker;
}
public String answer(String question) {
// 1. 混合检索Top5相关文档
List<Document> relevantDocs = retrieverService.hybridSearch(question, 5);
if (relevantDocs.isEmpty()) {
return "抱歉,知识库中未找到与您问题相关的内容。";
}
// 2. 构建上下文
String context = relevantDocs.stream()
.map(Document::getText)
.collect(Collectors.joining("\n\n---\n\n"));
// 3. 调用LLM生成答案
String prompt = QA_PROMPT_TEMPLATE
.replace("{context}", context)
.replace("{question}", question);
return chatClient.prompt(prompt)
.call()
.content();
}
/**
* 文档入库(索引阶段)
*/
public void indexDocument(String filePath) throws IOException {
// 读取并解析PDF
PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(
new FileSystemResource(filePath),
PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageExtractedTextFormatter(
ExtractedTextFormatter.builder()
.withNumberOfTopPagesToSkipBeforeDelete(0)
.build())
.withPagesPerDocument(1)
.build()
);
List<Document> rawDocs = pdfReader.get();
String fullText = rawDocs.stream()
.map(Document::getText)
.collect(Collectors.joining("\n"));
// 智能分块
List<String> chunks = chunker.recursiveChunk(fullText);
// 转换为Spring AI Document
List<Document> documents = new ArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("source", filePath);
metadata.put("chunk_index", i);
metadata.put("total_chunks", chunks.size());
documents.add(new Document(chunks.get(i), metadata));
}
// 存入向量库
vectorStore.add(documents);
// 存入BM25索引
bm25Service.indexDocuments(documents);
log.info("文档索引完成,共{}个chunk,文件:{}", chunks.size(), filePath);
}
}3.6 评估框架
@Component
public class RagEvaluator {
/**
* 计算召回率:检索结果中包含正确答案的比例
* @param testCases 测试用例列表 (question -> groundTruth)
* @param retriever 检索服务
* @return 召回率 0~1
*/
public double evaluateRecallAt5(
List<Map.Entry<String, String>> testCases,
HybridRetrieverService retriever) {
int hit = 0;
for (Map.Entry<String, String> tc : testCases) {
String question = tc.getKey();
String groundTruth = tc.getValue();
List<Document> retrieved = retriever.hybridSearch(question, 5);
boolean found = retrieved.stream()
.anyMatch(doc -> doc.getText().contains(groundTruth));
if (found) hit++;
}
return (double) hit / testCases.size();
}
}四、效果评估与优化
基于300条法律合同标注测试集,不同方案的召回率对比:
| 方案 | Recall@5 | MRR | 平均延迟 |
|---|---|---|---|
| 固定500字切块 + OpenAI Embedding + 向量检索 | 58.3% | 0.42 | 320ms |
| 递归分块(800字+100重叠) + OpenAI Embedding + 向量检索 | 71.2% | 0.55 | 340ms |
| 递归分块 + BGE-M3 + 向量检索 | 76.8% | 0.61 | 380ms |
| 递归分块 + BGE-M3 + 混合检索(RRF) | 83.5% | 0.71 | 510ms |
| 语义分块 + BGE-M3 + 混合检索 + 重排序 | 87.2% | 0.78 | 890ms |
从58.3%到87.2%,提升了将近29个百分点。其中分块策略优化贡献最大(约13个点),混合检索贡献次之(约7个点),重排序再贡献约4个点。
延迟分析: 语义分块的索引构建成本高,但在线检索延迟只增加了380ms,这在合同问答场景(用户不介意等1秒以内)完全可以接受。对于实时性要求高的场景,可以去掉在线重排序,只用混合检索,延迟510ms,召回率83.5%也够用。
优化思路:
- Query改写:用LLM把用户的口语化问题改写成更规范的检索词,可以再提升3-5个点。
- Parent-Child分块:小chunk用于精确检索,检索到后返回其父级大chunk作为上下文,兼顾精准与完整。
- 元数据过滤:对于有明确文档编号的查询,先按元数据过滤,再向量检索,速度快准确率高。
五、踩坑实录
坑1:Embedding模型的输入长度限制踩到了
BGE-M3的最大输入是8192个token,听起来挺多,但我一开始没注意,有几个特别长的合同条款chunk超过了这个限制。调用Embedding接口时没有报错,而是静默截断——只对前8192个token做了embedding,但我压根不知道。结果那些长chunk的向量是错的,检索完全不准。后来加了一个前置长度检查,超限的强制分割。
坑2:BM25索引和向量索引的ID不一致
我在做RRF融合时,用文档ID对齐两路检索结果。但忘了一件事:向量库存储时Spring AI会自动生成UUID作为ID,而BM25索引我用的是自定义整数ID。两边ID对不上,融合时没有任何文档能被合并,等于两路结果就是简单拼接然后去重,RRF完全没有发挥作用。调试了半天才发现这个问题。正确做法是在入库时统一分配ID,两个索引用同一套ID体系。
坑3:中文分词器对法律术语的处理差
SmartChineseAnalyzer把"不可抗力"拆成了"不可"、"抗力",把"违约责任"拆成了"违约"、"责任",用户查"不可抗力条款"时,BM25匹配到了大量包含"不可"的无关段落。后来改用了基于HanLP的自定义词典分析器,把法律专业词汇加进去,分词准确率大幅提升,BM25那路的贡献才真正显现出来。
坑4:相似度阈值设太高,关键信息被过滤掉了
一开始我把向量检索的相似度阈值设成0.8,觉得低于0.8的就是不相关的。但实际测下来,有些表述方式和查询词语义距离确实比较远(比如用户问"合同金额",文档里写的是"合同总价款"),相似度只有0.72,但内容完全是用户想要的。把阈值调到0.5之后,召回率提升了将近8个点,虽然精确率略有下降,但LLM的生成阶段可以过滤噪声,整体效果更好。
六、总结
RAG系统的召回率优化不是一蹴而就的事,它是分块策略、Embedding选型、检索算法三个维度协同优化的结果。
从这个项目的实践来看,对召回率贡献最大的是分块策略——把固定字数切换成递归字符分块,这一步最省力,效果却最明显。第二步是换中文友好的Embedding模型,尤其是涉及专业领域中文语料的场景,BGE-M3相比OpenAI的英文为主的模型优势很明显。第三步是引入混合检索,精确匹配和语义匹配各有所长,RRF融合是目前最稳妥的方案。
如果你现在正在做RAG项目,建议按这个顺序优先实施:首先把分块策略改好,然后评估一下Embedding模型是否匹配你的语料,最后再引入混合检索。不要一开始就把所有优化都堆上去,先搞清楚瓶颈在哪,对症下药效果更好。
