基于Java+Vue的向量数据库语义检索与相似文档查重系统 简介基于Java与Vue技术栈的向量数据库语义检索与相似文档查重系统项目实例面向具备一定Java和Vue开发基础的软件工程师、系统架构师及NLP相关技术人员。方案集成BERT文本向量化、Milvus近似最近邻检索及前后端分离架构完整覆盖文档上传、智能分段、语义查重、高亮比对报告生成等流程可应用于学术论文查重、企业知识产权保护、政务档案管理等场景解决传统关键词匹配无法识别语义改写的问题。资源包共1个docx文档大小约81KB内容包含需求分析、系统架构设计、数据库建模、API接口规范、前后端代码实现及部署运维说明并附有文档文本处理、向量批量入库、相似度计算等核心模块的代码示例与模块讲解尤其适合需要参照完整项目结构进行实践或二次开发的读者。已有153人学习可作为智能语义检索平台搭建与文档查重系统研发的参考实例。1. 基于 JavaVue 的向量数据库语义检索与相似文档查重系统能解决什么、适合谁把「人工智能正在改变教育行业」改成「教育行业正被 AI 深刻重塑」传统查重工具大概率判为低相似人眼却一眼看出这是同一句话的两种说法。这套基于 JavaVue 的向量数据库语义检索与相似文档查重系统解决的就是关键词比对搞不定的这个场景用 BERT 类预训练模型把文档转成高维语义向量交给 Milvus 做近似最近邻检索把「像不像」从字面重合提升到语义相似。资源内包含 Spring Boot 后端、Vue 前端、MySQL 表结构、Milvus 接入与完整代码注释适合正在做智能文档管理、知识库或内容安全系统的工程师也适合拿来做毕业设计二次开发或者直接作为企业内部文档系统的查重底座。2. 系统架构与技术选型为什么是 BERTMilvus而不是 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 操作 MySQLMilvus 客户端单独封装成一个 VectorStore 组件。这样做的理由是替换向量数据库时不至于动业务代码——如果某天 Milvus 换成 Qdrant 或者 PGVector只需要替换 VectorStore 的实现类。选型理由上Spring Boot 已经是 Java 生态里做企业级应用的事实标准Milvus 有官方 Java SDK和 Spring Boot 集成顺手文档也全。前端 Vue 2 Element UI 或 Vue 3 Element Plus 都可以项目资源里给的是 Element UIVue 3 项目可以直接换成 Element Plus组件 API 大体兼容。这里要提醒一句不要在 MySQL 里直接存 float 数组做相似度计算MySQL 对高维向量的索引支持基本没有十万条以上就明显卡顿向量检索这一步必须交给专用引擎。2.3 文档预处理与智能分段查重精度的第一道关口预处理管线是「解析 → 清洗 → 分段 → 向量化」。解析环节按扩展名分发PDF 用 PDFBoxdocx 用 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, roledocument文档元数据id, file_name, file_type, file_size, statusdocument_segment文档分段内容id, document_id, segment_index, segment_text, vector_idvector_embedding向量与文档映射id, segment_id, dimension, vector_idduplicate_check_task查重任务状态id, task_no, document_id, status, threshold, progressduplicate_check_result查重结果汇总id, task_id, source_doc_id, target_doc_id, similarity_scoreoperation_log操作审计日志id, user_id, operation, detail, created_atmodel_service_version嵌入模型版本id, model_name, version, statussystem_notice平台公告id, title, content, publish_timefile_storage_info文件存储信息id, document_id, storage_path, bucket3.2 建表 SQL 与字段选择用户表、文档分段表、查重任务表这三张最核心直接看建表 SQLCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有三处值得注意第一密码字段用 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/registerPOST用户注册/api/auth/loginPOST登录返回 JWT/api/document/uploadPOST上传文档multipart/form-data/api/document/listGET文档列表/api/document/detail/{id}GET文档详情与分段/api/duplicate/checkPOST发起查重任务返回 taskId/api/duplicate/progress/{taskId}GET查询查重进度/api/duplicate/result/{taskId}GET查重结果与报告/api/duplicate/compare/{resultId}GET相似文档内容比对/api/duplicate/report/{resultId}GET下载查重报告/api/admin/user/listGET管理员用户管理/api/log/auditGET日志与审计查重必须异步化。文档向量化加检索在 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 ResultLong 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 ListSegment splitIntoSegments(String rawText) { // 1. 去HTML标签、多余空白、控制字符统一小写 String cleaned rawText.replaceAll([^], ) .replaceAll(\\s, ) .trim(); // 2. 按自然段落切分 String[] paragraphs cleaned.split(\\n\\s*\\n); ListSegment 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 个 token256 字折算后留有余量。如果换英文文档窗口可以放大到 512 字符因为英文 token 与字符比更低。这两个值不是玄学按模型输入上限推导出来的改模型时优先调这里。4.3 文本向量化嵌入 API 客户端向量化模型独立部署后端封装一个 EmbeddingClient统一走 HTTP 调用Service public class EmbeddingClient { private final RestTemplate restTemplate; public EmbeddingClient(RestTemplate restTemplate) { this.restTemplate restTemplate; } public ListFloat embed(String text) { HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); MapString, Object body new HashMap(); body.put(texts, Collections.singletonList(text)); HttpEntityMapString, Object request new HttpEntity(body, headers); // 连接超时3秒、读取超时10秒模型推理慢时避免线程卡死 ResponseEntityEmbeddingResponse 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分段向量化后批量写入 Milvuspublic void insertSegments(Long documentId, ListSegmentVector segmentVectors) { try (MilvusServiceClient client new MilvusServiceClient( ConnectParam.newBuilder() .withHost(milvusHost) .withPort(milvusPort) .build())) { ListString vectorIds segmentVectors.stream() .map(sv - UUID.randomUUID().toString()) .collect(Collectors.toList()); ListListFloat vectors segmentVectors.stream() .map(SegmentVector::getVector) .collect(Collectors.toList()); ListLong 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(); RMutationResult 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. 取出目标文档全部分段的向量 ListListFloat 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(); RSearchResults resp client.search(param); // 3. 逐条命中记录计算文档级相似度 MapLong, Double docScoreMap aggregateScore(resp.getData()); // 4. 超过阈值的文档写入查重结果表生成比对报告 saveResults(task.getTaskId(), docScoreMap, task.getThreshold()); } }逻辑说明sourceVectors 是目标文档的所有分段向量Milvus 支持一次 search 传多个查询向量内部按 batch 计算比循环单条查询快得多。aggregateScore 把段落级分数加权汇总命中的段落越多、单段分数越高文档级相似度越高。参数说明nprobe 是 IVF 索引的探测簇数HNSW 索引下 ef 控制查询精度与耗时的平衡ef 越大召回越准但越慢。topK50 表示每个分段最多召回 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 classcompare-panel div classsource-column v-htmlrenderHighlight(sourceText, sourceHighlights)/div div classtarget-column v-htmlrenderHighlight(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 stylebackground:${bg};border-radius:2px${h.text}/span); }); return html; } /script逻辑说明这里有一个安全坑v-html 渲染的是后端返回的原文如果直接 replace 拼接而不做 HTML 转义文档里的