1. 基于SpringAI和RAG搭建企业级知识库系统实战
作为一名长期从事企业级应用开发的工程师,我最近完成了一个基于SpringAI和RAG技术的知识库系统搭建项目。这个系统能够将企业内部的各类文档(PDF、Word、Excel等)转化为可智能问答的知识库,显著提升了企业内部知识检索的效率。下面我将从技术选型到实现细节,完整分享这个项目的实战经验。
2. 技术架构与核心组件
2.1 整体架构设计
我们的知识库系统采用经典的RAG(检索增强生成)架构,主要由以下几个核心组件构成:
- 文档处理流水线:负责将原始文档转换为适合检索的向量表示
- 向量数据库:存储和管理文档的向量表示
- 检索与生成模块:处理用户查询并生成回答
- 前端交互界面:提供用户友好的问答界面
code复制[用户提问] → [查询处理] → [向量检索] → [结果生成] → [返回回答]
↑ ↑
[查询扩展] [向量数据库]
↑
[文档处理流水线]
2.2 技术选型与考量
在选择具体技术栈时,我们主要考虑了以下几个因素:
- 与现有技术栈的兼容性:企业已有系统基于Java生态,因此选择SpringAI而非Python系方案
- 性能要求:需要支持高并发查询,因此选择了性能优异的Milvus作为向量数据库
- 开发效率:Spring生态的成熟度可以显著降低开发成本
- 可维护性:选择有活跃社区支持的开源技术
最终确定的技术栈如下:
- 核心框架:Spring Boot 3.x + SpringAI
- 向量数据库:Milvus(Docker部署)
- 大语言模型:阿里云通义千问(通过DashScope API接入)
- 文档解析:Apache Tika
- 前端:Vue.js + Element UI
提示:Milvus的选择特别重要,它的性能比同类产品如FAISS高出一个数量级,特别适合企业级应用场景。我们实测在百万级向量数据下,查询延迟仍能保持在50ms以内。
3. 核心实现细节
3.1 文档处理流水线实现
文档处理是RAG系统的关键环节,我们实现了完整的ETL(提取-转换-加载)流程:
java复制@Slf4j
@Service
public class KnowledgeServiceImpl implements KnowledgeService {
@Override
public void attachUpload(KnowledgeUploadVO uploadVO) throws Exception {
// 文件上传与元数据记录
String fileName = uploadVO.getFile().getOriginalFilename();
String attachUrl = fileService.uploadFile(uploadVO.getFile());
// 抽取(E) - 使用Tika解析文档内容
long sTime = System.currentTimeMillis();
TikaDocumentReader tikaReader = new TikaDocumentReader(uploadVO.getFile().getResource());
List<Document> docs = tikaReader.read();
// 转换(T) - 文本分块与增强
TokenTextSplitter splitter = new TokenTextSplitter(1000, 400, 10, 5000, true);
List<Document> documents = splitter.apply(docs);
// 加载(L) - 存储到向量数据库
Lists.partition(documents, 50).forEach(list -> vectorStore.add(list));
}
}
3.1.1 文档解析优化技巧
不同类型的文档需要采用不同的解析策略:
-
PDF文档:
- 使用PDFBox提取文本时,注意保留原始布局信息
- 对于扫描件PDF,需要先进行OCR处理
-
Word文档:
- 利用docx的XML结构提取标题层级
- 将文档转换为Markdown格式保留结构
-
Excel文件:
- 将每行数据转换为键值对格式
- 保留表头信息作为上下文
java复制// Excel处理示例
public List<Document> processExcel(Resource excelResource) {
List<Document> docs = new ArrayList<>();
Workbook workbook = WorkbookFactory.create(excelResource.getInputStream());
for (Sheet sheet : workbook) {
Row headerRow = sheet.getRow(0);
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
Map<String, Object> metadata = new HashMap<>();
StringBuilder content = new StringBuilder();
for (int j = 0; j < headerRow.getLastCellNum(); j++) {
String header = headerRow.getCell(j).getStringCellValue();
String value = row.getCell(j) != null ? row.getCell(j).toString() : "";
metadata.put(header, value);
content.append(header).append(": ").append(value).append("\n");
}
docs.add(new Document(content.toString(), metadata));
}
}
return docs;
}
3.2 文本分块策略
文本分块是影响检索效果的关键因素。我们采用了多级分块策略:
- 第一级分块:按文档结构(如Markdown标题)进行大块划分(约1000token)
- 第二级分块:在大块内按语义进行细粒度划分(约200token)
- 分块重叠:设置10%的重叠区域避免信息割裂
java复制// 自定义Markdown分块器
public class MarkdownTextSplitter implements Function<List<Document>, List<Document>> {
private final int chunkSize;
private final int chunkOverlap;
public List<Document> apply(List<Document> documents) {
List<Document> result = new ArrayList<>();
for (Document doc : documents) {
String markdown = doc.getContent();
// 按##标题划分大块
String[] sections = markdown.split("(?=^##\\s)");
for (String section : sections) {
// 在大块内按段落划分小块
String[] paragraphs = section.split("\n\n");
StringBuilder chunk = new StringBuilder();
for (String para : paragraphs) {
if (chunk.length() + para.length() > chunkSize) {
if (!chunk.isEmpty()) {
result.add(createChunkDocument(chunk.toString(), doc));
chunk = new StringBuilder(
chunk.substring(chunk.length() - chunkOverlap));
}
}
chunk.append(para).append("\n\n");
}
if (!chunk.isEmpty()) {
result.add(createChunkDocument(chunk.toString(), doc));
}
}
}
return result;
}
}
3.3 检索增强实现
检索环节我们实现了多种优化策略:
- 查询扩展:通过LLM对原始查询进行改写和扩展
- 混合检索:结合向量检索和关键词检索
- 元数据过滤:根据业务属性预先过滤文档
- 结果重排序:使用Re-rank模型对初步结果重新排序
java复制@RestController
@RequestMapping("/ai/knowledge")
public class KnowledgeController {
@Anonymous
@GetMapping(path = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam("userInput") String userInput,
@RequestParam("sessionId") Long sessionId) {
// 查询扩展
String expandedQuery = expandQuery(userInput, sessionId);
// 混合检索
List<Document> vectorResults = vectorStore.similaritySearch(
SearchRequest.query(expandedQuery)
.withTopK(10)
.withSimilarityThreshold(0.5));
List<Document> keywordResults = keywordSearch(expandedQuery);
// 结果融合与重排序
List<Document> finalResults = reRankResults(
mergeResults(vectorResults, keywordResults),
expandedQuery);
// 生成回答
return generateResponse(userInput, finalResults, sessionId);
}
private String expandQuery(String query, Long sessionId) {
// 使用LLM扩展查询
return chatClient.prompt()
.system("基于对话历史,将用户问题改写为3个更完整的查询语句")
.user("历史:" + getHistory(sessionId) + "\n问题:" + query)
.call().content();
}
}
4. 性能优化实战
4.1 响应式编程实践
在高并发场景下,阻塞IO操作会成为性能瓶颈。我们全面采用响应式编程模型:
java复制private void saveMessage(Long sessionId, MessageType messageType, String content) {
Mono.fromRunnable(() -> {
ChatSessionDetailEntity entity = new ChatSessionDetailEntity();
entity.setSessionId(sessionId);
entity.setRole(messageType.getValue());
entity.setContent(content);
chatSessionService.insertChatSessionDetail(entity);
})
.subscribeOn(Schedulers.boundedElastic()) // 使用弹性线程池
.onErrorResume(e -> {
log.error("保存消息失败", e);
return Mono.empty();
})
.subscribe(); // 异步执行
}
4.2 缓存策略
为减少对向量数据库的频繁访问,我们实现了多级缓存:
- 本地缓存:使用Caffeine缓存热门查询结果
- 分布式缓存:使用Redis缓存文档块内容
- 预加载缓存:系统启动时预加载高频访问文档
java复制@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(30, TimeUnit.MINUTES)
.maximumSize(1000));
return cacheManager;
}
}
@Service
public class KnowledgeCacheService {
@Cacheable(value = "documentChunks", key = "#docId")
public List<Document> getDocumentChunks(String docId) {
// 从向量数据库获取文档块
return vectorStore.search(
SearchRequest.query("")
.withFilterExpression("docId == '" + docId + "'")
.withTopK(100));
}
}
4.3 向量数据库优化
针对Milvus数据库,我们进行了以下优化:
- 索引优化:使用IVF_FLAT索引类型,nlist参数设置为1000
- 分区设计:按业务域进行数据分区
- 批量操作:文档入库采用批量接口
- 资源隔离:为查询和索引构建分配独立的资源
yaml复制# Milvus配置示例
milvus:
host: localhost
port: 19530
collection:
name: knowledge_base
index:
type: IVF_FLAT
metric: COSINE
params:
nlist: 1000
partition:
size: 100000
5. 踩坑经验与解决方案
5.1 维度不匹配问题
在初期集成时,我们遇到了嵌入模型输出维度与向量数据库配置不匹配的问题:
code复制错误:插入向量时维度不匹配(预期:1536,实际:1024)
解决方案:
- 统一使用相同维度的嵌入模型(如text-embedding-3-small)
- 在应用层添加维度校验逻辑
- 数据库集合创建时明确指定维度
java复制// 维度校验工具类
public class EmbeddingDimensionValidator {
private static final Map<String, Integer> MODEL_DIMENSIONS = Map.of(
"text-embedding-3-small", 1536,
"text-embedding-3-large", 3072,
"text-embedding-ada-002", 1536
);
public static void validate(String modelName, int expected) {
int actual = MODEL_DIMENSIONS.getOrDefault(modelName, -1);
if (actual != expected) {
throw new IllegalArgumentException(
String.format("维度不匹配: 模型%s的维度是%d,但数据库预期%d",
modelName, actual, expected));
}
}
}
5.2 文档解析乱码问题
处理中文PDF时经常出现乱码,特别是扫描件PDF。我们通过以下方式解决:
- 优先尝试提取嵌入式文本
- 失败时自动回退到OCR处理
- 对结果进行字符集检测和转换
java复制public String extractTextFromPdf(Resource pdfResource) {
try (PDDocument document = PDDocument.load(pdfResource.getInputStream())) {
// 尝试提取文本内容
PDFTextStripper stripper = new PDFTextStripper();
String text = stripper.getText(document);
// 检查文本质量
if (isLowQualityText(text)) {
// 回退到OCR处理
text = ocrProcessor.process(pdfResource);
}
// 字符集转换
return CharsetHelper.convertToUtf8(text);
}
}
private boolean isLowQualityText(String text) {
// 检查非字符比例
long nonCharCount = text.chars()
.filter(c -> !Character.isLetterOrDigit(c) && !Character.isWhitespace(c))
.count();
float ratio = (float) nonCharCount / text.length();
return ratio > 0.3 || text.length() < 100;
}
5.3 检索结果不准确
初期检索经常返回不相关结果,我们通过以下改进显著提升了准确率:
- 查询改写:使用LLM对原始查询进行扩展
- 混合检索:结合向量搜索和关键词搜索
- 结果重排序:使用bge-reranker模型对结果重新排序
- 元数据过滤:根据业务属性预先过滤文档
java复制public List<Document> hybridSearch(String query, Map<String, Object> filters) {
// 向量检索
List<Document> vectorResults = vectorStore.similaritySearch(
SearchRequest.query(query)
.withTopK(20)
.withSimilarityThreshold(0.5)
.withFilterExpression(buildFilter(filters)));
// 关键词检索
List<Document> keywordResults = keywordSearch(query, filters);
// 结果融合与去重
List<Document> merged = mergeResults(vectorResults, keywordResults);
// 重排序
return rerank(merged, query);
}
private String buildFilter(Map<String, Object> filters) {
return filters.entrySet().stream()
.map(e -> e.getKey() + " == '" + e.getValue() + "'")
.collect(Collectors.joining(" && "));
}
6. 系统部署与监控
6.1 容器化部署
我们使用Docker Compose部署整个系统:
yaml复制version: '3.8'
services:
milvus:
image: milvusdb/milvus:v2.3.0
ports:
- "19530:19530"
volumes:
- milvus_data:/var/lib/milvus
environment:
- ETCD_ENABLED=true
- MINIO_ENABLED=true
knowledge-api:
image: knowledge-api:1.0.0
ports:
- "8080:8080"
depends_on:
- milvus
environment:
- MILVUS_HOST=milvus
- SPRING_PROFILES_ACTIVE=prod
volumes:
milvus_data:
6.2 监控配置
为了确保系统稳定运行,我们实现了全面的监控:
- 应用指标:使用Micrometer暴露Spring Boot指标
- 业务指标:自定义RAG相关指标(如检索耗时、命中率等)
- 日志收集:使用ELK栈集中管理日志
- 告警规则:设置关键指标阈值告警
java复制@Configuration
public class MetricsConfig {
@Bean
public MeterRegistryCustomizer<PrometheusMeterRegistry> metricsCustomizer() {
return registry -> {
// RAG特定指标
registry.gauge("rag.embedding.queue.size",
Tags.of("model", "text-embedding-3-small"),
queueSize);
registry.timer("rag.retrieval.latency",
Tags.of("type", "vector"))
.recordCallable(() -> vectorSearch(query));
};
}
@Bean
public CustomMetrics customMetrics(MeterRegistry registry) {
return new CustomMetrics(registry);
}
}
@Component
public class CustomMetrics {
private final Counter retrievalCounter;
public CustomMetrics(MeterRegistry registry) {
this.retrievalCounter = Counter.builder("rag.retrieval.count")
.description("Total number of retrieval operations")
.tag("type", "vector")
.register(registry);
}
public void incrementRetrievalCount() {
retrievalCounter.increment();
}
}
7. 项目演进方向
目前系统已经稳定运行,但我们仍在持续优化:
- 多模态支持:扩展支持图片、表格等非文本内容
- 增量更新:实现文档内容的增量同步
- 个性化检索:基于用户画像优化检索结果
- 智能路由:根据查询类型自动选择最合适的检索策略
一个正在开发中的智能路由示例:
java复制public SearchStrategy selectStrategy(String query) {
// 分析查询类型
QueryType type = queryAnalyzer.analyze(query);
switch (type) {
case FACT:
return new ExactMatchStrategy();
case SEMANTIC:
return new VectorSearchStrategy();
case COMPLEX:
return new MultiHopStrategy();
default:
return new HybridSearchStrategy();
}
}
在实际部署这个系统的过程中,我发现企业级知识库系统的建设不仅仅是技术问题,更需要关注以下方面:
- 文档质量:垃圾进垃圾出,原始文档的质量直接影响最终效果
- 领域适配:通用嵌入模型在专业领域表现不佳,需要微调或替换
- 用户教育:需要训练用户提出明确的问题
- 持续迭代:建立反馈循环不断优化系统
最后分享一个实用技巧:在处理大型文档库时,可以先对文档进行聚类分析,将相似文档分组处理,这能显著提升后续检索的准确性和效率。我们使用K-Means算法对文档向量进行聚类,效果非常显著。
