ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java+Vue搭建向量检索查重系统:从原理到Spring Boot实战

Java+Vue搭建向量检索查重系统:从原理到Spring Boot实战 简介一份基于JavaVue的向量数据库语义检索与相似文档查重系统的详细项目实例面向具备Spring Boot、Vue基础从事自然语言处理、知识管理或内容安全的软件工程师与架构师。内容覆盖文档上传、文本向量化、Milvus近似检索、查重分析与可视化完整流程展开需求分析、系统架构、数据库建模、API规范、前后端实现与部署运维含完整程序代码、数据库设计和GUI设计。压缩包仅1个docx文档大小81KB已有153人学习浏览。docx按项目背景、挑战与解决方案、模型架构、模块描述及代码示例等目录组织重点讲解BERT语义向量生成、向量批量入库、相似度计算与查重核心逻辑并涉及多格式解析、智能分段、自适应阈值、高亮比对报告等设计。适合学术论文查重、企业知识产权保护、网络内容监控、政务档案管理等场景可作为智能检索与查重平台开发的实用参考。1. 基于 javavue 的向量检索查重系统先别急着写代码基于 javavue 的向量数据库语义检索与相似文档查重系统第一眼看上去像是把 Python 生态里才顺手的方案硬搬进 Java。实际做课设、毕业设计或企业内部文档去重工具这个组合比纯 Python 更现实Spring Boot 处理文档上传、权限和 MySQL 元数据Vue 负责检索交互真正跟向量相关的部分只需一个 embedding 生成脚本加一个向量库服务。这套系统解决文档太多、关键词搜不准、查重靠肉眼三个具体问题。适合需要交付完整系统的毕业生、想搭知识库或做合同审计工具的 Java 工程师它不要求你掌握训练模型核心是理解 embedding、向量比对和阈值设计。2. 向量语义检索与查重原理从关键词命中到向量距离为什么能抓到近似文本在动手写 Spring Boot 之前先想清楚一个前提关键词检索和语义检索的差距到底在哪里。ES 或 MySQL 的 LIKE 查询依赖倒排索引能匹配苹果发布新手机和苹果发布新手机这种完全相同或同字面的文本但遇到iPhone 出新款了就断了。倒排索引对同义词、语序调整和概括性改写几乎没有应对能力。向量数据库解决的正是这个问题把文本编码成稠密向量用几何距离衡量语义关系。这套系统的名字里同时有语义检索和相似文档查重两者共用一套向量库但行为目标不同。检索是给定一句 query返回与之最相关的文档片段查重是给定一批文档找出互相重复的内容。下面把这两个目标分开讲顺便把向量库选型讲清楚。2.1 语义检索为什么比关键词检索强embedding 与余弦相似度的关系先说 embedding 的本质。一段文本经过模型编码后输出一个固定长度的浮点数数组例如 bge-small-zh-v1.5 输出 512 维向量m3e-base 输出 768 维。在这个高维空间里语义接近的文本向量距离近语义无关的向量距离远。衡量距离最常用的是余弦相似度即 (A·B) / (|A| × |B|)。两个向量方向一致时值接近 1如果嵌入模型在输出前做了归一化余弦相似度等价于点积Qdrant 返回的 score 可以直接当作相似度读取。对 Java 工程师来说这里有个容易误解的分工向量计算并不在 Java 里做。常见做法是起一个独立的 Python 服务用 sentence-transformers 加载中文模型对外暴露一个 HTTP 接口Java 把待编码文本传过去拿回浮点数组。Java 端真正负责的是把向量交给 Qdrant并由 Qdrant 完成相似度搜索。这样拆开的好处是模型切换、版本升级都只在 Python 侧发生Java 和向量库之间只需要对齐维度。embedding 服务的超时时间要留足。CPU 环境下一条 200 字的中文文本编码大约需要 100 到 300 毫秒批量编码时一次传 32 条是最稳妥的。编码完的向量建议统一归一化这样后续 Qdrant 里无论配 Cosine 还是 Dot 距离分数口径都是一致的。为了说明语义距离的效果举一个实际例子。在倒排索引里甲方逾期付款应当承担违约责任和买方拖欠货款需要赔偿没有匹配但两个句子的向量相似度可以到 0.85 以上这对查重来说已经是高危信号。如果只靠关键词查重这类改写过的重复条款几乎都会漏掉。2.2 相似文档查重的本质向量距离、阈值与去重分组检索和查重的差异在工程上很明显检索返回 topN交给用户自己判断查重要给出是否重复的判定结果并且把疑似重复的文档聚合到一个组里。既然是判定就必须有阈值。阈值定太高漏报定太低误报而系统一旦大量误报用户会直接失去信任所以查重模块的阈值设计比检索模块的 topN 设计敏感得多。没有做过向量检索的团队第一版常会用把整篇文档编码成一个向量然后两两算相似度的做法。这个方案在小规模数据上能用但有一个明显的缺陷文档变长后局部重复会被大量不重复内容稀释。比如一份 5000 字的合同只有其中一段 800 字的条款和其他文档重复整篇编码后相似度可能只有 0.7 不到完全触发不了阈值。正确做法是先把文档切成 chunk。我一般用固定窗口切割文本长度按 160 到 240 字比较稳窗口重叠 20 到 40 字。切片粒度有讲究太小了碎片噪声大太大了局部重复容易被稀释。切片后每个 chunk 单独编码入库payload 里记录文档 ID 和序号。查重时让一个 chunk 的向量去向量库检索如果命中的点属于另一篇文档且分数超过阈值就记录一条候选重复对。所有候选对收集完之后按文档 ID 做分组自然就得到查重报告。阈值没有通用答案。以 bge-small-zh 为例在业务文档上从 0.82 起步比较稳m3e 系列的分数普遍偏高要用 0.85 或 0.86 起步同名章节、模板填空这类高度模板化的文本阈值甚至可以到 0.9。换一次 embedding 模型阈值必须重新标定这一点后面第 5 章会展开讲。2.3 向量数据库选型Qdrant、Milvus、Chroma 在 Java 生态里的取舍向量检索可以不做成独立服务直接在 Java 内存里维护一个 Listfloat[] 也能完成 demo 级检索。但数据超过几万条之后暴力遍历开始卡顿而且文档还要支持按业务字段过滤这时候就该引入真正的向量数据库。国内从业者常用的向量库主要是 Qdrant、Milvus、Chroma 三个下面把对 Java 栈的适配情况放在一张表里。对比项ChromaMilvusQdrant部署成本Python 进程本地文件库依赖 etcd、MinIO 等组件偏重单个 Docker 容器即可Java 客户端官方没有完整 Java SDK靠 HTTP 拼接官方 Java SDK版本节奏偏慢官方 Java 客户端REST 和 gRPC 都支持数据模型collection / embedding偏研究和 democollection / partition面向海量collection payload适合带业务字段过滤单机 50 万条 512 维向量能跑性能一般能跑但部署成本明显高于收益稳定内存占用可预期选型结论很直接课设、毕设和中小企业的知识库场景优先 Qdrant数据量到千万级、需要分布式扩容时再考虑 Milvus。Chroma 的好处是起步最快但它的 Java 生态最弱对 Spring Boot 项目不是首选。另一种常见做法是继续用 ES 的 dense_vector 字段如果团队 ES 已经跑得很熟且数据量不大也能落地但在 HNSW 索引参数调节、payload 过滤、批量 upsert 的便利性上它和专门向量库的体验差距明显。还有一点需要提前意识到向量数据库只负责存和查文档的元数据、上传文件本身、查重分组结果仍然放在 MySQL。两边的关联方式就是 payload 里的 docId这个设计在后面建表时会体现出来。先把概念理清再进入具体实现。3. Spring Boot Vue 落地最小系统Word 入库、向量检索与查重接口一条线跑通在动手前先定技术栈版本避免后面环境对不上后端用 JDK 17 Spring Boot 3.x前端用 Vue 3 Vite数据库用 MySQL 8向量库用 Qdrant。JDK 8 环境下 Spring Boot 2.7 也可以跑但 Qdrant 官方 Java 客户端要求的最低版本是 Java 11所以 JDK 17 是最省心的选择。前端不装额外重型组件库用 Element Plus 就够。3.1 环境准备与工程骨架MySQL 建三张表、Qdrant 容器和 Maven 依赖先启动 Qdrant。REST 端口 6333 用于调试和 Python 脚本接入gRPC 端口 6334 供 Java 客户端使用两个端口都要映射出来。docker run -d --name qdrant \ -p 6333:6333 -p 6334:6334 \ -v qdrant_storage:/qdrant/storage \ qdrant/qdrant-v把向量数据落到命名卷里容器重启不丢数据。启动后用curl http://localhost:6333/collections确认返回空数组说明服务正常。MySQL 侧建三张核心表。doc 表存文件元数据chunk 表存切片文本和向量库 pointId 的关联duplicate_pair 表存查重命中的候选对。CREATE TABLE doc ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, file_path VARCHAR(500), chunk_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE chunk ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_id BIGINT NOT NULL, seq INT NOT NULL, content TEXT NOT NULL, vector_id VARCHAR(64) NOT NULL, KEY idx_doc_id (doc_id) ); CREATE TABLE duplicate_pair ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_a_id BIGINT NOT NULL, doc_b_id BIGINT NOT NULL, max_score DOUBLE NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_pair (doc_a_id, doc_b_id) );doc_id 和 vector_id 是关键关联字段vector_id 存 Qdrant 的 point id写的是 docId-chunkSeq 这种业务可见字符串排查问题时可以直接去 Qdrant 里定位。chunk 表把原文落了一份在 MySQL检索结果回显时不用每次反向查 Qdrant payload向量库意外丢失时也能作为重建依据。后端 Maven 依赖主要有两块POI 用来解析 Word 文档Qdrant 客户端用来操作向量库。dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency dependency groupIdio.qdrant/groupId artifactIdclient/artifactId version1.12.0/version /dependencyPOI 5.x 对应 JDK 8 以上没问题。Qdrant 客户端版本换过几次命名空间1.x 统一使用io.qdrant.client网上搜到旧项目里com.qdrant.client的 import 是旧版社区包直接升级依赖并把 import 改过来。3.2 文档解析与文本切片POI 读 Word切片后交给 Python 服务做 embedding前端上传的 Word 落到本地临时目录后后端先用 POI 解析正文。注意 doc 和 docx 是两种格式docx 用 XWPF 解析老式 .doc 要用 HWPF混用会直接解析失败或读出乱码。ListString extractParagraphs(File file, boolean isDocx) throws Exception { ListString texts new ArrayList(); if (isDocx) { try (XWPFDocument doc new XWPFDocument(new FileInputStream(file))) { for (XWPFParagraph p : doc.getParagraphs()) { String t p.getText().trim(); if (!t.isEmpty()) texts.add(t); } for (XWPFTable table : doc.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { String t cell.getText().trim(); if (!t.isEmpty()) texts.add(t); } } } } } return texts; }doc.getParagraphs()拿不到表格里的内容所以要单独遍历表格。如果后续要兼容 PDF可以用 pdfbox 转文本后走同样的切片流程这样即便本地没有对应文档解析器也能兜底。切片是查重精度的第一个隐藏参数。固定窗口容易实现我习惯按 200 字切、重叠 20 字。public static ListString splitChunks(String text, int size, int overlap) { ListString chunks new ArrayList(); int start 0; while (start text.length()) { int end Math.min(start size, text.length()); chunks.add(text.substring(start, end)); if (end text.length()) break; start end - overlap; } return chunks; }size设 200overlap设 20。重叠的意义是防止重复内容恰好落在两个窗口的交界处被切开20 字对中文来说能覆盖大部分句子边界。如果文档类型偏短文本比如工单和公告size 缩小到 64、重叠设为 8 到 16 会更好。切好的文本需要转成向量。这里采用独立的 Python embedding 服务避免在 JVM 里跑深度学习推理的复杂度。from fastapi import FastAPI, Request from sentence_transformers import SentenceTransformer app FastAPI() model SentenceTransformer(BAAI/bge-small-zh-v1.5) app.post(/embed) async def embed(req: Request): body await req.json() texts body[texts] vectors model.encode(texts, normalize_embeddingsTrue).tolist() return {vectors: vectors}Java 侧调用这个接口时把一批 chunk 文本打包成 JSON POST 过去一次最多传 32 条。normalize_embeddingsTrue让输出向量长度归一为 1后续 Qdrant 用 Cosine 距离时score 才是稳定的余弦相似度。遇到接口超时先查 Python 进程的模型加载路径和内存占用大多数慢请求都是冷启动或模型没进内存造成的。3.3 写入与检索Java 客户端操作 Qdrant 的 collection、upsert 和 search接着实现对 Qdrant 的写读。第一步创建 collection关键参数是维度和距离度量。QdrantClient client new QdrantClient( QdrantGrpcClient.newBuilder(localhost, 6334, false).build()); client.createCollectionAsync(doc_chunks_bge512, VectorParams.newBuilder() .setSize(512) .setDistance(Distance.Cosine) .build()).get();collection 名我固定带模型标识bge512这样从 bge-small-zh 换到 m3e-base 时直接新开一个doc_chunks_m3e768新旧数据不会互相污染。setSize(512)必须与 embedding 模型输出维度一致bge-small-zh-v1.5 是 512 维m3e-base 是 768 维一旦建错只能删了重建。写入点数据时除了向量还把 docId、chunkSeq、原文内容都塞进 payload。ListPointStruct points new ArrayList(); for (int i 0; i vectors.size(); i) { long pointId docId * 100000 i; // docId 为 MySQL 主键 points.add(PointStruct.newBuilder() .setId(PointId.newBuilder().setNum(pointId).build()) .setVectors(Vectors.newBuilder().setVector(vectors.get(i))) .putPayload(docId, new Value().setStringValue(String.valueOf(docId))) .putPayload(chunkSeq, new Value().setIntegerValue(i)) .putPayload(content, new Value().setStringValue(chunks.get(i))) .build()); } client.upsertAsync(doc_chunks_bge512, points, new UpsertPoints(true)).get();UpsertPoints(true)表示等待写入完成再返回保证后续检索能查到。pointId 用docId * 100000 chunkSeq生成前提是 docId 不超过 99999数据量再大建议改成 UUID并在 payload 里保留 docId 和 chunkSeq 字段。pointId 在排查问题时可以直接看出向量属于哪个文档第几段。检索接口的代码量反而不大。ListScoredPoint hits client.searchAsync(doc_chunks_bge512, SearchPoints.newBuilder() .setVector(queryVector) .setLimit(10) .setWithPayload(true) .build()).get();setLimit(10)控制返回候选数。检索接口返回 10 个点是合理的查重场景要放宽到 20 到 30因为候选少了会把轻度重复漏掉。setWithPayload(true)让结果把 content 一起带回来前端直接展示命中片段不需要再回 MySQL 查一遍。要注意getScore()的分数范围和模型、距离度量强相关建议在检索接口里对分数做一次展示层归一化否则用户看到 0.8 会误以为是 80 分。3.4 相似文档查重用检索接口反向扫描按阈值生成重复分组查重和检索共用同一个向量库但代码目标不同。对一份文档的每个 chunk拿它的向量去全库检索命中别的文档且分数超过阈值就记一对候选。double threshold 0.82; MapString, SetString pairMap new HashMap(); for (Chunk chunk : chunks) { ListScoredPoint hits searchChunk(chunk.getVector(), 30); for (ScoredPoint hit : hits) { String hitDocId hit.getPayload().get(docId).getStringValue(); if (hitDocId.equals(docId)) continue; if (hit.getScore() threshold) { pairMap.computeIfAbsent(docId, k - new HashSet()).add(hitDocId); } } }这段逻辑里最容易被忽略的是hitDocId.equals(docId)这一行。同一文档的相邻 chunk 在重叠窗口下会被自己命中如果不跳过自身查重结果会大量出现自己和自己重复的假阳性。候选对收集完成后按文档 ID 做连通分组一个组就是一份疑似重复的文档集合输出时带上每组最大命中分数作为排序依据。查重是离线任务建议用 Spring 的Async跑不要在文件上传请求里同步做。任务进度写入一张 task 表前端轮询状态。工程上最常见的问题是索引任务做一半挂了doc 表有了记录chunk 表空着再跑一遍又重复入库。我的做法是让任务天然可重跑先插入 doc 记录解析切片后写入 MySQL调 embedding 服务再写 Qdrant最后更新 doc.chunk_count。任一步失败doc 都是中间状态定时任务把 chunk_count 为 0 且创建时间超过 10 分钟的记录重新拉起来执行。3.5 Vue 检索页与查重报告axios 联动、结果表格和状态轮询前端最小可用的页面只有两个上传页和检索页。上传页负责把 Word 文件交给/api/upload后端返回 taskId检索页提供一个输入框和结果表格。template el-input v-modelquery placeholder输入一句话检索文档内容 clearable keyup.entersearch / el-table :dataresults stripe el-table-column propdocId label文档编号 width100 / el-table-column propcontent label命中片段 show-overflow-tooltip / el-table-column propscore label相似度 width110 / /el-table /template script setup import { ref } from vue import axios from axios const query ref() const results ref([]) async function search() { if (!query.value.trim()) return const { data } await axios.get(/api/search, { params: { q: query.value } }) results.value data } /scriptVue 3 的script setup下异步响应式数据直接用ref就行不需要this。axios 的params会把对象拼成 query string后端用RequestParam(q)接收。这里要处理好请求竞态用户快速输入两次第一次响应晚到会覆盖第二次的结果。简单做法是每次请求前记录一个自增序号响应回来时只接受最新序号的数据。查重报告页面用同样的表格组件展示加上一个导出 CSV按钮即可后端生成 CSV 后用window.open触发下载。4. 避坑与排查这套系统最容易翻车的 5 个位置从环境到阈值都有后悔药一个看似简单的业务逻辑做到生产环境经常被环境问题磨掉半天时间。下面这几条是从解析到阈值的踩坑记录每条都是真实现象附上原因和解决方式。这套系统的后悔药不多但都很具体。4.1 Word 解析乱码与表格内容缺失现象docx 文件用 Office 打开正常导入系统后段落变成乱码或者整个表格内容在库里完全没有记录。原因docx 和 doc 是两种完全不同的打包格式POI 里 XWPF 处理 docxHWPF 处理 doc。只写一个 XWPF 解析器遇到 .doc 文件要么抛异常要么读出乱码表格内容又不属于XWPFParagraph需要单独走getTables()遍历。解决上传后根据文件扩展名判断解析器doc 和 docx 拆成两个方法。清洗文本时把\r\n、多个连续空格和特殊空白字符统一替换成单个空格避免切片后出现孤立换行符导致的空 chunk。还要检查 MySQL 连接 URL 是否带上characterEncodingutf8否则入库后中文变乱码乱码再一路带进向量库检索结果全是看似匹配实则乱码。4.2 别在 JVM 里直接跑 embedding 模型现象用 DJL 加 ONNX 在 Java 侧加载 bge 模型启动耗时 30 秒以上CPU 推理一条文本也要几百毫秒并发一高整个检索接口直接卡死。原因Java 的深度学习推理栈在中文文本 embedding 这一侧的生态远没有 Python 的 sentence-transformers 顺手。模型文件转换、词表加载、版本匹配每一步都可能因为依赖版本对不上而翻车调试成本极高。解决把 embedding 做成独立 FastAPI 服务Java 只做 HTTP 调用。服务用 uvicorn 启动CPU 核数够就开 2 到 4 个 worker不够就单进程避免模型重复加载撑爆内存。这样替换模型只在 Python 侧进行Java 代码完全不用动。如果团队不允许引入 Python次优方案是用 ONNX Runtime 的 Java API 跑一个转好的 bge-onnx 模型但你要有心理准备去处理 tokenizer 和 token 长度对齐的问题。4.3 collection 建完就报错维度与距离度量必须一次写对现象createCollectionAsync传了 512 维upsert 时报 dimension mismatch或者搜索结果分数忽高忽低像是随机的。原因Qdrant 的 collection 创建后 schema 不可变维度写错只能删了重建。距离度量选了 Dot但 embedding 没有归一化分数完全不能作为相似度参考。解决建 collection 前先用 Python 打印模型输出向量的长度和范数确认维度后再写代码。度量统一用 Cosine同时 Python 侧编码时设置normalize_embeddingsTrue。我给 collection 起的名字里带上维度标识比如doc_chunks_bge512换模型时不会把旧 collection 里的脏数据带到新流程。4.4 查重结果全是相似或全不相似阈值和 embedding 模型绑定现象同一批文档用 m3e-base 跑出来的相似度普遍比 bge 高 0.03 到 0.05阈值沿用网上经验值 0.85结果全是疑似重复换回 bge 又漏报。原因不同 embedding 模型的分数尺度完全不同甚至同一模型的 checkpoint 更新也会改变分布。查重阈值没有通用的良好实践值必须基于当前模型和当前数据分布重新标定。解决准备 20 对已知重复的文档和 20 对已知不重复的文档跑一遍得到分数分布在两组分布重叠区间的中点附近取阈值。初始值可以从 bge 的 0.82 开始然后看误报率再微调。这个校准工作最好沉淀成一个带标注的小脚本以后换模型或换语料都能复用。4.5 上传超时、跨域报错与索引中间状态现象前端 axios 报跨域错上传一个 20MB 的 Word 文件请求在 Nginx 处 504 超时任务跑到一半 Python 服务挂掉MySQL 里的 doc 表和 Qdrant 里的向量数据对不上。原因Vite devServer 端口5173请求 Spring Boot8080跨域需要在后端配 CORS 或前端配 Vite proxy。大文件同步处理本来就慢加上转发层默认超时时间短前端等不到结果。embedding 服务崩溃后doc 元数据已入库但向量库缺失重复跑会生成重复数据。解决跨域配置只选一处生效不要同时配 CORS 和中间件转发。上传接口只做落盘和返回 taskId索引流程全部异步前端用轮询查询状态。索引任务设计成幂等docId 已存在就跳过入库向量写入前先按 pointId 检查是否已存在。这些细节看着琐碎但实际运行起来它们决定了这个系统是用来演示还是能稳定跑下去。5. 把系统从能跑推到能用Embedding 模型选择、切片策略与 Vue 交互细节系统能跑通之后下一步是让检索结果可信、查重误报可控、页面用得顺手。这一章包含三个调优方向和一个前端细节每一项都可以单独验证效果。5.1 换一个 embedding 模型召回效果立竿见影模型选择对中文语义效果的影响最大甚至超过后续调参。三个常用模型的对比模型输出维度中文语义表现体积适用场景BAAI/bge-small-zh-v1.5512稳定通用约 100MB检索 查重首选m3e-base768语义理解更强约 400MB需要更细语义区分度text2vec-base-chinese768短文本友好约 400MB短句、标题、摘要模型不是越大越好。CPU 推理场景下bge-small 的单条编码耗时大约是 m3e 的一半而且它的 512 维在单机 Qdrant 里占用内存更小。选择失误会让召回表现差很多换来换去也让人烦躁。如果固定使用 bge-small-zh-v1.5建议在 Python 服务的依赖文件里锁死模型名称和版本。重新下载模型时不同 checkpoint 的 embedding 分布有细微差别可能导致先前校准的阈值失灵。维度一定对齐m3e-base 换回 bge 时collection 记得用新名字。5.2 相似度阈值的校准方法不靠感觉靠小样本分布前面已经多次提到阈值和模型强绑定这里说具体怎么标定。从现有文档库里挑 20 对明显重复和 20 对明显不重复的内容每一对都计算向量相似度打印两组分数范围。若重复组最低分是 0.87不重复组最高分是 0.79那阈值取 0.83 到 0.85 之间都有余量。若两组有交集优先靠近重复组的高分侧减少误报宁漏勿错——查重系统误报太多比漏报更难解释。模板化文档要特别注意阈值差异。合同、官方文书这类文本模板高度统一正常不重复的两段内容也可能有 0.9 以上的基础相似度此时阈值要往上提到 0.93 之类。这个经验没法直接迁移最现实的路径是每接入一个领域语料就重复一遍小样本校准。5.3 切片窗口与 payload两个决定查重精度的隐藏参数切片长度和重叠窗口直接决定局部重复是否会被稀释。业务文档用 200 字、重叠 20 字起步如果文档平均每段短可以把窗口收窄到 64 字。切片后对入库的 payload 字段做一次最终确认payload 字段用途说明docIdMySQL 文档主键查重分组、去重的关键关联chunkSeq切片段落序号保证原文顺序回显时拼接content文本片段全文前端摘要展示免去回表查询sourcePath原始文件路径出问题时能定位到文件查重执行时还有一个细节重叠窗口造成的相邻 chunk 互相命中除了跳过同 docId还可以进一步用 chunkSeq 相邻判断过滤。比如 A 文档第 3 段命中 A 文档第 4 段在查重场景里不是跨文档重复直接过滤。这条过滤规则放在检索接口之外避免影响正常语义检索的结果。5.4 Vue 交互细节检索竞态、任务轮询和路由懒加载前端做深了会发现流程比后端更容易出问题。检索接口的响应时间不稳定用户连续输入会触发上一次响应覆盖新结果解决办法是维护一个请求序号只接受最新响应。上传大文件时页面长时间没有反馈我用状态轮询做任务进度/api/upload返回 taskId前端每隔 2 秒请求/api/task/{taskId}状态从 PROCESSING 变 DONE 后自动跳转检索页。查重报告导出用 CSV后端生成临时文件前端window.open下载不要在浏览器里拼表格。另一个容易忽略的地方是路由设计。检索页和查重报告页用vue-router的懒加载导入component: () import(...)这样首屏不会一次性加载 Element Plus 的完整组件树。从检索结果页跳转到该文档的查重报告时把 docId 作为路由参数传给报告页后端按 docId 查 duplicate_pair 表即可前端不需要维护全局状态。6. 查重复核的进阶技巧向量召回 精排别再做全库两两比对系统上线前你可能会想全库两两查重能不能做数据量在 1 万篇文档以内把每个 chunk 都和其他 chunk 比较一次是 O(n²)单机勉强能跑数据量一到 5 万篇全库比对的时间就不可接受了。更重要的是两两比对的粒度太粗两篇文档同一处改写其他部分完全不相关整体相似度会被拉低到阈值以下。我在做完一个合同库之后换成了向量召回 精排复核两阶段方案。第一阶段用 Qdrant 做召回。每个 chunk 的向量去检索 topN放宽到 30 个候选这时候记录的是可能相关的文档对。第二阶段对候选对做精排精排指标不依赖 embedding而是计算两段文本的重叠程度。常用公式是finalScore 0.7 * vectorScore 0.3 * overlapRatio其中 overlapRatio 用两个文本的 4-gram 集合重叠比例。交集多说明这批候选确实是逐字或近乎逐字的重复而不是语义擦边的相似。double finalScore(double vectorScore, String textA, String textB) { double overlap overlapRatio(textA, textB); return 0.7 * vectorScore 0.3 * overlap; }权重系数是可调的不是数学公式。如果你想验证这个方案是否有效找 10 组已知重复的文档跑一遍把第二阶段是否能把每组都筛出来作为通过标准。我自己的教训是第一次只靠单一阈值做查重为了减少误报把阈值调高结果漏掉了一份改写了措辞的合同改成两阶段之后召回阶段保持宽松精排阶段收紧每个阶段只调节一个变量问题清晰了很多。这个分段调参、分段验证的思路同样适用于检索与查重的其他参数。希望帮到你。本文还有配套的精品资源点击获取
返回列表