ARTICLE DETAIL

资讯详情

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

Java接入向量数据库:从零搭建语义搜索引擎

Java接入向量数据库:从零搭建语义搜索引擎 最近在做公司内部知识库的文档检索系统遇到一个很典型的场景同事在搜索框输入「上个月的销售总额是多少」结果返回空但知识库里明明有一份标题叫《月度经营分析报告》的文档里面清清楚楚写着当月销售额。问题出在哪传统检索靠的是关键词精确匹配用户问法和文档写法一旦对不上就是搜不到。这个项目就是解决这类问题的——用 Java 接入向量数据库把文档内容和用户 query 都转成向量靠语义相似度做检索。用户搜的是「销售总额」文档里写的是「营收情况」在向量空间里这俩距离很近照样能召回。这篇文章我会把选型、原理、Java 代码实操、还有踩过的坑都写清楚。适合有 Java 基础、想给系统加语义搜索能力或者想入门 RAG检索增强生成的开发者。1. 项目目标与整体方案拆解1.1 传统检索为什么不够用语义搜索解决了什么先搞清楚一个基本概念传统搜索引擎比如 Elasticsearch 的全文检索底层是倒排索引加 BM25 算法。它的工作方式是把文档拆成词元建立「词 → 文档」的映射。查询的时候同样把用户输入的句子拆成词元去倒排索引里找哪些文档包含这些词。词出现的频率越高、越稀有的词权重越大最后按分数排序返回。这种方式在关键词重叠度高的场景下表现不错但它有一个天然缺陷——它匹配的是「字面」不是「语义」。用户问「怎么退款」文档里写的是「退货流程」这两个句子在字面上几乎没有重合词BM25 的得分会很低文档排到后面去甚至直接搜不到。再比如用户说「车辆油耗太高」文档里写的是「每百公里燃油消耗量」传统检索同样无能为力。向量检索的思路完全不同。它先把每个文档切片后的文本交给一个 Embedding 模型模型把这段文字编码成一个高维向量比如 768 维。这个向量有几个特性语义相近的文本它们的向量在空间里距离近语义无关的文本向量距离远。用户查询的时候把 query 也编码成向量然后在向量数据库里找距离最近的 K 个向量对应返回原始文本。用生活类比的话传统搜索像是按姓名找人你必须知道对方叫什么向量搜索像是按长相找人你不认识名字但只要描述到位就能找到人。这两个方案不是互斥的实际生产里经常混合用后面我会专门说混合检索的玩法。1.2 向量数据库选型四个主流方案的硬核对比说干就干但第一步就卡住了——选哪个向量数据库。我调研的时候市面上方案很多挑了四个最有代表性的列个表对比方案本质Java 接入难度部署复杂度适合场景Milvus独立向量数据库中等官方 Java SDK高需要独立集群大规模生产、专用向量场景Qdrant独立向量数据库低提供 REST API 官方客户端中单机 Docker 即可中小规模、需要快速上线的项目pgvectorPostgreSQL 扩展极低用 JDBC 直接写 SQL低Postgres 加插件已有 Postgres 的团队、事务一致性要求高Elasticsearch搜索引擎 稠密向量中等ES Java Client中已有 ES 搜索栈、需要全文检索与向量混合我自己的选择逻辑是这样的如果项目已经重度使用 PostgreSQL比如用户、订单、元数据都在库里那么优先选 pgvector——不用额外引入一个运维组件用 JDBC 写 SQL 就搞定了向量检索还能跟原有业务数据做 JOIN 查询这在早期验证阶段省掉很多事。如果目标是做知识库/RAG 这类独立检索系统数据量大概率会涨到千万级那就直接上 Milvus。Milvus 的索引类型多HNSW、IVF_FLAT、IVF_PQ、DiskANN调优空间大社区也活跃。Java SDK 虽然是包了个 gRPC 客户端用起来还算顺手。Qdrant 是这两年的黑马Rust 写的性能很好API 设计非常简洁。如果你的团队不想伺候复杂的分布式组件又想要比 pgvector 更强的向量检索能力Qdrant 是很好的中间态选择。Elasticsearch 则适合那种「搜索已经跑在 ES 上不想拆掉重来」的情况毕竟 ES 8.x 开始内置了 dense_vector 字段可以直接跑 kNN。1.3 目标系统链路设计确定了选型整个系统的流水线也必须先画清楚。文档检索和语义搜索不是单点功能而是一条完整的处理链路文档入库阶段上传原始文档PDF、Word、TXT → 用解析器抽取纯文本 → 把长文本切成多个 chunk文本块 → 每个 chunk 用 Embedding 模型转成向量 → 连同原始文本和文档元数据写入向量数据库并建立向量索引。查询检索阶段用户输入 query → 用同一个 Embedding 模型把 query 编码成向量 → 在向量数据库里执行近似最近邻搜索 → 返回 TopK 相似的 chunk → 把原始文本片段展示给用户或者送给下游 LLM 生成答案。这个链路里最容易被忽视的一点是入库阶段和查询阶段必须是同一个 Embedding 模型模型版本也不能动。否则就好比你入库用普通话录语音查询用粤语识别两边根本对不上。后面我会细讲。2. 关键技术原理与设计细节2.1 文档解析与文本切分实战先说解析层。我遇到过的文档格式基本逃不出 PDF、Worddocx、Excel、PPT、TXT 这几种。如果每种格式都对接一个专用库代码会非常啰嗦PDFBox 管 PDF、Apache POI 管 Office、PlainText 自己读…… 所以我直接用 Apache Tika它一个库就把这些格式统一封装了。Tika 甚至能从 PDF 里提取元数据比如作者、创建时间、页数对我来说够用。实际代码很简单public String extractText(InputStream inputStream, String fileName) throws IOException, TikaException { Tika tika new Tika(); return tika.parseToString(inputStream); }但这里有个坑Tika 对扫描版 PDF 无能为力因为那是图片不是文本。如果业务里存在大量扫描件得额外接 OCR比如 Tesseract 或百度 OCR API这个不展开但你要心里有数。文档解析完拿到的是几千字甚至上万字的长文不能直接整篇塞给 Embedding 模型必须切片。为什么两个原因第一Embedding 模型有输入长度限制。以常见的 BGE 系列为例最长支持 512 个 token超过部分会被截断截断后语义信息丢失严重。第二切片粒度影响检索精度。设想一下用户问「公司的退款政策是什么」如果整篇文档是一份员工手册里面有不知道多少小节和退款无关的章节整篇编码成一个向量之后语义被「平均化」了检索回来的内容很泛不精准。切成小块之后每一块的主题更聚焦query 更容易命中匹配片段。那么切多长合适我实践下来有个经验区间中文文档每个 chunk 在 500800 字之间英文在 200400 词之间。太长语义发散太短容易把一句话、一个专业术语拦腰切断。另一个关键参数是 overlap重叠相邻 chunk 之间要留一定重叠避免切分位置正好在一句话中间导致这句关键信息被拆到两个 chunk 里都不完整。我实际用的切分方法是「固定长度 句子边界修正」先按固定长度切然后向前找最近的句号或换行符在句子边界截断。核心代码逻辑是public ListChunk splitDocument(String fullText, int chunkSize, int overlap) { ListChunk chunks new ArrayList(); if (fullText null || fullText.isEmpty()) { return chunks; } int start 0; int seq 0; while (start fullText.length()) { int end Math.min(start chunkSize, fullText.length()); if (end fullText.length()) { int sentenceEnd fullText.lastIndexOf(。, end); int newlineEnd fullText.lastIndexOf(\n, end); int goodEnd Math.max(sentenceEnd, newlineEnd); if (goodEnd start chunkSize / 2) { end goodEnd 1; } } chunks.add(new Chunk(String.valueOf(seq), fullText.substring(start, end))); start Math.max(1, end - overlap); } return chunks; }这段逻辑里有一个细节start end - overlap而不是end - overlap 1因为 overlap 只是相邻 chunk 的重叠区域不是彻底重复切。实际开发中可以用更专业的框架如 LangChain4j 的文本切分器但自己写一遍能更好理解边界条件。2.2 Embedding 模型选择与 Java 调用Embedding 模型是整个语义搜索的质量底座模型选错后面的功夫全白费。我直接说结论如果你的业务是中文场景不要用开箱即用的英文模型比如 OpenAI 的text-embedding-ada-002在英文上很好但在中文上效果明显弱于中文专用模型。中文场景实测口碑较好的开源方案有BAAI 的bge-base-zh-v1.5/bge-large-zh-v1.5输出维度 768 / 1024中文语义理解扎实是目前中文社区用得最多的m3e-base/m3e-large老牌中文 Embedding 模型兼容性好text2vec-base-chinese更轻量适合对精度要求不高的场景这些模型都是 Transformer 架构需要推理环境。我不建议在 Java 进程内直接用 Hugging Face 的transformers跑推理JVM 生态跑这种模型要额外引入 DJLDeep Java Library等框架部署复杂度高。更稳妥的做法是把模型部署成独立 HTTP 服务——用 Xinference、Ollama 或者 TEIText Embeddings Inference然后在 Java 侧通过 HTTP 调用。Java 侧调 Embedding API 我一般用 Spring 的RestTemplate或WebClient以 Xinference 为例public ListFloat getEmbedding(String text) { RestTemplate restTemplate new RestTemplate(); MapString, Object request new HashMap(); request.put(input, text); request.put(model, bge-base-zh-v1.5); MapString, Object data restTemplate.postForObject( http://localhost:9997/v1/embeddings, request, Map.class); ListMapString, Object list (ListMapString, Object) data.get(data); // 结果里的 embedding 字段本身就是 ListFloat注意 Java 类型转换时的泛型擦除 return (ListFloat) list.get(0).get(embedding); }这里必须提醒一个我踩过的硬坑向量维度必须全局一致。入库的时候用的是 768 维的 BGE 模型中途为了提高精度换成 1024 维的 BGE-Large那么向量数据库里 768 维的旧数据和 1024 维的新数据混在一起直接报维度不匹配的错。即使不报错检索逻辑也废了——不同模型的向量空间压根不是同一个空间内积和余弦都没有可比性。2.3 相似度计算与 HNSW 检索原理向量检索的核心就是找「最近邻」。向量数据库里通常提供三种距离度量余弦相似度计算向量夹角的余弦值对向量长度不敏感适合文本语义比较内积点积对向量长度敏感适合向量做过归一化的场景欧氏距离几何直线距离数值越小越相似适合图像等场景文本场景我基本只用余弦相似度。注意 Milvus 里配置MetricType.COSINE数值越大越相似pgvector 里的向量运算符算的是余弦距离数值越小越相似。别搞反了。海量数据下暴力遍历全量向量计算距离是不现实的。所以向量数据库用 ANN近似最近邻索引换空间和时间。最主流的是 HNSWHierarchical Navigable Small World分层小世界图。用生活类比来解释你到了一个陌生城市要找一家最好吃的火锅店最笨的办法是挨家挨户问而 HNSW 的思路是先飞到高空看城市轮廓锁定几个繁忙的商业区再降落到街道上快速导航到目标店——它通过多层图结构在高层快速跳过不相关的节点下沉到低层精确定位。HNSW 有核心参数参数含义经验值M每个节点的最大邻居数越大图越稠密召回越高内存越大1632efConstruction建图时搜索的候选集大小越大图质量越高建图越慢100200efSearch查询时的候选集大小越大召回越高查询越慢64256这三个参数直接影响「召回率 vs 性能」的平衡。生产上线前一定要做压测比如固定M16压测不同efSearch下查询 P99 延迟找到可接受的拐点。我踩过一次教训开发环境数据量小用默认参数跑得很嗨上了生产发现十万级向量查询要几百毫秒后来把efSearch从 256 降到 64延迟骤降到 50ms 内召回率只掉了 1 个点。所以参数别盲调拿自己的数据压后再定。2.4 混合检索向量搜索的黄金搭档纯语义搜索不是万能的。有一类查询向量搜索会非常吃力精确匹配类。比如用户搜「报错代码 50023」或者公司内部的项目编号「SPR-2024-031」文档里确实存在这个字符串但 Embedding 模型对这种无意义 token 的语义编码很弱向量检索的 topK 里往往召回不到。解决办法是混合检索Hybrid Search向量召回一路关键词召回一路BM25然后把两路结果用 RRFReciprocal Rank Fusion融合。RRF 的思路很简单两个列表里同一个文档的排名越靠前融合分越高。public MapString, Double mergeRankedResults(ListString vectorRanked, ListString keywordRanked, int k) { MapString, Double scores new HashMap(); for (int i 0; i vectorRanked.size(); i) { scores.put(vectorRanked.get(i), scores.getOrDefault(vectorRanked.get(i), 0.0) 1.0 / (k i 1)); } for (int i 0; i keywordRanked.size(); i) { scores.put(keywordRanked.get(i), scores.getOrDefault(keywordRanked.get(i), 0.0) 1.0 / (k i 1)); } return scores; }k通常取 60。用的时候按分数降序排列就是最终的融合结果。关键词那一路如果用的是 pgvector可以直接用 PostgreSQL 自带的tsvector全文检索如果用的是 Milvus需要额外跑一个 ES 或用赞助检索服务来解决。这个双路召回方案是生产级语义搜索系统里非常常见的架构。3. Java 接入实操从零到可用3.1 环境准备与依赖配置实操环节我会给出两条路线第一条用 pgvector 快速跑通最小 demo适合本地验证和快速理解原理第二条用 Milvus 上生产。先搞定环境。pgvector 路线最快。如果本机 Docker 可用直接跑docker run -d --name pgvector-demo \ -e POSTGRES_PASSWORDpassword \ -e POSTGRES_DBvector_demo \ -p 5432:5432 \ pgvector/pgvector:0.7.0这个镜像自带 pgvector 扩展跑起来之后进入容器执行CREATE EXTENSION IF NOT EXISTS vector;Milvus 路线要用 Docker Compose 起一个 Standalone 实例version: 3.5 services: etcd: image: quay.io/coreos/etcd:v3.5.5 environment: - ETCD_AUTO_COMPACTION_MODErevision - ETCD_AUTO_COMPACTION_RETENTION1000 - ETCD_QUOTA_BACKEND_BYTES4294967296 minio: image: minio/minio:RELEASE.2023-03-20T19-46-08Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin standalone: image: milvusdb/milvus:v2.4.0 ports: - 19530:19530 depends_on: - etcd - minioJava 依赖pgvector 用 JDBC 就够了Milvus 需要纯后端 SDK!-- Milvus SDK -- dependency groupIdio.milvus/groupId artifactIdmilvus-sdk-java/artifactId version2.4.3/version /dependency !-- Spring Boot JDBC 用于 pgvector -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId /dependency3.2 pgvector 快速跑通最小 Demopgvector 的好处在 Java 侧到极致你不需要引入任何新概念建表、插入、查询全用 SQL。建表 DDLCREATE TABLE documents ( id BIGSERIAL PRIMARY KEY, doc_id VARCHAR(64) NOT NULL, content TEXT NOT NULL, content_tsv TSVECTOR, embedding VECTOR(768) ); CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);注意embedding VECTOR(768)里的 768 必须跟 Embedding 模型的输出维度一致。vector_cosine_ops表示用余弦距离构建 HNSW 索引。Java 侧用 Spring 的JdbcTemplate操作// 插入 String sql INSERT INTO documents (doc_id, content, embedding) VALUES (?, ?, ?); jdbcTemplate.update(sql, chunk.getDocId(), chunk.getContent(), pgvector.PGvector.fromArray(embedding.toArray())); // 需要 cast // 查询按余弦距离升序取 TopK String querySql SELECT doc_id, content, 1 - (embedding ?) AS similarity FROM documents ORDER BY embedding ? LIMIT ?; ListMapString, Object results jdbcTemplate.query(querySql, new Object[]{queryVector, queryVector, 5}, (rs, rowNum) - Map.of( doc_id, rs.getString(doc_id), content, rs.getString(content), similarity, rs.getDouble(similarity) ));PGvector.fromArray是 pgvector 提供的 Java 序列化方法。Spring Boot 的项目里只要引入com.pgvector:pgvector-java依赖就有这个类。这一步跑通一个「文字转向量再按相似度查询」的最小闭环就成了前后不超过 30 分钟。3.3 Milvus 生产级接入全流程Milvus 的上手路径要复杂一些但它的能力边界远大于 pgvector。完整流程包括建库建表、创建索引、写入数据、检索四个步骤在 Java 侧分别是一个核心 API 调用。首先建立客户端连接MilvusServiceClient milvusClient new MilvusServiceClient( ConnectParam.newBuilder() .withHost(localhost) .withPort(19530) .build() );然后建 Collection类似 MySQL 的表。Milvus 是强 Schema 风格必须先定义字段。我的设计是四个字段doc_id字符串主键、contentChunk 原文、raw_text冗余存查询时返回、embedding浮点向量。FieldType docIdField FieldType.newBuilder() .withName(doc_id) .withDataType(DataType.VarChar) .withMaxLength(64) .withPrimaryKey(true) .build(); FieldType contentField FieldType.newBuilder() .withName(content) .withDataType(DataType.VarChar) .withMaxLength(4096) .build(); FieldType embeddingField FieldType.newBuilder() .withName(embedding) .withDataType(DataType.FloatVector) .withDimension(768) .build(); milvusClient.createCollection( CreateCollectionParam.newBuilder() .withCollectionName(doc_collection) .withFieldTypes(Arrays.asList(docIdField, contentField, embeddingField)) .build() );建完索引要立刻建向量索引。Milvus 里这一步是异步任务刚建完 Collection 立刻查询拿不到结果必须等createIndex返回成功后过一会儿再查或者用showCollections检查所有 segment 都 seek。实操中我养成习惯是在建索引后Thread.sleep(1000)懒人方案严谨的生产方案是 poll 索引状态。MapString, Object extraParams new HashMap(); extraParams.put(M, 16); extraParams.put(efConstruction, 200); milvusClient.createIndex( CreateIndexParam.newBuilder() .withCollectionName(doc_collection) .withFieldName(embedding) .withIndexType(IndexType.HNSW) .withMetricType(MetricType.COSINE) .withExtraParam(JSON.toJSONString(extraParams)) .build() );写入向量ListString docIds chunks.stream().map(Chunk::getDocId).collect(Collectors.toList()); ListString contents chunks.stream().map(Chunk::getContent).collect(Collectors.toList()); ListListFloat embeddings chunks.stream() .map(chunk - getEmbedding(chunk.getContent())) .collect(Collectors.toList()); milvusClient.insert( InsertParam.newBuilder() .withCollectionName(doc_collection) .withFields(Arrays.asList( new InsertParam.Field(doc_id, docIds), new InsertParam.Field(content, contents), new InsertParam.Field(embedding, embeddings) )) .build() );查询ListListFloat queryVector List.of(getEmbedding(上个月的销售总额是多少)); RSearchResults searchResponse milvusClient.search( SearchParam.newBuilder() .withCollectionName(doc_collection) .withVectors(queryVector) .withVectorFieldName(embedding) .withTopK(5) .withOutputFields(Collections.singletonList(content)) .withParams({\nprobe\: 16, \ef\: 64}) .build() ); // 解析结果 SearchResultsWrapper wrapper new SearchResultsWrapper(searchResponse.getData()); for (SearchResultsWrapper.IDScore idScore : wrapper.getIDScore(0)) { String content (String) wrapper.getFieldData(content, i); System.out.println(score idScore.getScore() , content content); }如果希望先拿到一个能演示效果的版本我建议先跑 pgvector 那条线理解这套「向量化 → 入库 → 相似度查询」的思维模型再切换到 Milvus避免两个新概念同时上手互相干扰。3.4 在 Spring Boot 中编排整个业务流程上面这些零散 API 要变成一个可用的业务系统需要在 Spring Boot 里做分层编排。我习惯按下面三层拆分层职责关键类Controller接收上传文档和检索请求返回 HTTP JSONDocumentController, SearchControllerService编排流程切片、调用 Embedding、入库/查询、混合检索DocumentService, SearchServiceRepository/Client封装向量库操作、Embedding HTTP 调用VectorStoreClient, EmbeddingClient上传文档的 Service 核心逻辑Service public class DocumentService { private final VectorStoreClient vectorStore; private final EmbeddingClient embeddingClient; public void uploadDocument(MultipartFile file, String docId) throws IOException { // 1. 解析文本 String fullText tika.parseToString(file.getInputStream()); // 2. 切片 ListChunk chunks splitDocument(fullText, 800, 100); // 3. 逐批向量化 入库 ListChunk buffer new ArrayList(); for (Chunk chunk : chunks) { chunk.setEmbedding(embeddingClient.getEmbedding(chunk.getContent())); buffer.add(chunk); if (buffer.size() 100) { vectorStore.insert(buffer); buffer.clear(); } } if (!buffer.isEmpty()) { vectorStore.insert(buffer); } } }检索的 ServiceService public class SearchService { private final VectorStoreClient vectorStore; private final KeywordSearchClient keywordSearchClient; private final EmbeddingClient embeddingClient; public ListSearchResult search(String query, int topK) { ListFloat queryVector embeddingClient.getEmbedding(query); ListSearchResult vectorResults vectorStore.search(queryVector, topK); ListSearchResult keywordResults keywordSearchClient.search(query, topK); return fuseByRRF(vectorResults, keywordResults, topK); } }这里值得单独强调一下批处理向量化模型的推理成本高逐条调 HTTP 在文档量大的时候非常慢。我的经验是每次至少攒 32 条文本再调用推理服务Xinference 等服务的批量接口能大幅降低 CPU/GPU 开销和网络往返次数。实测同样 1000 个 chunk逐条调耗时约 5 到 6 分钟32 条一批只要 40 秒左右。4. 常见问题与排查技巧实录4.1 Embedding 维度不匹配的报错这是接入向量数据库的第一个拦路虎。典型报错信息Milvus 报错field data type is FloatVector, expecting length dimpgvector 报错expected 768 dimensions, not 512根源基本是这些一是切换了 Embedding 模型但没同步改库表结构二是写代码时维度用了写死的常量比如旧版模型是 512 维后来新模型改成 768 维代码里没更新三是并发场景下插入了非 Float 类型的数据比如 JSON 反序列化后变成了 List 。排查方法很简单先确认当前模型输出维度再查库里 collection 的 field schema。标准做法是把模型维度做成配置项放在application.yml里跟向量数据库的 DDL 和 SDK 的withDimension参数保持一致避免在不同代码层各自写死。4.2 召回质量差分不清是切分的问题还是模型的问题如果检索结果相关性差很多人的第一反应是换更强的 Embedding 模型。但根据我的经验至少一半情况是切分策略的问题。怎么判断拿几条已知有标准答案的 query分别直接查询把召回结果打印出来人工看。如果召回的内容语义上相关但磕磕绊绊不完整比如搜「退款政策」召回了「退款」和「政策」两个词分居两个 chunk、内容严重断句那是切分粒度太细如果召回内容完整但语义明显偏题比如搜「退款政策」召回了「质量保证条例」那大概率是 Embedding 模型的问题。切分优化的顺序是先加 overlap从 50 加到 100150再换句子边界切分最后再考虑调整 chunk size。模型层面优化则要考虑换更专业的中文模型比如 BGE 的 large 版本或者训练一个领域微调模型。4.3 查询慢与内存占用过高HNSW 索引的毛病是吃内存。十万级向量、768 维、Float 类型裸向量数据就是 10 万 × 768 × 4 字节 ≈ 300MB加上 HNSW 图结构实际内存占用是这个基数的 2 到 3 倍。数据涨到千万级就要好好规划资源了。优化手段按性价比排列优先调查询参数efSearch降到一个可控值比如 64观察 P99 延迟变化考虑压缩向量类型从 Float32 降到 Float16精度损失小显存减一半数据规模再大用 IVF_PQ 索引做乘积量化查询时先桶粗筛再精排最后手段是降维比如用 PCA 把 768 维降到 256 维但必须全量重灌数据另一个容易忽略的点是写入放大。批量小且频繁写入向量库要频繁重建索引查询延迟会明显波动。我的做法是离线批量导入走 Bulk API实时增量用较小的 batch 间隔合并别一条一条插。4.4 数据的增删改与全量重建向量数据库大多不支持传统事务里的 UPDATE/DELETE 语义。Milvus 支持按主键删除但删除是异步的落盘有延迟。pgvector 倒是能用 SQL 直接 delete但删完 HNSW 索引里的向量不会立刻物理清除文档删了但检索还命中过期向量这在业务上挺难受。我的应对方案是在应用层设计「软删除」给每条 chunk 加一个deleted标记和version字段。查询时先按向量库捞出候选再用元数据过滤掉deletedtrue的记录。全量重建更干脆——文档更新频繁的场景直接删掉旧的 collection 重新建表重灌配合消息队列做异步任务对用户无感。写到最后分享一点个人体会这个项目做完之后我的感受是接入向量数据库本身并不难难的是切分策略、模型选择和检索质量调优这些非功能性工作。很多人一上来就问「哪个向量数据库最好」其实先把一个最简链路跑通再拿真实数据压测调参比反复调研选型有用得多。还有一个小建议给你的项目留好「向后兼容」的余地。Embedding 模型迭代速度快今天 BGE 是 SOTA半年后可能就有更强的。我在代码里统一封装了EmbeddingClient抽象层未来更换模型只需新增一个实现类不必改动业务代码。如果你正在准备 Java 面试这个链路也是一个很好的实践案例——能讲清楚 Embedding 是什么、向量数据库为什么用 ANN 索引、检索时为什么需要混合召回往往比背八股文更能体现真实水平。后续如果有余力还可以把这个语义检索系统延伸成 RAG 问答系统检索出的 chunk 喂给大模型生成答案那就是另一个值得单开一篇的项目了。
返回列表