
简介面向具备Java与Vue开发基础的软件工程师、系统架构师及NLP相关技术人员资料完整呈现了基于向量数据库的语义检索与相似文档查重系统的设计与实现。项目集成BERT文本向量化与Milvus向量数据库结合前后端分离架构覆盖需求分析、架构设计、数据建模、API规范、代码实现到部署运维全流程适用于学术查重、知识产权保护、网络内容监控等场景解决传统关键词匹配难以识别语义改写的问题。压缩包共1个docx文档大小仅81KB内含完整项目实例、数据库与GUI设计说明及代码详解并详细拆解文本预处理、向量化嵌入、相似度计算与查重核心逻辑等模块。已有153人浏览学习适合希望快速掌握语义检索系统搭建与文档查重机制的技术人员作为参考。1. 向量数据库 语义检索 查重这个项目到底在解决什么问题你的文档管理系统里有一份《XX系统部署方案》用户又传了一份《XX系统上线指引》。人看一眼就知道是同一件事但关键词检索可能一个字符都匹配不上因为标题、措辞、语序全变了。这类问题就是语义检索要解决的把文本变成高维向量用向量距离判断“像不像”。这套 Java Vue 的系统核心流程是文档上传后自动切分、向量化、写入向量数据库随后语义检索和相似文档查重都在这套向量索引上跑。Java 负责接收文件、调用模型、读写向量库Vue 负责上传界面和相似文档的对比展示。适合两类人想在现有管理系统里加语义能力的 Java 全栈开发以及需要一个完整、能改、能跑通的参考实现来研究落地细节的工程师。2. 技术选型与系统架构Java 和 Vue 在向量检索链路里各管哪一段2.1 向量数据库选型四类库的适用边界与我这边的选择向量数据库是整个系统的地基。选型错了后面所有代码都要推倒重来。我把常见方案分成四类方案部署方式适用规模运维成本适合场景FAISS嵌入式库随应用进程启动百万级以下低单机工具、研究原型Milvus独立分布式服务千万级以上高企业级在线服务Elasticsearch dense_vector独立服务百万级左右中团队已有 ES 体系Chroma嵌入式/轻量服务十万级低快速验证、小型工具如果你只是给自己做个本地查重小工具FAISS 就够了一个 jar 依赖搞定不需要额外部署服务但要做成 Java Vue 的完整系统并且给多人用我会选 Milvus。理由很直接它有成熟的 Java SDK支持 collection 级别的权限和数据管理查询参数nprobe、ef_search可以按场景动态调跟 Spring Boot 集成不会引入太多胶水代码。反过来如果项目已经重度使用 Elasticsearch就不要为了“向量数据库”这个名字再引一套新中间件用 ES 的 dense_vector 字段也能做近邻检索省一套运维。有同学会问向量能不能直接放 MySQL能放但不建议。一个 512 维的 float 向量在 MySQL 里要用 BLOB 存查的时候要么全表扫出来逐条算余弦要么装 MySQL 8 的倒排索引硬扛数据量一上去查询延迟就失控。MySQL 管业务数据向量库管向量距离计算各管各的这是这套架构里最值得先守住的分工原则。2.2 前后端数据流从 Vue 上传文档到 Java 返回相似文档的完整链路整条链路拆开是七步我建议你照着这个顺序去理解代码而不是从某个类开始看Vue 页面用 multipart/form-data 上传文件POST 到 Spring Boot 的/api/document/uploadJava 接收文件流落盘保存并在document_file表插入一条状态为“待建索引”的记录后台任务读取文件内容txt、md 直接按 UTF-8 读PDF、Word 需要用解析库抽文本这一步是后续所有效果的前提把抽取出来的文本按段落或定长窗口切成 chunk每个 chunk 调 embedding 服务拿到向量连同 chunk 原文和 fileId 写入向量库检索时用户输入一段文本或上传一篇文档同样做向量化然后去向量库做 top-k 近邻搜索后端把命中的 chunk、文档名、相似度分数返回给 VueVue 渲染对比视图。先看最小的上传接口实现RestController RequestMapping(/api/document) public class DocumentController { private final FileStore fileStore; private final TextParser textParser; private final VectorIndexService vectorIndexService; PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file) { // 1. 保存原始文件落盘或对象存储 String fileId fileStore.save(file); // 2. 抽文本并切分为 chunk这一步在独立线程池里做 textParser.parseAsync(fileId, file, () - { ListTextChunk chunks textParser.getChunks(fileId); // 3. 向量化 写向量库 vectorIndexService.indexChunks(fileId, chunks); }); return Result.ok(fileId); } }逻辑说明这个接口把“存文件”和“建索引”拆开接口立刻返回 fileId客户端拿到后可以轮询任务状态。“parseAsync”里传了一个回调索引完成后把状态位改成“完成”前端才能显示“可检索”。这样做的原因很实际一份几十页的 PDF解析、切分、向量化加起来可能要好几秒同步等会直接拉垮上传体验。参数说明fileStore负责文件存储单机部署就存本地磁盘部署到服务器就换成 OSStextParser的解析规则按文件后缀分发PDF 和 Word 的抽取逻辑不要混在一个 if 里后期每加一种格式改动面越小越好。2.3 模块划分与数据库设计文件表、段落表、任务表系统建议拆成三个模块文件管理、向量索引、语义检索/查重。数据库这边三张表就够不需要过度设计CREATE TABLE document_file ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_name VARCHAR(255) NOT NULL, file_path VARCHAR(512) NOT NULL, upload_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 0 -- 0 待建索引1 已完成2 解析失败 ); CREATE TABLE doc_chunk ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_id BIGINT NOT NULL, chunk_index INT NOT NULL, chunk_text TEXT NOT NULL, vector_id VARCHAR(128) -- 对应向量库里的主键 ); CREATE TABLE retrieval_task ( task_id VARCHAR(64) PRIMARY KEY, file_id BIGINT NOT NULL, task_type VARCHAR(16) NOT NULL, -- INDEX 建索引 / DEDUP 查重 status VARCHAR(16) DEFAULT PENDING, progress INT DEFAULT 0 );这里最关键的细节是doc_chunk只存原文和vector_id不存向量本身。向量的 768 个 float 放在向量库里MySQL 这边留一个外键指向它这样查业务数据时不会把一堆二进制大字段拖进来。retrieval_task表是给异步任务用的启动全量查重或批量建索引时先插一条 PENDING 记录再把任务丢线程池前端就能轮询进度条而不是干等。补一个实际经验任务表里一定要带task_type因为建索引和查重两个任务的并发控制策略不同。建索引可以并发跑 4 个查重一般只并发 1 到 2 个否则磁盘 IO 和 CPU 双双打满线上服务跟着抖。3. 做语义检索文本向量化与向量检索的落地实现3.1 文本向量化中文模型、分块策略和向量维度怎么定语义检索的效果高度依赖 embedding 模型模型选错参数再漂亮也是白搭。中文场景我一般优先选专门的中文模型比如 bge-small-zh、m3e 这类而不是直接用通用的多语言大模型。原因很朴素中文的模型在中文语料上微调过对同义改写、成语、行业术语的区分度更好而且同样效果下参数量小向量维度只有 512写入和检索都快一截。英文为主的数据再考虑多语言模型。文本向量化之前有个关键决定切分粒度。我见过不少项目直接把整篇文档丢给模型生成一个向量查重时整篇跟整篇比结果两篇都是混合主题的长文档向量被平均成了一团浆糊相似度全部趋近中间值。正确做法是按段落切段落过长再按窗口切窗口之间留 overlap。切分代码的常见写法public ListTextChunk split(String rawText, int maxLen, int overlap) { ListTextChunk chunks new ArrayList(); String[] paragraphs rawText.split(\\n{2,}); StringBuilder buf new StringBuilder(); for (String p : paragraphs) { if (buf.length() p.length() maxLen buf.length() 0) { chunks.add(new TextChunk(buf.toString())); // 保留结尾 overlap 长度的文本避免切断句子的语义 String tail buf.substring(Math.max(0, buf.length() - overlap)); buf new StringBuilder(tail); } buf.append(p); } if (buf.length() 0) { chunks.add(new TextChunk(buf.toString())); } return chunks; }逻辑说明按连续两个换行符切出段落再按 maxLen 把过长的段落拼接或截断。overlap 的作用是让被切到边缘的句子在下一个 chunk 里仍然带着上文出现保证检索时不会因为一句话被腰斩而漏召回。参数说明maxLen 要看模型的 max token 限制中文场景下一个 token 大约对应一个汉字到两个汉字512 token 的模型按 300 字符切比较稳妥我一般取 400 字符再留点余量overlap 取 50 到 100 字符。这两个参数直接影响后面查重的颗粒度和误判率建议作为配置项暴露出来不要写死在常量里。3.2 用 Java 客户端写入向量库批量入库与增量更新选 Milvus 的话Java SDK 的建库和写入代码长这样注意看参数说明它决定了你后面查询的坑有多少MilvusServiceClient client new MilvusServiceClient( ConnectParam.newBuilder() .withHost(127.0.0.1) .withPort(19530) .build()); // 建 collectiondimension 必须和 embedding 模型输出维度一致 CreateCollectionParam collectionParam CreateCollectionParam.newBuilder() .withCollectionName(doc_embedding) .withDimension(512) .withPrimaryFieldName(id) .withVectorFieldName(embedding) .withMetricType(MetricType.COSINE) .build(); client.createCollection(collectionParam);参数说明dimension写 512 是因为 bge-small-zh 输出 512 维如果换模型这个数字要跟着改而且 collection 建好后改不了只能删了重建。metricType用 COSINE 余弦距离语义相似用余弦最直观打分范围是 -1 到 1实际检索里基本都落在 0 到 1 之间后面做阈值过滤比较顺手。批量写入的代码public void batchInsert(ListTextChunk chunks) { ListLong ids new ArrayList(); ListListFloat vectors new ArrayList(); for (TextChunk c : chunks) { ListFloat vec embeddingService.embed(c.getText()); ids.add(c.getId()); vectors.add(vec); } ListInsertParam.Field fields new ArrayList(); fields.add(new InsertParam.Field(id, ids)); fields.add(new InsertParam.Field(embedding, vectors)); InsertParam param InsertParam.newBuilder() .withCollectionName(doc_embedding) .withFields(fields) .build(); client.insert(param); }逻辑说明先一次性向量化本批所有 chunk再统一插入比每条一插快一个数量级。embeddingService可以是调用远程模型服务的 HTTP 客户端也可以是加载在本地 JVM 里的模型看你的部署资源。批量大小一般取 100 到 500 条太小网络开销占比高太大内存压力大还要控制超时时间。增量更新是很多人遗漏的点。文档重新上传或覆盖时旧向量还留在库里查重会把历史版本和新版本算出重复。正确做法是删除一个 fileId 关联的所有向量再重新插入。Milvus 里用delete按标量字段过滤即可。删除和插入最好在同一个事务语义下完成——做不到事务就先把新向量插进一个临时 fileId确认成功后再删旧避免中间态查询出脏数据。3.3 语义检索的查询链路top-k 召回 阈值过滤查询比写入更考验参数意识。直接看代码public ListHit search(String queryText, int topK) { ListFloat queryVec embeddingService.embed(queryText); SearchParam param SearchParam.newBuilder() .withCollectionName(doc_embedding) .withMetricType(MetricType.COSINE) .withTopK(topK) .withVectorFieldName(embedding) .withParams({\nprobe\: 16}) .addOutField(chunk_text) .addOutField(file_id) .build(); ListListSearchResultData results client.search(param).getData(); // 后置过滤阈值决定“像不像”这一层必须在应用里做 double scoreTh 0.75; return results.get(0).stream() .filter(r - r.getScore() scoreTh) .map(r - new Hit(r.getScore(), r.getField(file_id), r.getField(chunk_text))) .collect(Collectors.toList()); }逻辑说明查询也走向量化保证 query 和库里的向量处于同一语义空间。withParams里的nprobe是 IVFFlat 索引的探针数量它决定召回质量探针越多搜索越细致但延迟线性上升。16 是一个常见的起步值如果数据量上百万20 到 32 更稳。阈值过滤放后端而不是放 SQL 里是因为向量库返回的 top-k 已经是剪枝后的结果阈值只是帮你在应用层进一步收口。topK 要明显大于你实际想返回的数量通常设成期望结果的 3 到 5 倍比如界面想展示 20 条topK 就设 100过滤完剩下的再排序返回。这样做的原因是向量索引的近邻搜索是近似结果topK 设小了阈值过滤后可能只剩几条甚至空列表用户会以为系统坏了。还要注意排除自己查重场景里查询的是某篇文档的 chunk检索结果里首先要排除掉同一个 fileId 的命中否则最大相似度永远是 1.0 自己匹配自己。这个过滤条件加在filter里不要等结果回来再删否则浪费 topK 的配额。4. 做相似文档查重向量检索不是全部还要管好阈值与索引4.1 查重的两种实现全量两两比对与近邻搜索查重和检索的实现路径不一样。检索是一个 query 进一组结果出查重是对库里的每一篇文档都要判断“它跟谁像”。有两种常见做法方式计算复杂度适用数据量输出全量两两比对O(n²)几千 chunk 以内精确的完整相似度矩阵向量索引近邻搜索O(n log n) 级别万级以上近似的 top 相似对全量两两比对适合做“标准答案”因为它是精确的它通常不用在线上而是用来标定阈值。代码最小实现是这样的public MapLong, ListScoredPair pairwiseDedup(ListChunkVector all, double threshold) { MapLong, ListScoredPair result new HashMap(); for (int i 0; i all.size(); i) { for (int j i 1; j all.size(); j) { double sim cosine(all.get(i).getVector(), all.get(j).getVector()); if (sim threshold) { result.computeIfAbsent(all.get(i).getFileId(), k - new ArrayList()) .add(new ScoredPair(all.get(j).getFileId(), sim)); } } } return result; }逻辑说明i 从 0 到 nj 从 i1 到 n只算上三角避免重复计算自己和别人两次。cosine计算前要先对向量做归一化否则长度差异会影响相似度。这个 double for 翻车的点不在正确性在性能。10 万个 chunk 就是 50 亿次余弦计算单机算到天亮。所以线上查重我一般复用向量库的索引每个 chunk 都拿去检索一次 topK再汇总成文档级别的相似对。这样吞吐量和数据量近似线性关系代价是结果近似但用户能接受的查重本质上就是“几乎重复”少量边缘漏报不影响使用。4.2 相似度阈值怎么定用一小批标注样本做标定别拍脑袋阈值定 0.8 还是 0.7是所有查重项目里最玄学的部分。同一个阈值在新闻语料上误判率可能 2%换到法务合同上直接爆炸。我现在的固定做法是先准备 50 对“真重复”的文档和 50 对“真不重复”的文档人工打标跑一遍计算画相似度分布然后选能让 F1 最大的阈值。标定脚本用 Python 写比 Java 顺手因为要快速迭代画图# 标定脚本pos_scores 是正样本重复的相似度neg_scores 是负样本不重复的相似度 def f1_at_threshold(pos_scores, neg_scores, th): tp sum(1 for s in pos_scores if s th) fp sum(1 for s in neg_scores if s th) fn sum(1 for s in pos_scores if s th) precision tp / (tp fp) if tp fp else 0 recall tp / (tp fn) if tp fn else 0 return 2 * precision * recall / (precision recall) if (precision recall) else 0 best_th, best_f1 0, 0 for th in [i / 100 for i in range(50, 100, 2)]: f f1_at_threshold(pos_scores, neg_scores, th) if f best_f1: best_th, best_f1 th, f print(fbest threshold: {best_th}, best f1: {best_f1})说明这个脚本的输出是一个阈值但更重要的输出是分布本身。如果正负样本的重叠区间很大说明模型或分块策略有问题调阈值只是事后补救。重叠区间小阈值才有一刀切的底气。实际调的时候得先决定你要偏哪边查重系统如果偏向“宁可误报不可漏报”阈值就往下压代价是人工复核成本上升如果偏向“报案少而精”阈值往上抬。我一般把初始阈值设在分布图上正样本的 P10 分位再按人工抽检结果微调。4.3 文档级聚合段落相似度合并成文档相似度的三种策略chunk 级别的相似度有了还要汇总成“文档与文档像不像”因为用户看到的是一篇文档不是一个段落。三种常见策略Max取所有命中段落里的最高相似度适合“抄了一段就是抄”的场景论文查重、合同比对常用Mean对所有段落相似度取平均适合整体风格或主题相似度的评估新闻聚合、文章推荐好用加权按段落长度加权长段落对结果的影响大于短段落避免一两句口水话拉高相似度。加权在 Java 里的实现public double weightedDocSimilarity(ListDouble sims, ListInteger lens) { double sumWeight 0, sumScore 0; for (int i 0; i sims.size(); i) { int w Math.max(1, lens.get(i)); sumScore sims.get(i) * w; sumWeight w; } return sumScore / sumWeight; }逻辑说明每个段落相似度乘以段落长度作为权重再除以总权重。Math.max(1, len)防止零长度段落把权重清零。我自己的项目里默认用 Max 做主判据加权做参考Max 超过阈值就直接标记为“疑似重复”加权值用来给用户一个“整体相似度”的百分比展示。查重报告里至少要有三列信息相似文档名、最高段落相似度、重叠段落原文。重叠原文来自 chunk_text这也是为什么入库时一定把文本原样存下来的原因——只存向量不存原文最后连高亮都做不了那这个系统就是个黑匣子。5. 避坑指南从向量乱码到 Vue 打包进 Spring Boot 的五个常见问题5.1 中文文本向量化后全是空向量或乱码现象文档入库成功但检索结果相似度忽高忽低跟内容毫无关系查重报告里命中的段落看起来像一堆乱码。原因概率最大的是文件解析阶段编码不对。PDF 和 Word 抽取出的文本可能是 GBK 或带大量控制字符喂给模型后产出的是随机向量另一种是文本里混了大量无效字符模型直接吐了零向量。解决解析后的文本先做清洗再向量化。String cleaned raw.replaceAll([\\p{Cntrl}[^\\n\\t]], ).trim(); // 过滤掉没有中文/英文主体的“空文本”避免把纯标点段落也入库 if (!cleaned.isEmpty() cleaned.matches(.*[\\u4e00-\\u9fa5a-zA-Z].*)) { ListFloat vec embeddingService.embed(cleaned); }这条规则解决了我这边 90% 的乱码问题剩下的就是确认 embedding 服务接收的请求体编码是 UTF-8。自检技巧固定对一个句子打向量每次调用结果应该高度一致如果同一个句子两次向量化结果都不稳定先去查服务端的编码和 tokenizer。5.2 向量维度对不齐导致检索直接报错现象系统跑得好好的某天换了个 embedding 模型后insert或search直接抛“dimension mismatch”异常整个检索接口挂掉。原因向量库 collection 的 schema 在创建时就固定了维度旧库是 512 维新模型输出 768 维数据写不进去查询也查不了。很多人上线前没把模型和库的版本关系记录下来。解决把模型名称和维度写进配置中心或数据库系统启动时做一次自检当前模型维度与集合维度不一致直接拒绝启动并提示人工处理。处理方式只能删除旧 collection 重建然后全量重新建索引——没有后悔药所以换模型前务必跑一次全部文档的批量向量化任务确认耗时和数据量可接受再动手。5.3 Vue 打包后放进 Spring Boot页面白屏与路由 404现象本地npm run dev一切正常npm run build后把 dist 目录内容扔进 Spring Boot 的static目录首页打开白屏直接访问/detail/123这个地址返回 404。原因两个经典叠加问题。vue-router 默认的 history 模式在刷新或直接输 URL 时请求到了 Spring Boot 后端后端找不到对应的 controller就 404 了另外打包后的静态资源路径是绝对路径/assets/xxx.js部署在子路径或 jar 内时找不到资源白屏。解决路由切 hash 模式静态资源改相对路径。// vite.config.js export default defineConfig({ base: ./, // 关键打包后资源引用改相对路径 });// router/index.js const router createRouter({ history: createWebHashHistory(), // 用 hash 模式刷新不依赖后端 fallback routes, });一个坑是我自己踩过的只改了路由没改 base结果首页能开但样式和 JS 全挂在/assets/下 404。两个配置要一起改部署时直接把 dist 内容拷进 static 目录无需额外配 view controller。5.4 全量查重任务跑太久数据库连接池被占满现象点了一下“全量查重”CPU 飙升在线检索和文件上传跟着全挂接口超时率肉眼可见地上涨。原因全量查重任务里每个 chunk 都要调 embedding 服务、查向量库、写结果表这些操作占满了公共线程池和数据库连接。在线请求和离线任务抢同一批资源谁也跑不动。解决离线任务单独用一个线程池限制并发数并且不要把查重结果写回主业务库的频繁更新表。ThreadPoolExecutor dedupPool new ThreadPoolExecutor( 1, // 核心线程 1 2, // 最多 2 个并发 60, TimeUnit.SECONDS, new LinkedBlockingQueue(100) // 队列上限 100 );参数说明核心线程设为 1最多 2保证全量查重不会把服务拖死队列有界新任务进来如果队列满了直接拒绝并提示用户“前一查重任务未结束”。写进度用retrieval_task表状态位更新频率控制在 5 秒一次别每处理一个 chunk 就 update 一次那本身也是 IO 压力。5.5 相似度阈值拍脑袋定误判率上线就失控现象阈值设 0.75上线后发现两篇只是主题相关的文章被判成重复或者真正的搬运文一个都没查出来运营天天提 bug。原因不同领域、不同分块长度下余弦相似度的分布差异非常大。0.75 在一个语料下误报率 2%换个语料可能 30%。阈值必须跟着语料和切分参数走不能永恒不变。解决把第 4 章的标定脚本做成固定流程。每次接入新语料库先跑一轮分布统计把最佳阈值和对应的 F1 记到配置表里同时给查重报告加一个人工复核入口前端展示“疑似重复对”的段落高亮让用户点击“是/否重复”反馈数据定期回流到标定样本里。这就是为什么查重系统最核心的资产其实是那一批人工标注的相似文档对而不是模型和代码本身。6. 把检索结果做成能用的 GUI段落高亮、对比视图与一轮回归验证系统的最终体验在 Vue 这一层。检索结果不能只给一个文档列表用户要的是“为什么像、像在哪”。我的界面做法是左右分栏左边是当前文档的段落右边是相似文档的段落按相似度从高到低排列命中段落用同样的背景色高亮相似度大于阈值的段落再加一个右侧标签条显示分数。代码里一个最小的对比视图长这样template div classcompare div classcol v-fordoc in matchedDocs :keydoc.fileId div classfile-name{{ doc.fileName }}/div p v-forhit in doc.hits :keyhit.chunkId :class{ hotspot: hit.score threshold } {{ hit.chunkText }} /p /div /div /template script setup defineProps({ matchedDocs: Array, // 含 fileId, fileName, hits: [{chunkId, chunkText, score}] threshold: Number }); /scripthotspot样式控制背景色和左侧边框前端不需要再算任何相似度分数完全信赖后端返回这样前后端的口径是一致的。验证方法比界面更重要。我的习惯是准备 40 对已知“重复”和 40 对“不重复”的测试文档写一个脚本批量调用检索接口统计 top-1 召回率、阈值下的精确率和召回率作为每次改动后的回归测试。改分块窗口、换 embedding 模型、调阈值任何动作之后都得跑一遍跑挂了就知道是哪个参数引入的。这套测试样本比代码本身更值钱建议从第一天就开始积累。最后分享一个教训早先做查重系统我把重心全放在模型和向量库上阈值随手填了一个 0.75上线一周就收到一堆误报投诉。后来把标定脚本和回归样本固化进项目里每次调参都有依据界面也加了人工复核按钮误报明显收敛。技术方案本身不复杂复杂的是让它在真实数据上稳定工作。希望帮到你。本文还有配套的精品资源点击获取