企业AI助手从0到1(二):知识库构建与RAG实现
企业AI助手从0到1(二):知识库构建与RAG实现
开篇故事:知识库上线了,AI还是在乱说
知识库第一版上线的那天,陈建国发来了一张截图。
用户问:"年假可以分批请吗?"
AI的回答是:"根据公司规定,年假可以分批请假,最多分3次使用。"
问题在于:公司从来没有规定"最多分3次"这个限制。 这是AI编的。
陈建国当时就懵了。他把200份HR文档都上传了,为什么AI还是在乱说?
我帮他排查了一下,发现了3个问题:
问题1:文档质量太差。 上传的200份文档里,有60份是扫描版PDF,OCR识别错误率高达15%,"3次"这个数字很可能是从某个错误识别的文档里检索出来的。
问题2:分块策略不对。 员工手册第5章第3节"年假政策"被切割成了6个碎片,每个碎片都不完整,AI拼接碎片信息时出了错。
问题3:相似度阈值太低。 top_k=10,阈值0.5,AI把质量很低的文档片段也纳入了上下文,噪音太多。
这3个问题,是知识库项目90%翻车案例的根因。
今天这篇文章,我们把知识库从源头到检索,完整做一遍,每个环节都有生产级代码。
一、知识源梳理:哪些文档有价值
1.1 文档价值评估矩阵
不是所有文档都该进知识库。陈建国最初的错误就是"把能找到的文档全上传了"。
文档评估标准(5分制):
权威性(官方发布 vs 个人整理):1-5分
时效性(6个月内 vs 1年以上未更新):1-5分
可读性(结构清晰 vs 扫描版/乱码):1-5分
查询频率(高频被问到 vs 从没人问):1-5分
总分 ≥ 15分:优先纳入
总分 10-14分:处理后纳入
总分 < 10分:暂不纳入,标注原因陈建国项目文档梳理结果:
| 文档类别 | 数量 | 入库数量 | 主要排除原因 |
|---|---|---|---|
| HR政策文件 | 45份 | 38份 | 7份过期(2年以上未更新) |
| 员工手册 | 3份 | 3份(处理后) | 需OCR转换 |
| 设备操作手册 | 120份 | 32份 | 88份仅与生产部相关,暂缓 |
| 流程规范 | 30份 | 25份 | 5份已被新版本替代 |
| 培训材料 | 50份 | 0份 | 主要是PPT,结构不适合RAG |
实际入库:98份文档,去掉了102份噪音文档。
1.2 文档类型支持清单
// 支持的文档类型(优先级顺序)
优先支持:
.pdf → Apache Tika解析,注意扫描版需OCR
.docx → Apache POI解析,最佳效果
.txt → 直接读取,最简单
.md → Markdown解析器处理
部分支持:
.xlsx → 表格类文档,分块效果差,需特殊处理
.pptx → 演示文档,结构散乱,建议先人工整理为Word
暂不支持:
扫描版PDF(需先过OCR流水线)
图片中的文字(需视觉模型提取)
视频字幕(需先转文字)二、文档预处理流水线
2.1 流水线整体架构
2.2 文档解析器
// service/DocumentParserService.java
package com.enterprise.aiassistant.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.ExtractedTextFormatter;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class DocumentParserService {
/**
* 解析文档,返回Spring AI Document列表
* Spring AI Document包含:content(文本) + metadata(元数据)
*/
public List<Document> parse(Resource resource, String fileType) {
return switch (fileType.toLowerCase()) {
case "pdf" -> parsePdf(resource);
case "docx", "doc", "pptx", "xlsx" -> parseTika(resource);
case "txt", "md" -> parsePlainText(resource);
default -> {
log.warn("不支持的文件类型: {}, 尝试用Tika解析", fileType);
yield parseTika(resource);
}
};
}
private List<Document> parsePdf(Resource resource) {
try {
var config = PdfDocumentReaderConfig.builder()
.withPageExtractedTextFormatter(
ExtractedTextFormatter.builder()
.withNumberOfBottomTextLinesToDelete(3) // 删除页脚
.withNumberOfTopTextLinesToDelete(2) // 删除页眉
.build()
)
.withPagesPerDocument(1) // 每页作为一个Document
.build();
var reader = new PagePdfDocumentReader(resource, config);
List<Document> docs = reader.get();
log.info("PDF解析完成: {} 页", docs.size());
return docs;
} catch (Exception e) {
log.error("PDF解析失败: {}", e.getMessage(), e);
throw new RuntimeException("PDF解析失败: " + e.getMessage(), e);
}
}
private List<Document> parseTika(Resource resource) {
try {
var reader = new TikaDocumentReader(resource);
List<Document> docs = reader.get();
log.info("Tika解析完成: {} 段", docs.size());
return docs;
} catch (Exception e) {
log.error("Tika解析失败: {}", e.getMessage(), e);
throw new RuntimeException("Tika解析失败: " + e.getMessage(), e);
}
}
private List<Document> parsePlainText(Resource resource) {
try {
String content = new String(resource.getInputStream().readAllBytes());
return List.of(new Document(content));
} catch (Exception e) {
throw new RuntimeException("文本文件解析失败", e);
}
}
}2.3 文本清洗器
// service/TextCleaningService.java
package com.enterprise.aiassistant.service;
import org.springframework.stereotype.Service;
import java.util.regex.Pattern;
@Service
public class TextCleaningService {
// 常见噪音模式
private static final Pattern PAGE_NUMBER = Pattern.compile("^\\s*\\d+\\s*$", Pattern.MULTILINE);
private static final Pattern WATERMARK = Pattern.compile("(?i)(?:机密|confidential|draft|草稿)\\s*[\\r\\n]");
private static final Pattern MULTI_SPACES = Pattern.compile("[ \\t]+");
private static final Pattern MULTI_NEWLINES = Pattern.compile("[\\r\\n]{3,}");
private static final Pattern SPECIAL_CHARS = Pattern.compile("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]");
/**
* 清洗文本
*/
public String clean(String raw) {
if (raw == null || raw.isBlank()) {
return "";
}
String text = raw;
// 1. 删除特殊控制字符
text = SPECIAL_CHARS.matcher(text).replaceAll("");
// 2. 删除水印行
text = WATERMARK.matcher(text).replaceAll("");
// 3. 删除纯数字行(页码)
text = PAGE_NUMBER.matcher(text).replaceAll("");
// 4. 合并多余空格
text = MULTI_SPACES.matcher(text).replaceAll(" ");
// 5. 合并多余空行(最多保留2个换行)
text = MULTI_NEWLINES.matcher(text).replaceAll("\n\n");
// 6. 全角字符转半角(中文文档常见问题)
text = fullWidthToHalfWidth(text);
return text.strip();
}
/**
* 全角转半角(处理日文/中文文档的特殊字符)
*/
private String fullWidthToHalfWidth(String text) {
StringBuilder sb = new StringBuilder(text.length());
for (char c : text.toCharArray()) {
if (c >= 0xFF01 && c <= 0xFF5E) {
// 全角字母数字符号 → 半角
sb.append((char) (c - 0xFEE0));
} else if (c == 0x3000) {
// 全角空格 → 半角
sb.append(' ');
} else {
sb.append(c);
}
}
return sb.toString();
}
/**
* 评估文本质量
* @return 0-1.0, 低于0.5建议人工review
*/
public double assessQuality(String text) {
if (text == null || text.isBlank()) return 0.0;
double score = 1.0;
// 中文字符比例(太低说明可能是乱码)
long chineseChars = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
double chineseRatio = (double) chineseChars / text.length();
if (chineseRatio < 0.1 && text.length() > 100) {
// 中文文档但中文字符太少,可能是OCR乱码
score -= 0.3;
}
// 特殊字符比例(太高说明解析出了问题)
long specialChars = text.chars()
.filter(c -> (c >= 0 && c <= 31) || c == 65533) // 乱码字符
.count();
if ((double) specialChars / text.length() > 0.02) {
score -= 0.3;
}
// 文本长度(太短可能是页眉/页脚碎片)
if (text.trim().length() < 50) {
score -= 0.2;
}
return Math.max(0, score);
}
}2.4 智能分块策略
// service/DocumentChunkingService.java
package com.enterprise.aiassistant.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
public class DocumentChunkingService {
@Value("${app.knowledge.chunk-size:512}")
private int chunkSize;
@Value("${app.knowledge.chunk-overlap:50}")
private int chunkOverlap;
/**
* 对文档列表进行分块
*/
public List<Document> chunk(List<Document> documents, Map<String, Object> baseMetadata) {
var splitter = TokenTextSplitter.builder()
.withChunkSize(chunkSize)
.withMinChunkSizeChars(50) // 最小块大小:50字符(避免碎片)
.withMinChunkLengthToEmbed(30) // 低于30字符的块丢弃
.withMaxNumChunks(10000)
.withKeepSeparator(true) // 保留分隔符,有助于上下文理解
.build();
List<Document> allChunks = new ArrayList<>();
int totalChunks = 0;
for (Document doc : documents) {
if (doc.getText() == null || doc.getText().isBlank()) {
continue;
}
// 合并基础元数据
doc.getMetadata().putAll(baseMetadata);
List<Document> chunks = splitter.apply(List.of(doc));
// 为每个块添加位置信息
for (int i = 0; i < chunks.size(); i++) {
Document chunk = chunks.get(i);
chunk.getMetadata().put("chunk_index", totalChunks + i);
chunk.getMetadata().put("chunk_total", chunks.size());
// 质量过滤:过短或内容无意义的块丢弃
String text = chunk.getText();
if (text != null && text.trim().length() >= 30) {
allChunks.add(chunk);
}
}
totalChunks += chunks.size();
}
log.info("分块完成: {} 个原始文档 → {} 个有效块", documents.size(), allChunks.size());
return allChunks;
}
}三、向量化与存储:批量嵌入的实现
3.1 批量向量化核心服务
// service/EmbeddingIngestionService.java
package com.enterprise.aiassistant.service;
import com.enterprise.aiassistant.entity.Document;
import com.enterprise.aiassistant.repository.DocumentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.DocumentWriter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
@Slf4j
@Service
@RequiredArgsConstructor
public class EmbeddingIngestionService {
private final VectorStore vectorStore;
private final DocumentRepository documentRepository;
private final DocumentParserService parserService;
private final TextCleaningService cleaningService;
private final DocumentChunkingService chunkingService;
private final MinioService minioService;
// OpenAI embedding API有批量限制(每批最多2048个token,建议每批20个块)
@Value("${app.knowledge.embedding-batch-size:20}")
private int batchSize;
/**
* 异步处理文档(上传后台任务)
*/
@Async("documentProcessingExecutor")
public CompletableFuture<Void> processDocumentAsync(Long documentId) {
try {
processDocument(documentId);
} catch (Exception e) {
log.error("文档处理失败: docId={}, error={}", documentId, e.getMessage(), e);
documentRepository.updateStatus(documentId, "failed", e.getMessage());
}
return CompletableFuture.completedFuture(null);
}
@Transactional
public void processDocument(Long documentId) {
Document doc = documentRepository.findById(documentId)
.orElseThrow(() -> new RuntimeException("文档不存在: " + documentId));
log.info("开始处理文档: id={}, title={}", documentId, doc.getTitle());
documentRepository.updateStatus(documentId, "processing", null);
// 1. 从MinIO下载文档
var resource = minioService.downloadAsResource(doc.getFilePath());
// 2. 解析文档
var parsedDocs = parserService.parse(resource, doc.getFileType());
log.info("文档解析完成: {} 段落", parsedDocs.size());
// 3. 文本清洗
var cleanedDocs = parsedDocs.stream()
.map(d -> {
String cleaned = cleaningService.clean(d.getText());
double quality = cleaningService.assessQuality(cleaned);
if (quality < 0.5) {
log.warn("低质量文本段落,跳过: quality={:.2f}, preview={}",
quality, cleaned.substring(0, Math.min(50, cleaned.length())));
return null;
}
d.getMetadata().put("text_quality", quality);
return new org.springframework.ai.document.Document(cleaned, d.getMetadata());
})
.filter(d -> d != null)
.toList();
// 4. 分块
var baseMetadata = java.util.Map.of(
"doc_id", documentId.toString(),
"doc_title", doc.getTitle(),
"kb_id", doc.getKnowledgeBase().getId().toString(),
"department", doc.getKnowledgeBase().getDepartment() != null
? doc.getKnowledgeBase().getDepartment() : "all",
"access_level", doc.getKnowledgeBase().getAccessLevel()
);
var chunks = chunkingService.chunk(cleanedDocs, baseMetadata);
log.info("分块完成: {} 个有效块", chunks.size());
if (chunks.isEmpty()) {
throw new RuntimeException("文档解析后无有效内容,请检查文档格式");
}
// 5. 批量向量化并存储
int successCount = batchEmbedAndStore(chunks);
log.info("向量化完成: {}/{} 个块存储成功", successCount, chunks.size());
// 6. 更新文档状态
documentRepository.updateStatusAndChunkCount(documentId, "active", successCount);
log.info("文档处理完成: id={}", documentId);
}
/**
* 批量向量化并存储
* 分批处理,避免超过API限制和内存压力
*/
private int batchEmbedAndStore(List<org.springframework.ai.document.Document> chunks) {
AtomicInteger successCount = new AtomicInteger(0);
// 分批处理
List<List<org.springframework.ai.document.Document>> batches = partition(chunks, batchSize);
log.info("分 {} 批处理,每批 {} 个块", batches.size(), batchSize);
for (int i = 0; i < batches.size(); i++) {
List<org.springframework.ai.document.Document> batch = batches.get(i);
try {
// Spring AI VectorStore.add() 内部会调用EmbeddingModel批量生成向量
vectorStore.add(batch);
successCount.addAndGet(batch.size());
log.debug("批次 {}/{} 完成: {} 个块", i + 1, batches.size(), batch.size());
// 避免API限流
if (i < batches.size() - 1) {
Thread.sleep(100);
}
} catch (Exception e) {
log.error("批次 {} 向量化失败: {}", i + 1, e.getMessage(), e);
// 继续处理其他批次,不中断整体流程
}
}
return successCount.get();
}
private <T> List<List<T>> partition(List<T> list, int size) {
List<List<T>> partitions = new ArrayList<>();
for (int i = 0; i < list.size(); i += size) {
partitions.add(list.subList(i, Math.min(i + size, list.size())));
}
return partitions;
}
}3.2 线程池配置
// config/AsyncConfig.java
package com.enterprise.aiassistant.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class AsyncConfig {
/**
* 文档处理线程池
* 文档处理是IO密集型(等待LLM API),适合多线程并发
*/
@Bean("documentProcessingExecutor")
public Executor documentProcessingExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4); // 核心4线程
executor.setMaxPoolSize(8); // 最大8线程
executor.setQueueCapacity(100); // 队列100
executor.setThreadNamePrefix("doc-processor-");
executor.setRejectedExecutionHandler((r, pool) -> {
throw new RuntimeException("文档处理队列已满,请稍后重试");
});
executor.initialize();
return executor;
}
}四、元数据设计
4.1 元数据字段规范
// 每个chunk的元数据结构(存入PGVector的metadata字段)
{
// 文档来源
"doc_id": "123", // 文档ID
"doc_title": "员工手册2025版", // 文档标题
"file_type": "pdf", // 文件类型
// 知识库信息
"kb_id": "1", // 知识库ID
"department": "hr", // 所属部门(用于权限过滤)
"access_level": "public", // 访问级别
// 位置信息
"chunk_index": 5, // 块在文档中的序号
"chunk_total": 32, // 文档总块数
"page_number": 3, // 原文页码(PDF)
// 质量信息
"text_quality": 0.95, // 文本质量分数
// 时间信息
"doc_updated_at": "2025-01-15" // 文档最后更新时间
}4.2 元数据过滤检索
// service/KnowledgeRetrievalService.java
package com.enterprise.aiassistant.service;
import com.enterprise.aiassistant.entity.User;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class KnowledgeRetrievalService {
private final VectorStore vectorStore;
@Value("${app.knowledge.top-k:5}")
private int defaultTopK;
@Value("${app.knowledge.similarity-threshold:0.7}")
private double similarityThreshold;
/**
* 检索相关文档片段
* 自动根据用户权限过滤
*/
public List<Document> retrieve(String query, User user, Long kbId) {
return retrieve(query, user, kbId, defaultTopK);
}
public List<Document> retrieve(String query, User user, Long kbId, int topK) {
// 构建元数据过滤条件
var filterBuilder = new FilterExpressionBuilder();
// 权限过滤:只返回用户有权限的文档
var accessFilter = buildAccessFilter(filterBuilder, user);
// 知识库过滤(如果指定了知识库)
if (kbId != null) {
var kbFilter = filterBuilder.eq("kb_id", kbId.toString());
accessFilter = filterBuilder.and(accessFilter, kbFilter);
}
var searchRequest = SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThreshold(similarityThreshold)
.filterExpression(accessFilter)
.build();
List<Document> results = vectorStore.similaritySearch(searchRequest);
log.debug("检索完成: query='{}', results={}, threshold={}",
query.substring(0, Math.min(20, query.length())),
results.size(), similarityThreshold);
return results;
}
/**
* 基于用户角色和部门构建访问过滤条件
*/
private Object buildAccessFilter(FilterExpressionBuilder b, User user) {
if ("admin".equals(user.getRole())) {
// 管理员可以看所有文档
return b.gte("text_quality", 0.5); // 只过滤极低质量文档
}
// 普通员工:可以看public文档 + 自己部门的dept文档
var publicFilter = b.eq("access_level", "public");
if (user.getDepartment() != null && !user.getDepartment().isBlank()) {
var deptFilter = b.and(
b.eq("access_level", "department"),
b.eq("department", user.getDepartment())
);
return b.or(publicFilter, deptFilter);
}
return publicFilter;
}
}五、检索调优:找到最优的top_k和相似度阈值
5.1 调优实验框架
// test/RetrievalTuningTest.java
package com.enterprise.aiassistant.test;
import com.enterprise.aiassistant.service.KnowledgeRetrievalService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* 检索调优工具
* 运行方式:spring.profiles.active=tuning
*/
@Slf4j
@Component
@Profile("tuning")
@RequiredArgsConstructor
public class RetrievalTuningTest implements CommandLineRunner {
private final KnowledgeRetrievalService retrievalService;
// 标准测试集:问题 → 期望包含的关键词
private static final List<Map.Entry<String, String>> TEST_CASES = List.of(
Map.entry("年假怎么申请", "申请流程"),
Map.entry("病假需要什么证明", "医院证明"),
Map.entry("迟到扣多少钱", "扣款标准"),
Map.entry("生育假有多少天", "98天"),
Map.entry("报销流程是什么", "OA系统")
);
@Override
public void run(String... args) {
System.out.println("=== 检索参数调优报告 ===\n");
// 测试不同topK
System.out.println("--- top_k 影响分析 ---");
for (int topK : new int[]{3, 5, 8, 10}) {
double hitRate = evaluateTopK(topK, 0.7);
System.out.printf("top_k=%d, threshold=0.7 → 命中率: %.1f%%\n", topK, hitRate * 100);
}
// 测试不同阈值
System.out.println("\n--- 相似度阈值 影响分析 ---");
for (double threshold : new double[]{0.5, 0.6, 0.7, 0.75, 0.8}) {
double hitRate = evaluateTopK(5, threshold);
System.out.printf("top_k=5, threshold=%.2f → 命中率: %.1f%%\n", threshold, hitRate * 100);
}
}
private double evaluateTopK(int topK, double threshold) {
int hits = 0;
for (var testCase : TEST_CASES) {
// 简化:实际需要传入真实User对象
// var results = retrievalService.retrieve(testCase.getKey(), mockUser, null, topK);
// boolean hit = results.stream().anyMatch(d ->
// d.getText().contains(testCase.getValue()));
// if (hit) hits++;
}
return (double) hits / TEST_CASES.size();
}
}5.2 陈建国项目的调优结果
经过3轮调优实验(共测试30个标准问题),最终确定的参数:
| 参数 | 初始值 | 调优后 | 命中率变化 |
|---|---|---|---|
| top_k | 10 | 5 | 73% → 87% |
| 相似度阈值 | 0.5 | 0.72 | 73% → 87% |
| 分块大小 | 1024 token | 512 token | 78% → 87% |
| 分块重叠 | 0 | 50 token | 82% → 87% |
核心发现:top_k太大会引入噪音;阈值太低会引入低质量片段。宁可"找不到"也不能"找错了"。
六、知识库管理后台接口
6.1 文档管理控制器
// controller/KnowledgeBaseController.java
package com.enterprise.aiassistant.controller;
import com.enterprise.aiassistant.dto.request.KnowledgeBaseCreateRequest;
import com.enterprise.aiassistant.dto.response.ApiResponse;
import com.enterprise.aiassistant.dto.response.DocumentResponse;
import com.enterprise.aiassistant.entity.User;
import com.enterprise.aiassistant.service.KnowledgeBaseService;
import com.enterprise.aiassistant.service.EmbeddingIngestionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@RestController
@RequestMapping("/api/knowledge-bases")
@RequiredArgsConstructor
public class KnowledgeBaseController {
private final KnowledgeBaseService kbService;
private final EmbeddingIngestionService ingestionService;
/**
* 查询用户有权限的知识库列表
*/
@GetMapping
public ApiResponse<List<?>> listKnowledgeBases(
@AuthenticationPrincipal User user) {
return ApiResponse.success(kbService.listAccessible(user));
}
/**
* 创建知识库(仅管理员)
*/
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<?> createKnowledgeBase(
@Valid @RequestBody KnowledgeBaseCreateRequest request,
@AuthenticationPrincipal User user) {
return ApiResponse.success(kbService.create(request, user));
}
/**
* 上传文档到知识库
*/
@PostMapping(value = "/{kbId}/documents",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<DocumentResponse> uploadDocument(
@PathVariable Long kbId,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "title", required = false) String title,
@AuthenticationPrincipal User user) {
// 1. 验证文件
validateUploadFile(file);
// 2. 保存文档记录并上传到MinIO
DocumentResponse docResponse = kbService.saveDocument(kbId, file, title, user);
// 3. 异步处理(向量化)
ingestionService.processDocumentAsync(docResponse.getId());
return ApiResponse.success(docResponse);
}
/**
* 查询知识库文档列表
*/
@GetMapping("/{kbId}/documents")
public ApiResponse<List<DocumentResponse>> listDocuments(
@PathVariable Long kbId,
@AuthenticationPrincipal User user,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.success(kbService.listDocuments(kbId, user, page, size));
}
/**
* 删除文档(同时删除向量数据)
*/
@DeleteMapping("/{kbId}/documents/{docId}")
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<Void> deleteDocument(
@PathVariable Long kbId,
@PathVariable Long docId) {
kbService.deleteDocument(kbId, docId);
return ApiResponse.success(null);
}
/**
* 重新处理文档(用于修复失败的文档)
*/
@PostMapping("/{kbId}/documents/{docId}/reprocess")
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<Void> reprocessDocument(
@PathVariable Long kbId,
@PathVariable Long docId) {
ingestionService.processDocumentAsync(docId);
return ApiResponse.success(null);
}
private void validateUploadFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("文件不能为空");
}
long maxSize = 50 * 1024 * 1024L; // 50MB
if (file.getSize() > maxSize) {
throw new IllegalArgumentException("文件大小不能超过50MB");
}
String filename = file.getOriginalFilename();
if (filename == null || !filename.matches(".*\\.(pdf|docx?|txt|md|xlsx?)$")) {
throw new IllegalArgumentException("不支持的文件类型,支持:pdf/doc/docx/txt/md/xls/xlsx");
}
}
}七、知识库质量监控
7.1 未命中查询分析
// service/KnowledgeQualityService.java
package com.enterprise.aiassistant.service;
import com.enterprise.aiassistant.repository.MessageRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class KnowledgeQualityService {
private final MessageRepository messageRepository;
/**
* 每天凌晨2点分析未命中查询
*/
@Scheduled(cron = "0 0 2 * * ?")
public void analyzeMissedQueries() {
// 查询检索结果为空的消息(retrieved_chunks为空列表)
var yesterday = LocalDate.now().minusDays(1);
List<String> missedQueries = messageRepository.findMissedQueries(yesterday);
if (missedQueries.isEmpty()) {
log.info("昨日无未命中查询");
return;
}
// 按关键词聚类(简单词频分析)
Map<String, Long> keywordFreq = missedQueries.stream()
.flatMap(q -> extractKeywords(q).stream())
.collect(Collectors.groupingBy(k -> k, Collectors.counting()));
// 输出TOP10未覆盖主题
log.info("=== 昨日未命中查询分析 ===");
log.info("总计 {} 个未命中问题", missedQueries.size());
keywordFreq.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(10)
.forEach(e -> log.info(" 关键词: {} ({}次)", e.getKey(), e.getValue()));
// 生成待补充知识清单(存入数据库,供运营人员处理)
saveKnowledgeGapReport(yesterday, missedQueries, keywordFreq);
}
private List<String> extractKeywords(String query) {
// 简单实现:按2-4字的中文词组切分
// 生产环境可以用HanLP/jieba分词
return List.of(query.replaceAll("[,。?!、]", " ").split("\\s+"))
.stream()
.filter(w -> w.length() >= 2)
.toList();
}
private void saveKnowledgeGapReport(LocalDate date,
List<String> queries,
Map<String, Long> keywords) {
// 存入数据库,供管理员在后台查看
log.info("知识缺口报告已保存: {} 个问题待补充", queries.size());
}
/**
* 计算知识库覆盖率(每周统计)
*/
@Scheduled(cron = "0 0 3 ? * MON")
public void calculateCoverageRate() {
var lastWeekStart = LocalDate.now().minusWeeks(1);
var lastWeekEnd = LocalDate.now().minusDays(1);
long totalQueries = messageRepository.countByDateRange(lastWeekStart, lastWeekEnd);
long missedQueries = messageRepository.countMissedByDateRange(lastWeekStart, lastWeekEnd);
long negativeQueries = messageRepository.countByFeedback("dislike", lastWeekStart, lastWeekEnd);
if (totalQueries == 0) return;
double coverageRate = 1.0 - (double) missedQueries / totalQueries;
double satisfactionRate = 1.0 - (double) negativeQueries / totalQueries;
log.info("=== 上周知识库质量报告 ===");
log.info("查询总量: {}", totalQueries);
log.info("覆盖率: {:.1f}% (未命中{}个)", coverageRate * 100, missedQueries);
log.info("满意度: {:.1f}% (差评{}个)", satisfactionRate * 100, negativeQueries);
}
}八、增量更新:新文档实时加入知识库
8.1 增量更新策略
// service/IncrementalUpdateService.java
package com.enterprise.aiassistant.service;
import com.enterprise.aiassistant.entity.Document;
import com.enterprise.aiassistant.repository.DocumentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class IncrementalUpdateService {
private final VectorStore vectorStore;
private final DocumentRepository documentRepository;
private final EmbeddingIngestionService ingestionService;
/**
* 更新文档:删除旧向量 + 重新向量化
*/
@Transactional
public void updateDocument(Long documentId) {
Document doc = documentRepository.findById(documentId)
.orElseThrow(() -> new RuntimeException("文档不存在: " + documentId));
// 1. 删除该文档所有旧的向量
deleteDocumentVectors(documentId);
// 2. 重置文档状态
documentRepository.updateStatus(documentId, "pending", null);
// 3. 重新异步处理
ingestionService.processDocumentAsync(documentId);
log.info("文档增量更新已触发: id={}, title={}", documentId, doc.getTitle());
}
/**
* 删除文档的所有向量数据
*/
public void deleteDocumentVectors(Long documentId) {
var filter = new FilterExpressionBuilder()
.eq("doc_id", documentId.toString());
// PGVector支持按filter删除
vectorStore.delete(filter.build().toString());
log.info("已删除文档向量: docId={}", documentId);
}
}九、效果验证框架
9.1 知识库评测标准集
// test/KnowledgeBaseEvaluationTest.java
package com.enterprise.aiassistant.test;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.Map;
@SpringBootTest
public class KnowledgeBaseEvaluationTest {
@Autowired
private KnowledgeRetrievalService retrievalService;
/**
* 标准测试集(问题+期望包含的答案关键词+期望文档标题)
*/
private static final List<EvalCase> EVAL_CASES = List.of(
new EvalCase("年假怎么申请", "员工手册", "申请流程", List.of("申请", "审批", "OA")),
new EvalCase("病假需要什么手续", "员工手册", "病假规定", List.of("医院", "证明", "天数")),
new EvalCase("迟到多久算旷工", "考勤制度", "旷工定义", List.of("30分钟", "旷工")),
new EvalCase("报销上限是多少", "费用报销制度", "报销限额", List.of("限额", "元")),
new EvalCase("五险一金比例", "薪酬制度", "社保比例", List.of("8%", "12%"))
);
@Test
public void evaluateRetrievalQuality() {
int hit = 0;
int total = EVAL_CASES.size();
for (EvalCase evalCase : EVAL_CASES) {
// TODO: 注入mock user
// var results = retrievalService.retrieve(evalCase.query(), mockUser, null);
// boolean isHit = results.stream().anyMatch(d ->
// evalCase.expectedKeywords().stream()
// .anyMatch(kw -> d.getText().contains(kw)));
// if (isHit) hit++;
}
double hitRate = (double) hit / total;
System.out.printf("知识库命中率: %.1f%% (%d/%d)\n", hitRate * 100, hit, total);
// 断言:命中率不低于80%
// assertTrue(hitRate >= 0.80, "知识库命中率低于80%,需要优化");
}
record EvalCase(String query, String expectedDocTitle,
String expectedSection, List<String> expectedKeywords) {}
}性能与效果数据
经过两轮优化后,陈建国项目的知识库关键指标:
| 指标 | 第一版(优化前) | 第二版(优化后) |
|---|---|---|
| 文档解析成功率 | 73%(扫描版失败多) | 95% |
| 分块质量分(平均) | 0.62 | 0.88 |
| 检索命中率(标准集) | 61% | 87% |
| AI幻觉率(差评/总量) | 18% | 4% |
| 向量化耗时(100份文档) | - | 约12分钟 |
| 平均检索延迟 | 280ms | 180ms |
最关键的发现:提升文档质量(从73%解析成功率到95%),比调整任何算法参数效果都大。
FAQ
Q1:文档处理失败了怎么排查?
A:查documents表的status和error_message字段,80%的失败原因在这里。常见问题:1)PDF是扫描版无法直接OCR;2)文档有密码保护;3)文件损坏。排查后重新上传,或调用/reprocess接口重试。
Q2:向量化太慢,有没有加速方案?
A:三个方向:1)并发处理:batchSize从20提高到50(注意API限流);2)换更快的Embedding模型,text-embedding-3-small比ada-002快30%;3)批量上传时间安排在夜间,不影响白天使用。
Q3:PGVector扩展安装失败怎么办?
A:使用pgvector/pgvector:pg16这个Docker镜像,已经预装好了,不需要手动安装。如果是自建PostgreSQL,需要手动执行CREATE EXTENSION vector;。
Q4:知识库里的文档更新了,如何同步?
A:调用PUT /api/knowledge-bases/{kbId}/documents/{docId}接口,系统会先删除旧向量,再重新处理。更新一份中等大小文档(50页)约需3-5分钟。
Q5:如何评估RAG的回答质量?
A:两个维度:1)检索召回率——问题的答案是否在top_k结果里(自动化测试);2)回答相关性——LLM根据检索结果给出的答案是否正确(人工评测或LLM-as-judge)。建议每月做一次评测,跟踪质量趋势。
补充:MinIO文档存储服务实现
// service/MinioService.java
package com.enterprise.aiassistant.service;
import io.minio.*;
import io.minio.errors.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class MinioService {
private final MinioClient minioClient;
@Value("${app.minio.bucket}")
private String bucketName;
/**
* 上传文件到MinIO
* @return 文件路径(用于后续下载)
*/
public String upload(MultipartFile file, String prefix) {
String filename = prefix + "/" + UUID.randomUUID()
+ getExtension(file.getOriginalFilename());
try {
// 确保bucket存在
boolean exists = minioClient.bucketExists(
BucketExistsArgs.builder().bucket(bucketName).build());
if (!exists) {
minioClient.makeBucket(
MakeBucketArgs.builder().bucket(bucketName).build());
}
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(filename)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
log.info("文件上传成功: {}", filename);
return filename;
} catch (Exception e) {
log.error("文件上传失败: {}", e.getMessage(), e);
throw new RuntimeException("文件上传失败: " + e.getMessage(), e);
}
}
/**
* 从MinIO下载文件为Resource
*/
public Resource downloadAsResource(String filePath) {
try {
InputStream inputStream = minioClient.getObject(
GetObjectArgs.builder()
.bucket(bucketName)
.object(filePath)
.build()
);
return new InputStreamResource(inputStream);
} catch (Exception e) {
log.error("文件下载失败: path={}, error={}", filePath, e.getMessage());
throw new RuntimeException("文件下载失败: " + e.getMessage(), e);
}
}
/**
* 删除文件
*/
public void delete(String filePath) {
try {
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(bucketName)
.object(filePath)
.build()
);
log.info("文件删除成功: {}", filePath);
} catch (Exception e) {
log.error("文件删除失败: path={}, error={}", filePath, e.getMessage());
}
}
private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) return "";
return filename.substring(filename.lastIndexOf('.'));
}
}// config/MinioConfig.java
package com.enterprise.aiassistant.config;
import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MinioConfig {
@Value("${app.minio.endpoint}")
private String endpoint;
@Value("${app.minio.access-key}")
private String accessKey;
@Value("${app.minio.secret-key}")
private String secretKey;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}系列预告
这是系列第二篇,我们完成了知识库构建的全流程:文档梳理、预处理流水线、批量向量化、元数据设计、检索调优、管理接口、质量监控。
下一篇(article-177),我们进入对话系统与权限管控。那个"普通员工问出了高管薪资"的故事,权限漏洞的代码我们会完整复现,然后告诉你正确的做法。
