简介:面向具备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/upload; - Java 接收文件流,落盘保存,并在
document_file表插入一条状态为“待建索引”的记录; - 后台任务读取文件内容:txt、md 直接按 UTF-8 读;PDF、Word 需要用解析库抽文本,这一步是后续所有效果的前提;
- 把抽取出来的文本按段落或定长窗口切成 chunk;
- 每个 chunk 调 embedding 服务拿到向量,连同 chunk 原文和 fileId 写入向量库;
- 检索时,用户输入一段文本或上传一篇文档,同样做向量化,然后去向量库做 top-k 近邻搜索;
- 后端把命中的 chunk、文档名、相似度分数返回给 Vue,Vue 渲染对比视图。
先看最小的上传接口实现:
@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, () -> { List<TextChunk> chunks = textParser.getChunks(fileId); // 3. 向量化 + 写向量库 vectorIndexService.indexChunks(fileId, chunks); }); return Result.ok(fileId); } }逻辑说明:这个接口把“存文件”和“建索引”拆开,接口立刻返回 fileId,客户端拿到后可以轮询任务状态。“parseAsync”里传了一个回调,索引完成后把状态位改成“完成”,前端才能显示“可检索”。这样做的原因很实际:一份几十页的 PDF,解析、切分、向量化加起来可能要好几秒,同步等会直接拉垮上传体验。
参数说明:fileStore负责文件存储,单机部署就存本地磁盘,部署到服务器就换成 OSS;textParser的解析规则按文件后缀分发,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 List<TextChunk> split(String rawText, int maxLen, int overlap) { List<TextChunk> 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()); // 建 collection:dimension 必须和 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(List<TextChunk> chunks) { List<Long> ids = new ArrayList<>(); List<List<Float>> vectors = new ArrayList<>(); for (TextChunk c : chunks) { List<Float> vec = embeddingService.embed(c.getText()); ids.add(c.getId()); vectors.add(vec); } List<InsertParam.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 List<Hit> search(String queryText, int topK) { List<Float> 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(); List<List<SearchResultData>> 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 Map<Long, List<ScoredPair>> pairwiseDedup(List<ChunkVector> all, double threshold) { Map<Long, List<ScoredPair>> 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 到 n,j 从 i+1 到 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(f"best threshold: {best_th}, best f1: {best_f1}")说明:这个脚本的输出是一个阈值,但更重要的输出是分布本身。如果正负样本的重叠区间很大,说明模型或分块策略有问题,调阈值只是事后补救。重叠区间小,阈值才有一刀切的底气。
实际调的时候,得先决定你要偏哪边:查重系统如果偏向“宁可误报不可漏报”,阈值就往下压,代价是人工复核成本上升;如果偏向“报案少而精”,阈值往上抬。我一般把初始阈值设在分布图上正样本的 P10 分位,再按人工抽检结果微调。
4.3 文档级聚合:段落相似度合并成文档相似度的三种策略
chunk 级别的相似度有了,还要汇总成“文档与文档像不像”,因为用户看到的是一篇文档,不是一个段落。三种常见策略:
- Max:取所有命中段落里的最高相似度,适合“抄了一段就是抄”的场景,论文查重、合同比对常用;
- Mean:对所有段落相似度取平均,适合整体风格或主题相似度的评估,新闻聚合、文章推荐好用;
- 加权:按段落长度加权,长段落对结果的影响大于短段落,避免一两句口水话拉高相似度。
加权在 Java 里的实现:
public double weightedDocSimilarity(List<Double> sims, List<Integer> 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].*")) { List<Float> 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 class="compare"> <div class="col" v-for="doc in matchedDocs" :key="doc.fileId"> <div class="file-name">{{ doc.fileName }}</div> <p v-for="hit in doc.hits" :key="hit.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 }); </script>hotspot样式控制背景色和左侧边框,前端不需要再算任何相似度,分数完全信赖后端返回,这样前后端的口径是一致的。
验证方法比界面更重要。我的习惯是准备 40 对已知“重复”和 40 对“不重复”的测试文档,写一个脚本批量调用检索接口,统计 top-1 召回率、阈值下的精确率和召回率,作为每次改动后的回归测试。改分块窗口、换 embedding 模型、调阈值,任何动作之后都得跑一遍;跑挂了就知道是哪个参数引入的。这套测试样本比代码本身更值钱,建议从第一天就开始积累。
最后分享一个教训:早先做查重系统,我把重心全放在模型和向量库上,阈值随手填了一个 0.75,上线一周就收到一堆误报投诉。后来把标定脚本和回归样本固化进项目里,每次调参都有依据,界面也加了人工复核按钮,误报明显收敛。技术方案本身不复杂,复杂的是让它在真实数据上稳定工作。希望帮到你。
本文还有配套的精品资源,点击获取