简介:基于Java与Vue技术栈的向量数据库语义检索与相似文档查重系统项目实例,面向具备一定Java和Vue开发基础的软件工程师、系统架构师及NLP相关技术人员。方案集成BERT文本向量化、Milvus近似最近邻检索及前后端分离架构,完整覆盖文档上传、智能分段、语义查重、高亮比对报告生成等流程,可应用于学术论文查重、企业知识产权保护、政务档案管理等场景,解决传统关键词匹配无法识别语义改写的问题。资源包共1个docx文档,大小约81KB,内容包含需求分析、系统架构设计、数据库建模、API接口规范、前后端代码实现及部署运维说明,并附有文档文本处理、向量批量入库、相似度计算等核心模块的代码示例与模块讲解,尤其适合需要参照完整项目结构进行实践或二次开发的读者。已有153人学习,可作为智能语义检索平台搭建与文档查重系统研发的参考实例。
1. 基于 Java+Vue 的向量数据库语义检索与相似文档查重系统:能解决什么、适合谁
把「人工智能正在改变教育行业」改成「教育行业正被 AI 深刻重塑」,传统查重工具大概率判为低相似,人眼却一眼看出这是同一句话的两种说法。这套基于 Java+Vue 的向量数据库语义检索与相似文档查重系统,解决的就是关键词比对搞不定的这个场景:用 BERT 类预训练模型把文档转成高维语义向量,交给 Milvus 做近似最近邻检索,把「像不像」从字面重合提升到语义相似。资源内包含 Spring Boot 后端、Vue 前端、MySQL 表结构、Milvus 接入与完整代码注释,适合正在做智能文档管理、知识库或内容安全系统的工程师,也适合拿来做毕业设计二次开发,或者直接作为企业内部文档系统的查重底座。
2. 系统架构与技术选型:为什么是 BERT+Milvus,而不是 ES 和关键词比对
2.1 从关键词匹配到语义向量的三次跃迁
传统的文档查重,底层是倒排索引加 TF-IDF 或者 BM25,核心逻辑是「同一个词在两篇文档里出现了多少次」。这个方案对完全复制粘贴有效,但遇到同义替换、语序调整、主被动句改写,命中率立刻掉下来。比如把「该算法显著提升了检索效率」改成「检索效率因该算法得到明显提升」,关键词已经错位,BM25 打分基本失真。第一代检索工具就是死在语义改写上。
第二代加入了 LDA 这类主题模型,把文档映射到主题分布,能抓住一些浅层语义,但主题数要人工定,短文本上泛化很差,训练效果也不好,实际工程里很快被弃用。到第三代预训练语言模型出现,情况才真正变化:BERT 把每个 token 映射成语境相关的向量,整句通过 pooling(常见做法是取 CLS 向量或对 token 向量做均值池化)得到固定维度的句向量,语义相近的句子在向量空间里夹角就小。这一步是从「字面匹配」跃迁到「语义匹配」的关键。
有了句向量之后,剩下的问题变成一个纯粹的向量检索问题:给一个查询向量,在千万级别的历史向量里找到距离最近的 TopK 个。MySQL 里存着 768 维的向量做暴力计算,单机十万条就跑不动了,这里才轮到向量数据库上场。Milvus 这类专用引擎支持 HNSW、IVF 索引,能在一亿级别向量上做毫秒级近似检索,这是语义查重能落地的工程前提。
2.2 整体架构:Spring Boot + Vue + Milvus 分层设计
整个系统是经典的前后端分离结构。前端 Vue 负责文档上传、查重任务列表、进度轮询、结果高亮比对;后端 Spring Boot 拆成用户、文档、查重任务、向量入库、日志审计等模块;存储层 MySQL 放业务元数据,Milvus 放向量数据;嵌入模型独立部署成一个 HTTP 服务,Spring Boot 通过 RestTemplate 或 OkHttp 调用。
分层上我一般会让后端再拆一层:Controller 只管参数校验和结果封装,Service 管业务编排,Repository 用 MyBatis-Plus 操作 MySQL,Milvus 客户端单独封装成一个 VectorStore 组件。这样做的理由是替换向量数据库时不至于动业务代码——如果某天 Milvus 换成 Qdrant 或者 PGVector,只需要替换 VectorStore 的实现类。
选型理由上,Spring Boot 已经是 Java 生态里做企业级应用的事实标准,Milvus 有官方 Java SDK,和 Spring Boot 集成顺手,文档也全。前端 Vue 2 + Element UI 或 Vue 3 + Element Plus 都可以,项目资源里给的是 Element UI,Vue 3 项目可以直接换成 Element Plus,组件 API 大体兼容。这里要提醒一句:不要在 MySQL 里直接存 float 数组做相似度计算,MySQL 对高维向量的索引支持基本没有,十万条以上就明显卡顿,向量检索这一步必须交给专用引擎。
2.3 文档预处理与智能分段:查重精度的第一道关口
预处理管线是「解析 → 清洗 → 分段 → 向量化」。解析环节按扩展名分发:PDF 用 PDFBox,docx 用 Apache POI,旧版 doc 先用 LibreOffice 转成 docx 再解析,纯文本直接读。清洗环节去掉 HTML 标签、多余空白、特殊符号,统一转成 UTF-8。最容易翻车的是 PDF 里的表格和图片,PDFBox 抽取出来经常是乱序文本,这种情况我会把表格区域单独标记,分段时按「标题-正文-表格」区分优先级。
分段策略直接决定查重粒度。整个文档喂给 BERT 会被截断到 512 token,后面的内容语义全丢,所以必须切段。常见做法是两层:先按自然段落切,段落超过 400 字再用滑动窗口补一刀,窗口大小 256 字、步长 128 字,相邻窗口保留 128 字重叠,避免语义被拦腰切断。段与段之间记录 document_id 和 segment_index,方便查重后回原文定位高亮位置。
2.4 向量化、ANN 检索与相似度判定逻辑
向量化时文本先进 tokenizer,再进 BERT 模型推理,输出做均值池化得到 768 维句向量,最后做 L2 归一化。归一化这一步很多人会忽略,但余弦相似度对向量长度不敏感,归一化后等价于内积,Milvus 里用 IP(内积)度量也能拿到同样的排序,性能还更快。
检索侧,Milvus 的索引我先用 HNSW,参数 M 取 16、efConstruction 取 200,查询时 ef 取 64。召回 TopK 后,相似度分数要反推成「相似度百分比」给人看,Milvus 返回的距离值和度量方式有关,IP 度量下分数范围不固定,需要做 min-max 归一化映射。
查重判定用多粒度融合:段落级向量先粗筛,命中相似段落后再对段落内句子做细粒度比对,最后把句子级相似度加权汇总成文档级分数。阈值不写死,短文本阈值高(0.85 左右),长文本阈值低(0.75 左右),原因在于短文本可比较的信息量少,同样的相似度分数更可能来自偶然巧合。
3. MySQL 建模与 API 接口规范:十张表和一个异步查重约定
3.1 十张表的职责划分
数据库设计直接决定系统的可维护性。项目里按「用户、文档、向量、任务、结果、日志」六条线拆成十张表,我先用表格列出核心职责:
| 表名 | 职责 | 关键字段 |
|---|---|---|
| user | 用户与角色 | id, username, password_hash, role |
| document | 文档元数据 | id, file_name, file_type, file_size, status |
| document_segment | 文档分段内容 | id, document_id, segment_index, segment_text, vector_id |
| vector_embedding | 向量与文档映射 | id, segment_id, dimension, vector_id |
| duplicate_check_task | 查重任务状态 | id, task_no, document_id, status, threshold, progress |
| duplicate_check_result | 查重结果汇总 | id, task_id, source_doc_id, target_doc_id, similarity_score |
| operation_log | 操作审计日志 | id, user_id, operation, detail, created_at |
| model_service_version | 嵌入模型版本 | id, model_name, version, status |
| system_notice | 平台公告 | id, title, content, publish_time |
| file_storage_info | 文件存储信息 | id, document_id, storage_path, bucket |
3.2 建表 SQL 与字段选择
用户表、文档分段表、查重任务表这三张最核心,直接看建表 SQL:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL, `password_hash` varchar(128) NOT NULL, `role` varchar(16) NOT NULL DEFAULT 'USER' COMMENT 'ADMIN/USER/VIEWER', `status` tinyint NOT NULL DEFAULT 1, `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `document_segment` ( `id` bigint NOT NULL AUTO_INCREMENT, `document_id` bigint NOT NULL, `segment_index` int NOT NULL, `segment_text` text NOT NULL, `word_count` int DEFAULT 0, `vector_id` varchar(128) DEFAULT NULL COMMENT '对应Milvus中的主键', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_document_id` (`document_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `duplicate_check_task` ( `id` bigint NOT NULL AUTO_INCREMENT, `task_no` varchar(64) NOT NULL, `document_id` bigint NOT NULL, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待处理 1处理中 2成功 3失败', `threshold` decimal(4,3) NOT NULL DEFAULT 0.800, `progress` int NOT NULL DEFAULT 0, `message` varchar(255) DEFAULT NULL, `created_by` bigint DEFAULT NULL, `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `finished_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_task_no` (`task_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有三处值得注意:第一,密码字段用 password_hash 存 bcrypt 摘要,不要存明文;第二,document_segment 只存业务文本,不存向量,向量主键用 vector_id 关联 Milvus;第三,查重任务表里 status 用 tinyint 枚举并写注释,比用字符串更好维护。
3.3 向量为什么单独存 Milvus
分段的向量不落 MySQL,而是写入 Milvus 的 document_segment collection。原因有两个:一是 768 维向量在 MySQL 里只能用 BLOB 存,查询时全表加载做暴力计算,数据到十万条就明显变慢;二是 Milvus 支持标量过滤,比如按 document_id 过滤掉「查自己」的情况,也支持按业务线 partition 隔离,这些能力 MySQL 都给不了。
MySQL 和 Milvus 的数据一致性通过 vector_id 对接。分段入库时,先在 MySQL 插入 document_segment 拿到自增 id,再生成一个 UUID 作为 Milvus 主键,把向量插入 Milvus,最后把 vector_id 回填到 MySQL 的记录上。两步操作不放在同一个事务里,靠任务状态机保证最终一致:任务表 status 为处理中时,前端只显示进度;如果 Milvus 写入失败,任务置为失败,下次重跑时从 MySQL 里清除未回填 vector_id 的分段,不会产生脏数据。
注意:MySQL 与 Milvus 的最终一致依赖任务状态机,不要把两个库的写入放到同一个事务里,否则长事务会拖垮连接池。
3.4 API 接口规范与异步查重设计
接口统一走 RESTful 风格,核心接口我用表格列出:
| 接口路径 | 方法 | 说明 |
|---|---|---|
| /api/auth/register | POST | 用户注册 |
| /api/auth/login | POST | 登录,返回 JWT |
| /api/document/upload | POST | 上传文档,multipart/form-data |
| /api/document/list | GET | 文档列表 |
| /api/document/detail/{id} | GET | 文档详情与分段 |
| /api/duplicate/check | POST | 发起查重任务,返回 taskId |
| /api/duplicate/progress/{taskId} | GET | 查询查重进度 |
| /api/duplicate/result/{taskId} | GET | 查重结果与报告 |
| /api/duplicate/compare/{resultId} | GET | 相似文档内容比对 |
| /api/duplicate/report/{resultId} | GET | 下载查重报告 |
| /api/admin/user/list | GET | 管理员用户管理 |
| /api/log/audit | GET | 日志与审计 |
查重必须异步化。文档向量化加检索在 CPU 密集场景下可能跑几十秒,同步接口会把 HTTP 连接占死。我的约定是:POST /api/duplicate/check 只做任务创建,返回 task_no 和 task_id;前端用 setInterval 轮询 progress 接口,status 变为 2 时再展示结果。任务在 Service 层通过 @Async 提交到独立线程池执行,线程池参数(核心 8、最大 16、队列 200)按服务器核数调整。
安全这块,JWT 拦截器校验登录态,角色用注解 @PreAuthorize 控制,上传下载接口都要走文件权限校验。管理员能看全量任务,普通用户只能看自己的文档和任务,SQL 查询里强制拼 user_id 条件,不能只靠前端按钮隐藏。
4. 后端核心链路落地:文档上传、向量化、入库与查重任务的完整实现
4.1 文档上传与文本抽取
上传接口接收 MultipartFile,先校验大小和后缀,再落盘临时目录,随后按扩展名分发到不同的解析器:
@PostMapping("/api/document/upload") public Result<Long> upload(@RequestParam("file") MultipartFile file, @RequestParam("userId") Long userId) { // 1. 校验文件大小不超过50MB,后缀在允许列表内 if (file.getSize() > 50 * 1024 * 1024) { return Result.error("文件不能超过50MB"); } String ext = FilenameUtils.getExtension(file.getOriginalFilename()); if (!SUPPORTED_EXTENSION.contains(ext.toLowerCase())) { return Result.error("不支持的格式: " + ext); } // 2. 保存到本地临时目录 String storagePath = fileStorageService.save(file); // 3. 调用文本抽取器解析,返回纯文本 String rawText = textExtractor.extract(storagePath, ext); // 4. 创建document记录并落库 Long documentId = documentService.createDocument(userId, file.getOriginalFilename(), ext, rawText); return Result.success(documentId); }逻辑说明:上传接口只是入口,真正耗时的是第 3 步文本抽取,大 PDF 可能抽十几秒,所以这里我会在 document.status 上放一个 PROCESSING 标记,前端看到的是「解析中」,不阻塞页面。
参数说明:SUPPORTED_EXTENSION 白名单建议收 pdf、doc、docx、txt、md,千万不要收 exe、html——HTML 解析会掺杂脚本内容,向量化后全是噪音。50MB 是上传上限,如果需要传更大文件,要换成分片上传或对象存储预签名 URL,这个接口就扛不住了。
4.2 文档分段与清洗
文本抽取出来后先清洗,再按 2.3 的策略分段:
public List<Segment> splitIntoSegments(String rawText) { // 1. 去HTML标签、多余空白、控制字符,统一小写 String cleaned = rawText.replaceAll("<[^>]+>", "") .replaceAll("\\s+", " ") .trim(); // 2. 按自然段落切分 String[] paragraphs = cleaned.split("\\n\\s*\\n"); List<Segment> segments = new ArrayList<>(); int index = 0; for (String p : paragraphs) { // 3. 超长段落用滑动窗口再切:窗口256字,步长128字 if (p.length() > 400) { for (int start = 0; start < p.length(); start += 128) { int end = Math.min(start + 256, p.length()); segments.add(new Segment(index++, p.substring(start, end))); if (end == p.length()) break; } } else { segments.add(new Segment(index++, p)); } } return segments; }逻辑说明:分段结果决定查重的最小粒度。窗口 256 字、步长 128 字是语义检索里常用的配置,既能保证句子完整,又不至于把一段完整论述切碎;滑动窗口保留的 128 字重叠用来缓解边界语义被切断的问题。
参数说明:400 字是触发滑动窗口的阈值,来自 BERT 512 token 的约束——中文一个字约 0.6~1 个 token,256 字折算后留有余量。如果换英文文档,窗口可以放大到 512 字符,因为英文 token 与字符比更低。这两个值不是玄学,按模型输入上限推导出来的,改模型时优先调这里。
4.3 文本向量化:嵌入 API 客户端
向量化模型独立部署,后端封装一个 EmbeddingClient,统一走 HTTP 调用:
@Service public class EmbeddingClient { private final RestTemplate restTemplate; public EmbeddingClient(RestTemplate restTemplate) { this.restTemplate = restTemplate; } public List<Float> embed(String text) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); Map<String, Object> body = new HashMap<>(); body.put("texts", Collections.singletonList(text)); HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers); // 连接超时3秒、读取超时10秒,模型推理慢时避免线程卡死 ResponseEntity<EmbeddingResponse> resp; try { resp = restTemplate.postForEntity( "http://embedding-service:8081/api/embedding", request, EmbeddingResponse.class); } catch (ResourceAccessException e) { throw new BizException("向量化服务不可用"); } return resp.getBody().getData().get(0); } }逻辑说明:这里没引入 OpenFeign,是因为向量化服务不涉及复杂的负载均衡,一个 RestTemplate 足够。连接超时和读取超时要分开设,模型推理一次几百毫秒,读取超时设太短会把正常请求误判为失败。
参数说明:embedding-service 采用独立服务部署有几个好处,模型加载一次常驻内存,多服务共享,后端重启不需要重新加载模型。如果你的环境没有独立模型服务,本地直接用 HuggingFace 的 BertModel 跑也行,但并发能力会受 JVM 内存限制,实测单机并行推理超过 8 路就容易 GC 频繁。
4.4 向量批量入库 Milvus
分段向量化后批量写入 Milvus:
public void insertSegments(Long documentId, List<SegmentVector> segmentVectors) { try (MilvusServiceClient client = new MilvusServiceClient( ConnectParam.newBuilder() .withHost(milvusHost) .withPort(milvusPort) .build())) { List<String> vectorIds = segmentVectors.stream() .map(sv -> UUID.randomUUID().toString()) .collect(Collectors.toList()); List<List<Float>> vectors = segmentVectors.stream() .map(SegmentVector::getVector) .collect(Collectors.toList()); List<Long> documentIds = segmentVectors.stream() .map(sv -> documentId) .collect(Collectors.toList()); InsertParam param = InsertParam.newBuilder() .withCollectionName("document_segment") .withFields(Arrays.asList( FieldData.newBuilder() .withFieldName("vector_id").withType(DataType.VarChar).withValues(vectorIds).build(), FieldData.newBuilder() .withFieldName("document_id").withType(DataType.Int64).withValues(documentIds).build(), FieldData.newBuilder() .withFieldName("vector").withType(DataType.FloatVector).withValues(vectors).build())) .build(); R<MutationResult> resp = client.insert(param); if (resp.getStatus() != R.Status.Success) { throw new BizException("Milvus插入失败: " + resp.getMessage()); } // 回填vector_id到MySQL的document_segment表 documentSegmentMapper.updateVectorId(documentId, vectorIds); } }逻辑说明:插入前要保证 collection 已创建,字段类型必须和建表时一致,FloatVector 对应 768 维的 List 。vector_id 用 UUID 做主键,避免和 MySQL 自增 id 混用。
参数说明:withHost 和 withPort 对应 Milvus 的 standalone 部署地址,默认 19530 端口。collection 的 metric_type 我选 IP(内积),配合前面说的向量归一化,能拿到和余弦相似度一致的排序。建 collection 时如果没设 auto_id,插入时必须显式带主键。
4.5 查重任务调度与相似检索
查重任务用 @Async 异步执行,核心是 Milvus 的 search 调用:
@Async("duplicateCheckExecutor") public void runCheck(DuplicateCheckTask task) { // 1. 取出目标文档全部分段的向量 List<List<Float>> sourceVectors = segmentService.getVectorsByDocument(task.getDocumentId()); // 2. 对每个分段向量做TopK检索 try (MilvusServiceClient client = new MilvusServiceClient(connectParam())) { SearchParam param = SearchParam.newBuilder() .withCollectionName("document_segment") .withVectors(sourceVectors) .withTopK(50) .withOutputFields(Arrays.asList("document_id", "vector_id")) .withParams("{\"nprobe\": 16, \"ef\": 64}") .build(); R<SearchResults> resp = client.search(param); // 3. 逐条命中记录计算文档级相似度 Map<Long, Double> docScoreMap = aggregateScore(resp.getData()); // 4. 超过阈值的文档写入查重结果表,生成比对报告 saveResults(task.getTaskId(), docScoreMap, task.getThreshold()); } }逻辑说明:sourceVectors 是目标文档的所有分段向量,Milvus 支持一次 search 传多个查询向量,内部按 batch 计算,比循环单条查询快得多。aggregateScore 把段落级分数加权汇总:命中的段落越多、单段分数越高,文档级相似度越高。
参数说明:nprobe 是 IVF 索引的探测簇数,HNSW 索引下 ef 控制查询精度与耗时的平衡,ef 越大召回越准但越慢。topK=50 表示每个分段最多召回 50 条候选,这个值要根据库内文档量调整,库小设 20 足够,库大再往上加。搜索时一定要用 outputFields 带上 document_id,否则无法反推命中哪篇文档。
5. 前端 Vue 交互与避坑指南:上传组件、进度轮询、高亮比对和五条踩坑记录
5.1 页面结构与 Vue 路由设计
前端页面按业务拆成登录页、文档管理页、上传页、任务列表页、结果详情页。路由用 Vue Router,动态路由加载管理员模块:
const routes = [ { path: '/login', component: Login }, { path: '/documents', component: DocumentList, meta: { auth: true } }, { path: '/upload', component: UploadPage, meta: { auth: true } }, { path: '/tasks', component: TaskList, meta: { auth: true } }, { path: '/result/:taskId', component: ResultDetail, meta: { auth: true } }, { path: '/admin', component: AdminLayout, meta: { auth: true, role: 'ADMIN' }, children: [ { path: 'users', component: AdminUserList }, { path: 'logs', component: AdminLogList } ] } ];逻辑说明:路由守卫里校验 JWT 和角色,管理员模块用嵌套路由单独挂载,普通用户访问 /admin 直接重定向到首页。meta 里的 role 字段和后端 @PreAuthorize 是双保险,前端控制入口,后端控制数据。
5.2 文件上传与进度轮询组件
上传组件用 axios 的 onUploadProgress 拿上传进度,上传成功后立刻发起查重任务并轮询进度接口:
async function uploadAndCheck(file) { const formData = new FormData(); formData.append('file', file); formData.append('userId', userStore.id); const uploadResp = await axios.post('/api/document/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: e => { uploadProgress.value = Math.round((e.loaded / e.total) * 100); } }); const taskResp = await axios.post('/api/duplicate/check', { documentId: uploadResp.data.data }); const timer = setInterval(async () => { const progressResp = await axios.get(`/api/duplicate/progress/${taskResp.data.data.taskId}`); taskProgress.value = progressResp.data.data.progress; if (progressResp.data.data.status === 2) { clearInterval(timer); router.push(`/result/${taskResp.data.data.taskId}`); } if (progressResp.data.data.status === 3) { clearInterval(timer); ElMessage.error(progressResp.data.data.message); } }, 2000); }逻辑说明:轮询间隔 2 秒是折中的选择,查重任务一般几十秒完成,1 秒轮询太频繁,5 秒体验又不够实时。轮询到终态(成功或失败)后必须 clearInterval,否则组件卸载后定时器还在跑,会白打接口。
5.3 查重结果高亮比对
结果详情页把后端返回的相似段落对渲染成左右对照,相似句子按分数着色:
<template> <div class="compare-panel"> <div class="source-column" v-html="renderHighlight(sourceText, sourceHighlights)"></div> <div class="target-column" v-html="renderHighlight(targetText, targetHighlights)"></div> </div> </template> <script setup> function renderHighlight(text, highlights) { let html = text; highlights.forEach(h => { // 用相似度分数决定颜色深浅,分数越高颜色越深 const bg = h.score > 0.9 ? '#ff6b6b' : h.score > 0.8 ? '#ffa39d' : '#ffccc7'; html = html.replace(h.text, `<span style="background:${bg};border-radius:2px">${h.text}</span>`); }); return html; } </script>逻辑说明:这里有一个安全坑,v-html 渲染的是后端返回的原文,如果直接 replace 拼接而不做 HTML 转义,文档里的