第2042篇:LangChain4j的RAG管线——文档加载、切分、向量化全流程
大约 6 分钟
第2042篇:LangChain4j的RAG管线——文档加载、切分、向量化全流程
适读人群:需要在Java项目中实现RAG的工程师 | 阅读时长:约19分钟 | 核心价值:掌握LangChain4j的RAG全流程,从文档加载到检索增强生成的完整实现
我帮一个客户做内部知识库系统,要求是:把公司的Word文档、PDF、内部wiki,全部变成可以AI问答的知识库。
LangChain4j的RAG相关API设计得相当完整,但坑也不少。文档切分策略的选择、向量维度的一致性、检索时的相关性阈值——每一个都可能影响最终效果。
这篇文章把整个流程从头到尾走一遍。
RAG架构回顾
第一步:文档加载
@Service
@RequiredArgsConstructor
@Slf4j
public class DocumentIngestionService {
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> embeddingStore;
/**
* 加载文档:支持多种格式
*/
public int ingestDocuments(String directoryPath) {
List<Document> documents = new ArrayList<>();
Path dir = Paths.get(directoryPath);
try (var files = Files.walk(dir)) {
files.filter(Files::isRegularFile)
.forEach(filePath -> {
try {
Document doc = loadDocument(filePath);
if (doc != null) {
documents.add(doc);
}
} catch (Exception e) {
log.warn("文档加载失败: {}", filePath, e);
}
});
} catch (IOException e) {
throw new DocumentLoadException("遍历目录失败: " + directoryPath, e);
}
// 切分和向量化
return processDocuments(documents);
}
private Document loadDocument(Path filePath) throws IOException {
String fileName = filePath.toString().toLowerCase();
if (fileName.endsWith(".pdf")) {
return FileSystemDocumentLoader.loadDocument(filePath,
new ApachePdfBoxDocumentParser());
} else if (fileName.endsWith(".docx")) {
return FileSystemDocumentLoader.loadDocument(filePath,
new ApachePoiDocumentParser());
} else if (fileName.endsWith(".txt") || fileName.endsWith(".md")) {
return FileSystemDocumentLoader.loadDocument(filePath,
new TextDocumentParser());
} else if (fileName.endsWith(".html")) {
return FileSystemDocumentLoader.loadDocument(filePath,
new HtmlDocumentParser());
}
log.debug("不支持的文件类型,跳过: {}", filePath);
return null;
}
private int processDocuments(List<Document> documents) {
if (documents.isEmpty()) {
log.info("没有找到可处理的文档");
return 0;
}
// 文本切分
DocumentSplitter splitter = DocumentSplitters.recursive(
500, // 每块最大500字符
50, // 相邻块之间50字符的重叠
new ChineseTextSplitter() // 中文友好的分割器
);
List<TextSegment> segments = splitter.splitAll(documents);
log.info("文档切分完成: {}个文档 -> {}个片段", documents.size(), segments.size());
// 向量化并存储
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.build();
ingestor.ingest(segments);
log.info("向量化存储完成: {}个片段", segments.size());
return segments.size();
}
}第二步:文档切分策略选择
切分策略是RAG效果的关键,不同场景选不同策略:
@Configuration
public class SplitterConfig {
/**
* 通用文档:递归字符分割
* 适合:文章、报告、手册
*/
@Bean("generalSplitter")
public DocumentSplitter generalSplitter() {
return DocumentSplitters.recursive(
500, // chunk大小(字符数)
50 // overlap(重叠字符数,避免上下文在边界处断裂)
);
}
/**
* 结构化文档:按段落分割
* 适合:有清晰段落结构的文档(如帮助文档、FAQ)
*/
@Bean("structuredSplitter")
public DocumentSplitter structuredSplitter() {
return new DocumentByParagraphSplitter(
200, // 段落最大长度,超过就进一步拆分
20
);
}
/**
* 代码文档:按语义边界分割
* 适合:API文档、函数说明
*/
@Bean("codeSplitter")
public DocumentSplitter codeSplitter() {
return DocumentSplitters.recursive(
1000, // 代码通常需要更大的上下文
100, // 更多重叠,代码上下文依赖强
List.of("\n\n", "\n", " ") // 优先在空行处切分
);
}
}切分参数的影响:
| 参数 | 偏小的影响 | 偏大的影响 | 建议值 |
|---|---|---|---|
| chunk_size | 上下文不完整,检索到的信息碎片化 | 相关度变低,引入无关信息 | 400-600字符 |
| chunk_overlap | 上下文在边界处丢失 | 存储量增加,检索结果重复 | chunk_size的10% |
第三步:向量存储配置
@Configuration
public class VectorStoreConfig {
/**
* pgvector:PostgreSQL的向量扩展
* 适合:已有PostgreSQL,数据量中等(< 1000万向量)
*/
@Bean
public EmbeddingStore<TextSegment> pgVectorStore(DataSource dataSource) {
return PgVectorEmbeddingStore.builder()
.datasource(dataSource)
.table("document_embeddings")
.dimension(768) // nomic-embed-text是768维
.indexType(PgVectorEmbeddingStore.IndexType.HNSW) // 近似最近邻索引
.distanceType(PgVectorEmbeddingStore.DistanceType.COSINE_DISTANCE)
.build();
}
}-- 需要在PostgreSQL中执行的初始化SQL
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_embeddings (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
embedding vector(768) NOT NULL,
text TEXT NOT NULL,
metadata JSONB
);
-- 创建HNSW索引(查询速度快,适合在线服务)
CREATE INDEX ON document_embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);第四步:检索和增强生成
@Service
@RequiredArgsConstructor
public class RagQueryService {
private final ChatLanguageModel chatModel;
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> embeddingStore;
/**
* 基础RAG查询
*/
public String query(String userQuestion) {
// 检索相关文档
EmbeddingSearchRequest searchRequest = EmbeddingSearchRequest.builder()
.queryEmbedding(embeddingModel.embed(userQuestion))
.maxResults(5) // 返回最相似的5个片段
.minScore(0.7) // 相关度阈值(0.0-1.0)
.build();
EmbeddingSearchResult<TextSegment> searchResult =
embeddingStore.search(searchRequest);
List<EmbeddingMatch<TextSegment>> matches = searchResult.matches();
if (matches.isEmpty()) {
return "根据现有知识库,暂无相关信息。如需了解更多,请联系相关部门。";
}
// 构建上下文
String context = matches.stream()
.map(match -> {
TextSegment segment = match.embedded();
String source = (String) segment.metadata().get("file_name");
return String.format("[来源: %s]\n%s", source, segment.text());
})
.collect(Collectors.joining("\n\n---\n\n"));
// 组装增强Prompt
String augmentedPrompt = String.format("""
基于以下知识库内容回答用户问题。
如果知识库中没有相关信息,请明确告知。
知识库内容:
%s
用户问题:%s
""", context, userQuestion);
return chatModel.generate(augmentedPrompt).content().text();
}
/**
* AI Service风格的RAG(更优雅)
*/
@Bean
public KnowledgeBaseAssistant createKbAssistant(
ChatLanguageModel model,
EmbeddingModel embeddingModel,
EmbeddingStore<TextSegment> store) {
// 构建检索增强的内容检索器
ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(store)
.embeddingModel(embeddingModel)
.maxResults(5)
.minScore(0.7)
.build();
return AiServices.builder(KnowledgeBaseAssistant.class)
.chatLanguageModel(model)
.contentRetriever(retriever)
.chatMemoryProvider(userId -> MessageWindowChatMemory.withMaxMessages(10))
.build();
}
}
@AiService
interface KnowledgeBaseAssistant {
@SystemMessage("""
你是公司内部知识库助手。
基于提供的文档内容回答问题。
如果文档中没有相关信息,请说明。
""")
String ask(@MemoryId String userId, @UserMessage String question);
}常见的RAG质量问题排查
问题1:检索结果不相关
原因:embedding模型对中文的支持不足,或chunk切分不合理。
解决:换用中文专用embedding模型(如bge-large-zh),调整chunk_size和重叠比例。
问题2:相关文档存在但没有检索到
原因:minScore阈值设置过高。
解决:降低minScore到0.5-0.6,然后在代码层面对检索结果再过滤,或者用重排序(Reranking)模型做二次排序。
// 带重排序的高质量检索
@Service
@RequiredArgsConstructor
public class RerankingRetriever {
private final EmbeddingStore<TextSegment> store;
private final EmbeddingModel embeddingModel;
private final ChatClient rerankModel; // 用LLM对检索结果重排序
public List<TextSegment> retrieveWithReranking(String query, int topK) {
// 第一阶段:向量检索,宽松阈值,多取一些
EmbeddingSearchRequest searchRequest = EmbeddingSearchRequest.builder()
.queryEmbedding(embeddingModel.embed(query))
.maxResults(topK * 3) // 先取3倍数量
.minScore(0.5)
.build();
List<EmbeddingMatch<TextSegment>> candidates =
store.search(searchRequest).matches();
if (candidates.isEmpty()) return List.of();
// 第二阶段:LLM重排序
String rerankPrompt = String.format("""
以下是检索到的文档片段,请按照与查询"%s"的相关性排序,
从最相关到最不相关,输出片段编号(逗号分隔):
%s
""",
query,
IntStream.range(0, candidates.size())
.mapToObj(i -> "[" + i + "] " + candidates.get(i).embedded().text())
.collect(Collectors.joining("\n\n")));
String rankingResult = rerankModel.prompt().user(rerankPrompt).call().content();
// 解析排序结果,返回topK个
// 实际解析逻辑...
return candidates.stream()
.limit(topK)
.map(EmbeddingMatch::embedded)
.collect(Collectors.toList());
}
}RAG的核心挑战不在于技术实现,而在于调整各个参数直到效果满意:切分策略、chunk大小、检索数量、相关度阈值,每个参数都需要根据实际文档和查询特点来调整。
建议先建立一个小规模的评估集(50-100个问题+标准答案),用于量化不同参数配置下的检索效果。
