我们需要把一份PDF最终转化成向量数据库中的向量,需要经过以下步骤: 1、pdf文件上传 2、pdf文件存储在minio 3、将文件解析成markdown 4、基于markdown做分块 5、将分块后的数据做embedding 6、将向量和文本块保存在向量数据库 上面整个过程其实还是比较长的,所以需要引入异步方式来处理整个流程。那么就需要有一张表来记录文档。 除了整个文档需要记录以外,我们还需要把分段也做个保存,这样做有个好处,就是在管理工作台页面上能看到一个文档下面的所有分段的列表。 knowledge_document(知识文档表) 用途:存储上传到知识库的文档元数据信息,作为文档实体。 | 字段 | 类型 | 说明 | | --- | --- | --- | | doc_id | BIGINT | 文档ID,自增主键 | | doc_title | VARCHAR(1024) | 文档标题 | | upload_user | VARCHAR(255) | 上传用户 | | doc_url | VARCHAR(2048) | 文档存储URL | | converted_doc_url | VARCHAR(2048) | 解析后文档存储URL | | status | VARCHAR(32) | 文档状态 | | accessible_by | VARCHAR(1024) | 可见范围权限控制(如角色名称) |
状态流转:
INIT(初始状态) → UPLOADED(上传成功后状态) → CONVERTED(PDF转成markdown后的状态) → CHUNKED(已经分段后的状态) → VECTOR_STORED(已经存储到向量库以后的状态)
有一些数据是不需要保存在向量数据库的,比如父子分段中的父分段,比如需要通过数据查询而不是文档搜索的数据。(这两个后面都会讲),那么状态流转就是这样的:
INIT(初始状态) → UPLOADED(上传成功后状态) → CONVERTED(PDF转成markdown后的状态) → CHUNKED(已经分段后的状态) → STORED(已经存储到关系型数据库以后的状态)
knowledge_segment(知识片段表) 用途:存储文档分片后的文本片段,是 RAG 检索的基本单元。一篇文档会被拆分为多个片段。 | 字段 | 类型 | 说明 | | --- | --- | --- | | id | BIGINT | 片段ID,自增主键 | | chunk_id | VARCHAR(255) | 分片唯一标识(用于向量化存储关联) | | text | LONGTEXT | 文本内容 | | document_id | BIGINT | 所属文档ID(外键关联 knowledge_document.doc_id) | | chunk_order | INT | 分片顺序(文档内排序) | | embedding_id | VARCHAR(255) | 嵌入向量ID(Elasticsearch 中的向量ID) | | status | VARCHAR(255) | 分片状态:INIT(初始化)、VECTOR_STORED(已向量化) | | metadata | VARCHAR(2048) | 元数据JSON(包含 parent_chunk_id、brother_chunk_id 等关联信息) | | skip_embedding | INT | 是否跳过嵌入向量生成 |
关联关系:knowledge_document一对多knowledge_segment。一个文档包含多个知识片段,片段通过 document_id 关联到文档。
文档上传
提供一个controller,传入三个参数,一个是要上传的文件、一个上传者、一个是可见范围。
/**
* 文件上传接口
*
* @param file 上传的文件
* @param uploadUser 上传用户
* @param accessibleBy 可见范围(可选)
* @return 保存后的文档记录
*/
@PostMapping("/upload")
public KnowledgeDocument uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam("uploadUser") String uploadUser,
@RequestParam(value = "accessibleBy", required = false) String accessibleBy) throws IOException {
try {
String fileName = file.getOriginalFilename();
//用minio上传
String fileUrl = fileStorageService.uploadFile(file, fileName);
// 构建文档记录
KnowledgeDocument document = new KnowledgeDocument();
document.setDocTitle(fileName);
document.setUploadUser(uploadUser);
document.setDocUrl(fileUrl);
document.setStatus(DocumentStatus.UPLOADED);
//todo permission处理
document.setAccessibleBy(accessibleBy);
// 保存到数据库
knowledgeDocumentService.save(document);
// 如果是 PDF 文件(通过后缀或文件头判断),异步调用转换处理
if (isPdfFile(fileName) || isPdfContent(file)) {
try {
fileProcessService.processDocument(document, file.getInputStream());
} catch (Exception e) {
// 转换失败不影响上传结果,仅记录日志
System.err.println("PDF 转换处理失败,documentId: " + document.getDocId() + ", error: " + e.getMessage());
}
} else {
// 更新文档状态为已转换
document.setStatus(DocumentStatus.CONVERTED);
document.setConvertedDocUrl(fileUrl);
knowledgeDocumentService.updateById(document);
}
return document;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
这里面是先调用minio的服务上传文件,然后创建一个KnowledgeDocument,保存在数据库中,这时候的状态是UPLOADER。 然后根据文件类型判断是不是pdf,如果是的话,则需要做文档的转换。如果不是的话,直接把状态推进到CONVERTED。并记录上传地址到convertedUrl上。 文件是否PDF的判断逻辑如下:
/**
* 通过后缀名判断是否为 PDF 文件
*
* @param fileName 文件名
* @return true 如果是 PDF 文件
*/
private boolean isPdfFile(String fileName) {
if (fileName == null || fileName.isEmpty()) {
return false;
}
return fileName.toLowerCase().endsWith(".pdf");
}
/**
* 通过 Apache Tika 检测文件内容类型判断是否为 PDF 文件
*
* @param file 上传的文件
* @return true 如果是 PDF 文件
*/
private boolean isPdfContent(MultipartFile file) {
try (InputStream is = file.getInputStream()) {
String mimeType = tika.detect(is);
return "application/pdf".equals(mimeType);
} catch (IOException e) {
System.err.println("文件类型检测失败: " + e.getMessage());
return false;
}
}
然后PDF的转换逻辑,下一篇文档讲。