ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java+Vue+pgvector实战:向量数据库驱动语义检索与相似文档查重

Java+Vue+pgvector实战:向量数据库驱动语义检索与相似文档查重 简介一份基于Java与Vue技术栈的向量数据库语义检索与相似文档查重系统详细项目实例面向具备Java和Vue基础的后端工程师、系统架构师及NLP技术人员适合学术论文查重、企业知识产权保护、网络内容监控、政务档案管理等语义比对场景。资源包为单个docx文档体积仅81KB却涵盖完整程序代码、数据库与GUI设计说明以及各模块代码详解重点覆盖BERT文本向量化、Milvus向量数据库存储与近似最近邻检索、前后端分离交互、查重阈值自适应、高亮比对报告生成等核心实现。内容按项目背景、挑战与解决方案、模型架构、代码示例等章节组织并在此基础上补充需求分析、系统架构设计、数据库建模、API接口规范与部署运维要点支持多格式文档解析、智能分段、安全权限控制等扩展能力可帮助读者搭建可扩展的智能语义检索平台。已有153人学习浏览适合高校科研、企业IT及文档数字化项目研发人员实践参考。1. 向量数据库做语义检索与相似文档查重这套 JavaVue 项目究竟解决什么问题向量数据库做语义检索与相似文档查重是把 NLP 从“调模型”带到“能上线”最近的一条路。做知识库问答、合同比对、资料归档的团队经常撞上同一个场景关键词搜索只认字面同义改写之后就漏文档量一大靠人工一篇篇翻着查重完全不现实。这个基于 Java Vue 的向量数据库项目核心就一句话把文档转成高维向量写入向量数据库让“语义相似度”变成“向量距离”检索和查重共用同一套索引。Java 后端负责文档入库、Embedding 接入和相似度计算Vue 前端负责检索交互与查重结果展示。它适合正在做文本检索系统、相似文档去重或知识库项目的 Java 工程师也适合刚接触 NLP 工程落地的同学照着复现。2. 为什么用向量数据库语义检索与相似文档查重共用同一块底座2.1 关键词和 SQL LIKE 先翻车一个字面匹配的典型漏判场景先看一个最常见的落地场景。你有一份《企业劳动用工合规检查指引》用户搜“员工离职经济补偿”用 SQL LIKE 去查可能什么都查不到而另一份标题里完全没有这些词、正文却在讲“解除劳动合同补偿金”的文档就这样从眼前溜过去了。这就是字面匹配的天花板它只认字符前后缀不认语义。语义检索想解决的是“意思相近才算相关”而不是“字面相同才返回”。Embedding 做的事情是把一句中文文本映射成一个几百维的浮点向量训练目标就是让语义相近的句子在向量空间里靠得近。“离职补偿”和“解除劳动合同补偿金”之间的距离应该远小于“离职补偿”和“公司团建”之间的距离。于是检索问题就转化成了计算查询向量与库内文档向量的距离。这一步是质的转变以前你维护的是倒排索引和分词词典现在你维护的是向量空间里的坐标。但这里有个容易被忽略的问题文档量到了十万、百万级以后把向量存放在普通关系表里你没法高效回答“离我最近的 Top10 是谁”。MySQL 的 B-tree 索引对高维浮点向量几乎无能为力全表扫一遍算距离在数据量上来以后直接不可接受。向量数据库天生就是解决这个问题的它用 HNSW、IVF 这类近似最近邻索引把“高维空间找邻居”从 O(N) 降到了对数级。这也是我为什么坚持在这个项目里单独引入向量数据库而不是把向量塞回业务库凑合。至于另一个常被拿出来对比的方向——图数据库我也在选型时认真看过。图数据库擅长关系遍历比如“人—组织—文档”的关联路径但它解决不了“两篇文档没有任何共同节点但语义高度相似”的情况。语义相似不需要显示的关系边它天然是向量空间里的距离问题。这就是“图数据库与向量数据库对比”之后我坚定选择后者的原因。2.2 向量数据库选型的边界pgvector、Milvus 与 Qdrant 怎么选市面上的向量数据库不少真正要在 Java 项目里落地我一般先画一张选型表按能接受的部署成本和团队现状来定而不是看哪个名气大选型部署方式Java 侧接入适合规模运维成本pgvectorPostgreSQL 扩展JDBC 直接连百万级向量够用最低Milvus独立分布式服务官方 Java SDK千万级以上高Qdrant独立服务SDK 或 REST千万级中Elasticsearch dense_vector依赖已有 ESREST API已有 ES 体系时加分中如果你的团队已经有 Elasticsearch给索引加 dense_vector 字段实现 kNN 检索是可行的前提是你接受 ES 那套映射和分片调优。如果团队只有 Java MySQL 存量最稳的起步路径反而是 pgvector它不是一个独立服务而是 PostgreSQL 的扩展Java 侧用 JDBC 就能操作向量列可以和业务字段放同一张表做关联查询。我这次项目的复现默认就选 pgvector理由是它对 Java 工程师的学习成本最低排错路径最短。规模上来之后再拆 Milvus 也来得及。常见的做法是后端先封装一层向量操作接口库内部用 pgvector 实现后续要换 Milvus 只替换这一层实现上层检索服务和 Vue 页面完全不用动。这样既满足了“先跑通”又给生产环境留了扩容余地。记住一点选型不是选最猛的是选你团队能养得活的。一个小团队上来就搭三节点 Milvus 集群最后往往没人运维索引坏了都不知道。2.3 三条处理链路入库、检索、查重如何共用一套向量索引这个系统从功能上看有三大块但底层的向量索引只有一套。第一是入库链路原始文档经过文本提取、清洗后调用 Embedding 模型得到向量连同文档标题、正文、哈希一起写入数据库。第二是检索链路用户在页面输入一句话前端把它交给后端后端用同一个 Embedding 模型转成向量在向量库里做最近邻搜索返回 TopK 结果和相似度。第三是查重链路新文档进来时先把全文转成向量再拿这个向量去库里找“最像的已存文档”相似度超过阈值就判定为重复或疑似重复。三条链路本质上都是“文本转向量 算距离”只是使用目的不同检索是拿用户的查询向量找内容查重是拿新文档的向量找老文档。正因为如此项目的大部分复杂度并不在业务 CRUD而在 Embedding 接入的一致性、向量的写入方式以及阈值怎么校准。理解了这三条链路后面的建表、接口和页面设计就都清晰了。前端 Vue 页面再好看后端如果没把 TopK、阈值这些参数透传好系统永远是纸上谈兵。3. 后端实现Spring Boot 接 pgvector 的建表、嵌入与查询3.1 建库建表与实体映射向量列和文档表到底怎么设计先建 PostgreSQL 扩展再创建两张表一张存文档本身一张存向量。分开存的原因是把向量列单独放便于控制索引的构建频率避免每次更新文档元数据都触发向量索引重建。-- 启用 pgvector 扩展 CREATE EXTENSION IF NOT EXISTS vector; -- 文档主表存标题、正文、哈希值 CREATE TABLE doc_record ( id BIGSERIAL PRIMARY KEY, title VARCHAR(255) NOT NULL, content TEXT NOT NULL, file_hash CHAR(64), created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now() ); -- 向量表doc_id 与主表一一对应embedding 是 768 维向量 CREATE TABLE doc_vector ( doc_id BIGINT PRIMARY KEY REFERENCES doc_record(id) ON DELETE CASCADE, embedding VECTOR(768) NOT NULL, created_at TIMESTAMP DEFAULT now() ); -- 为向量列创建 HNSW 索引 CREATE INDEX idx_doc_vector_embedding ON doc_vector USING hnsw (embedding vector_cosine_ops) WITH (m 16, ef_construction 64);这里的 EMBEDDING 维度写成 768是因为常用的开源中文 Embedding 模型如 bge-base-zh、text2vec-base-chinese 输出的都是 768 维。维度必须和模型实际输出一致否则插入时直接报错。vector(768)括号里的数字是强约束不是注释。HNSW 索引参数里m 16表示每个节点在图中的最大连接数数值越大召回越好但内存也会涨ef_construction 64是建索引时动态候选集的大小影响索引质量对查询速度影响不大。这两个参数在百万级以内用默认值起步没问题不要一上来就追求极端参数。用 MyBatis Plus 写实体类时业务表字段可以直接和doc_record对应但向量表我不建议靠代码生成器反推 DDL。网上确实有“mybatisplus 根据 Java 实体类生成创建表的 sql 语句”的做法普通字段可以一旦遇到VECTOR(768)类型和 HNSW 索引模板基本无能为力。我的习惯是实体类只用来映射查询结果建表 SQL 始终手写维护。这样最直观也不会被工具自作主张改掉索引定义。3.2 接入中文 Embedding 服务把文本变成 768 维浮点数组Java 后端并不直接加载模型常见做法是在 Python 侧封装一个 HTTP 推理服务加载开源中文 Embedding 模型对外暴露一个/embed接口。Java 只负责传文本进去、拿浮点数组出来。这样切分的好处是模型迭代不影响 Java 工程Java 工程师也不需要在 Maven 里引入一堆 Python 不友好的深度学习依赖。Service RequiredArgsConstructor public class EmbeddingService { Value(${embedding.base-url}) private String baseUrl; private final RestTemplate restTemplate; /** * 把一段文本转成 768 维向量 */ public float[] embedText(String text) { HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); MapString, Object body new HashMap(); body.put(texts, List.of(text)); body.put(truncate, true); // 超过模型最大长度时截断避免报错 HttpEntityMapString, Object request new HttpEntity(body, headers); ResponseEntityEmbeddingResponse response restTemplate.postForEntity(baseUrl /embed, request, EmbeddingResponse.class); if (response.getBody() null || response.getBody().getVectors().isEmpty()) { throw new RuntimeException(Embedding 服务返回为空); } return response.getBody().getVectors().get(0); } }这里有一个重要的隐藏约定truncate必须显式传 true。中文长文档经常超过模型的最大输入长度比如 BERT 系模型普遍是 512 个 token超长后如果服务端不做截断请求会直接 4xx。你在实现 EmbeddingService 时一定要先确认部署的 Python 服务支持什么参数、超出长度后是截断还是报错两边对齐后再把这段 Java 代码固化下来。Embedding 服务本身部署不复杂下载开源模型用 FastAPI 包一层启动后监听本地端口即可。需要注意模型必须固定线上的检索和入库用的必须是同一个模型文件。你要是今天换一个模型明天又换一个向量空间都变了之前入库的向量全部失效等于整个库要重建。3.3 检索与查重的核心接口相似度怎么算阈值怎么传语义检索接口是系统的门面。它把用户查询转成向量再用 pgvector 的余弦距离运算符做排序。返回的是余弦距离范围 0 到 2所以相似度要用1 - 余弦距离。Repository RequiredArgsConstructor public class VectorSearchDao { private final JdbcTemplate jdbcTemplate; /** * 语义检索返回与查询向量最相似的 TopK 文档 */ public ListSearchHit semanticSearch(String query, int topK, double minSimilarity) { float[] queryVector embeddingService.embedText(query); String vectorLiteral toPgVectorLiteral(queryVector); String sql SELECT d.id, d.title, d.content, 1 - (v.embedding ?::vector) AS similarity FROM doc_vector v JOIN doc_record d ON d.id v.doc_id WHERE 1 - (v.embedding ?::vector) ? ORDER BY v.embedding ?::vector LIMIT ? ; return jdbcTemplate.query( sql, new Object[]{vectorLiteral, minSimilarity, topK}, (rs, rowNum) - new SearchHit( rs.getLong(id), rs.getString(title), rs.getString(content), rs.getDouble(similarity) ) ); } private String toPgVectorLiteral(float[] vector) { StringBuilder sb new StringBuilder([); for (int i 0; i vector.length; i) { if (i 0) sb.append(,); sb.append(vector[i]); } sb.append(]); return sb.toString(); } }?::vector是把 JDBC 参数显式转换成 pgvector 类型不写这个强转PostgreSQL 有时无法从字符串自动推断类型。topK 一般取 10 到 20minSimilarity 的下限我建议由前端传入否则后端写死一个 0.7换一个领域语料就废了。把这些参数暴露给 Vue 页面让使用者自己拉阈值比你在后端拍脑袋决定要稳得多。相似文档查重的后端逻辑本质上就是“拿新文档的向量去库里搜索不过滤自己”。如果只重一两篇文档直接调 semanticSearch 然后排除自身就行。批量建库时文档多了就要换策略先把向量全取出来做聚类分桶再在桶内两两比较。不要写一个双层 for 循环去比 O(N²) 对文档我在测试时用一万篇文档跑过全量比对要卡几分钟线上绝对不能这么干。分桶粗筛后再做精确比对是生产环境里最常见的查重路径。4. Vue 前端落地检索交互、查重页面与跨域联调4.1 初始化 Vue 项目环境检查与依赖安装后端接口就绪后前端的事情从“vue 安装及环境配置”开始。很多人在这一步就卡住了其实是 Node 环境没配对。建议先用命令确认两件事Node 版本要在 16 以上npm 能正常访问仓库。# 检查环境和版本 node -v npm -v # 用 Vite 初始化 Vue3 项目 npm create vitelatest semantic-search-frontend -- --template vue cd semantic-search-frontend # 安装依赖 npm install # 额外安装请求库 npm install axios在前后端分离的项目里Vite 创建出来的项目默认是前端开发服务器端口是 5173后端 Spring Boot 默认跑在 8080。两个端口不同就会有跨域问题。这个问题留在后面小节处理这里先把项目跑起来看到 Vite 的默认欢迎页说明环境没问题了。目录结构我一般会整理成 views页面、api接口封装、components通用组件三层。语义检索页和查重页是两套不同的交互建议直接拆成两个 view。剩下通用部分我习惯写一个api/search.js统一封装 axios 实例这样后端地址换环境时只改一处。4.2 语义检索页搜索框、TopK 参数与相似度结果条语义检索页的核心交互很简单一个输入框一个检索按钮下面展示相似度倒序排列的结果列表。技术上要注意的是在 Vue 里调用后端接口要异步处理用 loading 状态防止用户重复点击。下面是最小可运行版本script setup import { ref } from vue import { semanticSearch } from ../api/search const keyword ref() const topK ref(10) const minSimilarity ref(0.6) const results ref([]) const loading ref(false) const handleSearch async () { if (!keyword.value.trim()) return loading.value true try { const { data } await semanticSearch({ query: keyword.value, topK: topK.value, minSimilarity: minSimilarity.value }) results.value data } finally { loading.value false } } /script template div classsearch-bar input v-modelkeyword placeholder输入一句话进行语义检索 keyup.enterhandleSearch / button :disabledloading clickhandleSearch {{ loading ? 检索中... : 检索 }} /button /div ul classresult-list li v-foritem in results :keyitem.id h3{{ item.title }}/h3 p{{ item.content }}/p div classsimilarity-bar span相似度/span div classbar div classbar-fill :style{ width: (item.similarity * 100) % }/div /div span{{ item.similarity.toFixed(4) }}/span /div /li /ul /template这个页面里的 minSimilarity 默认 0.6只是起步值。我实际项目里会给它加一个滑条让用户自己调。为什么不能写死因为不同 Embedding 模型产出的相似度分数分布差异很大同一个模型在不同的领域语料上分布也不一样。0.6 在这个模型上可能什么都召回不了换个模型可能全是误召。把阈值做成可调参数比在后端硬编码聪明得多。topK 同理知识库场景 10 条够用合同查重场景可能要看 20 条。结果列表里的相似度条用 CSS 宽度实现item.similarity * 100是常用做法。要注意相似度可能大于 1浮点计算误差会导致 100% 超出父容器我给外层 div 加了 overflow hidden这是细节但线上页面少了它容易穿版。4.3 相似文档查重页分组展示与可疑重复对查重页面和检索页面不一样它展示的是“文档与文档之间的关系”。基本交互是选中一个文档点击查重后端返回与该文档相似度超过阈值的所有文档前端按相似度排序展示。我的页面里会给每一条结果加一个重复标记方便人工二次确认。这里的 vue 插槽可以用来做自定义结果项扩展比如给高分结果加红色角标但我建议第一版先保持简单。script setup import { ref } from vue import { detectDuplicates } from ../api/search const docId ref(null) const threshold ref(0.8) const duplicates ref([]) const runDuplicateCheck async () { if (!docId.value) return const { data } await detectDuplicates({ docId: docId.value, minSimilarity: threshold.value }) duplicates.value data } /script template div label文档 ID/label input v-modeldocId typenumber placeholder输入文档 ID / label查重阈值/label input v-modelthreshold typenumber step0.05 / button clickrunDuplicateCheck开始查重/button div v-fordup in duplicates :keydup.targetDocId classdup-item span{{ dup.sourceTitle }}/span span与/span span{{ dup.targetTitle }}/span span相似度 {{ dup.similarity.toFixed(4) }}/span /div /div /template真正上线时查重页面很少做“输入文档 ID”这种操作更像是上传一个文档附件后端解析后返回相似文档列表。我这里保留文档 ID 是为了复现方便你可以先把两篇明显同一主题的文档入库再拿其中一篇的 ID 去查验证链路通不通。查重页面的核心价值是让人能直观看到相似度分数并判断后端返回的相似文档是否合理所以展示优先级高于样式先把逻辑跑通再考虑好看。4.4 联调跨域vite 代理和后端 CORS 的取舍前后端端口不同浏览器会直接拦截跨域请求。这是前后端分离项目最常见又最容易处理的坑。我推荐的做法是在 vite.config.js 里配置代理把前端请求转发到后端前端代码里只写相对路径不写后端地址。import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })加上这段配置后前端请求/api/search会被 Vite 开发服务器转发到http://localhost:8080/api/search浏览器和前端服务同源自然不存在跨域问题。生产环境则用 Nginx 做同样的反向代理前后端部署在同一域名下。后端的 CrossOrigin 注解也能解决但既然能用代理解决我一般不在代码里散落跨域注解因为那样生产环境如果换域名还得再改一遍代码。联调时遇到最多的现象是后端接口用 Postman 调得好好的前端一调就被拦截。这时候你先看浏览器控制台有没有 CORS 字样有的话先检查 vite 代理是否生效——注意 Vite 启动后要保存并重启才生效改完等它自动重启也行。确认代理没问题后再怀疑后端。5. 避坑记录五个最容易让系统翻车的细节与排查方法5.1 插入向量报 768 变 769维度错误是向量库第一道坎现象执行 INSERT 时报错大意是输入维度是 769但表定义是 768。解决这是我自己第一次接入 pgvector 时踩的坑。原因很隐蔽我把 float[] 转成 Postgres 数组时用逗号拼接后多了一个分隔符比如数组最后一个元素后面多了一个逗号Postgres 会把空字符串当成一个额外维度。这类问题发生在自行拼接toPgVectorLiteral时。解决方法是写完拼接逻辑后先在数据库里执行一个小数据量的 SELECT打印出数组长度核对。我后来直接在工具类里加了校验拼接完再数一遍逗号数量不对就抛异常从源头杜绝。另一个常见原因是 Embedding 模型换了输出从 768 变成 384但表结构没改。这是典型配置漂移解决办法是把模型版本写进配置中心每次模型升级时强制迁移向量表。5.2 查重阈值 0.8 漏、0.6 误阈值不是拍脑袋定的现象把查重阈值设为 0.8明显是同一篇文档改写而来的内容被判为不重复把阈值降到 0.6大量只是沾点边的内容都被标成疑似重复。原因不同领域语料的相似度分数分布差异很大合同文本和新闻稿的分布完全不同0.8 和 0.6 都没有普适性。解决我一般会先抽 50 组人工标注过的真实重复文档对跑一遍查重把相似度分数全部记录下来画出分布图——阈值就取“重复对和非重复对分区最明显”的位置而不是猜。这个校准过程会让人很不舒服因为你会发现同一模型跑合同和跑技术文档阈值能差出 0.15。5.3 长文档直接 Embedding检索结果“形似神不似”现象检索“新能源补贴政策”返回的文档里确实有“新能源”三个字但讲的是汽车购置完全没提补贴。原因长文档正文几千个字直接喂给 Embedding 模型模型能提取的只是整体语义细粒度主题会被稀释。解决入库前做分块按段落或按固定长度切块每块单独向量化检索时先命中子块再聚合到父文档返回。我在项目里的参数是每块 256 到 512 个 token块和块之间重叠 64 个 token避免切断完整句子。分块后虽然向量数量变多但检索精度提升非常明显。5.4 前端 axios 跨域被浏览器拦截CORS 的两种解法现象前端调用后端接口浏览器报blocked by CORS policy但同一个接口在 Postman 里返回正常。原因浏览器安全策略拦截了跨域请求Postman 不受这个限制。解决第一优先用 Vite 代理开发环境零侵入生产环境用 Nginx 反代。第二才是在后端加 CrossOrigin但加了之后要小心它会允许所有来源的跨域访问如果接口部署在公网可能有安全风险。我的建议是CORS 配置只在联调阶段开上线前务必收紧。5.5 HNSW 索引参数乱调查询变慢问题在索引而非数据量现象向量数据只加了五万条查询却越来越慢数据库 CPU 居高不下。原因HNSW 索引不是建了就完事它的 ef_search 参数控制每次查询遍历的候选节点数量。有人为了召回率把它调到 200查询变慢后还以为是数据量太大。解决把 ef_search 从默认 40 调回 40 到 80 之间先观察召回率是否下降得厉害再决定要不要继续加大。我一般用查询耗时和召回率两把尺子一起量单看召回率容易牺牲性能单看耗时容易牺牲效果。另外索引的 m 和 ef_construction 是建索引时的参数改完后要重建索引不是改个配置就生效这个和查询参数不同容易混淆。6. 效果验收与进阶用一组改写样本校准阈值再谈 Rerank 精排系统做完之后最容易犯的错误是“自认为效果好”。我建议你花半天时间做一次验收准备 20 组真实文档对每组包含一个原始文档和一个人工改写版本改写幅度尽量参考真实场景比如调整语序、替换同义词、增删句子。然后一组一组跑检索和查重统计 Top10 命中率和误报率。拿到分布数据后再回过头调 minSimilarity 阈值。这一步是系统的“后悔药”它能帮你把拍脑袋定的参数改成有据可查的配置。我见过团队上线后又因为阈值不合理返工那时文档已经灌了好几万条重建向量索引很痛。检查环节放在上线前能省下大量返工时间。进阶方向主要看三块。一块是 Rerank 精排向量检索只是粗排返回 Top30 后再用 CrossEncoder 模型对结果逐条打分重新排序后取 Top10效果提升非常明显代价是每一条都要过一遍模型延迟高几十毫秒。第二块是长文档分块后子块的向量聚合回文档的策略我试过平均池化、最大池化和加权池化平均池化最稳最大池化容易放大噪声。第三块是查重规模化文档到十万条之后先做聚类分桶再在桶内精确比对不要再用全量两两比较。我在第二个项目里把阈值从 0.65 调到 0.72召回率掉了 4 个百分点误报率却少了近一半。这个平衡点靠经验猜不出来一定是从自己的样本里测出来的。现在做新的语义检索项目我会把“构建人工标注样本集”写在排期里当成和建表一样重要的事情来做。希望这篇能帮你把这个项目跑通也替你把该踩的坑提前踩一遍。本文还有配套的精品资源点击获取
返回列表