最近在做公司内部知识库的文档检索系统,遇到一个很典型的场景:同事在搜索框输入「上个月的销售总额是多少」,结果返回空,但知识库里明明有一份标题叫《月度经营分析报告》的文档,里面清清楚楚写着当月销售额。问题出在哪?传统检索靠的是关键词精确匹配,用户问法和文档写法一旦对不上,就是搜不到。
这个项目就是解决这类问题的——用 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 即可) | 中小规模、需要快速上线的项目 |
| pgvector | PostgreSQL 扩展 | 极低(用 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、Word(docx)、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 在 500~800 字之间,英文在 200~400 词之间。太长语义发散,太短容易把一句话、一个专业术语拦腰切断。另一个关键参数是 overlap(重叠):相邻 chunk 之间要留一定重叠,避免切分位置正好在一句话中间,导致这句关键信息被拆到两个 chunk 里都不完整。
我实际用的切分方法是「固定长度 + 句子边界修正」:先按固定长度切,然后向前找最近的句号或换行符,在句子边界截断。核心代码逻辑是:
public List<Chunk> splitDocument(String fullText, int chunkSize, int overlap) { List<Chunk> 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 生态跑这种模型要额外引入 DJL(Deep Java Library)等框架,部署复杂度高。更稳妥的做法是把模型部署成独立 HTTP 服务——用 Xinference、Ollama 或者 TEI(Text Embeddings Inference),然后在 Java 侧通过 HTTP 调用。
Java 侧调 Embedding API 我一般用 Spring 的RestTemplate或WebClient,以 Xinference 为例:
public List<Float> getEmbedding(String text) { RestTemplate restTemplate = new RestTemplate(); Map<String, Object> request = new HashMap<>(); request.put("input", text); request.put("model", "bge-base-zh-v1.5"); Map<String, Object> data = restTemplate.postForObject( "http://localhost:9997/v1/embeddings", request, Map.class); List<Map<String, Object>> list = (List<Map<String, Object>>) data.get("data"); // 结果里的 embedding 字段本身就是 List<Float>,注意 Java 类型转换时的泛型擦除 return (List<Float>) list.get(0).get("embedding"); }这里必须提醒一个我踩过的硬坑:向量维度必须全局一致。入库的时候用的是 768 维的 BGE 模型,中途为了提高精度换成 1024 维的 BGE-Large,那么向量数据库里 768 维的旧数据和 1024 维的新数据混在一起,直接报维度不匹配的错。即使不报错,检索逻辑也废了——不同模型的向量空间压根不是同一个空间,内积和余弦都没有可比性。
2.3 相似度计算与 HNSW 检索原理
向量检索的核心就是找「最近邻」。向量数据库里通常提供三种距离度量:
- 余弦相似度:计算向量夹角的余弦值,对向量长度不敏感,适合文本语义比较
- 内积(点积):对向量长度敏感,适合向量做过归一化的场景
- 欧氏距离:几何直线距离,数值越小越相似,适合图像等场景
文本场景我基本只用余弦相似度。注意 Milvus 里配置MetricType.COSINE,数值越大越相似;pgvector 里的向量运算符<=>算的是余弦距离,数值越小越相似。别搞反了。
海量数据下暴力遍历全量向量计算距离是不现实的。所以向量数据库用 ANN(近似最近邻)索引换空间和时间。最主流的是 HNSW(Hierarchical Navigable Small World,分层小世界图)。用生活类比来解释:你到了一个陌生城市要找一家最好吃的火锅店,最笨的办法是挨家挨户问,而 HNSW 的思路是先飞到高空看城市轮廓,锁定几个繁忙的商业区,再降落到街道上快速导航到目标店——它通过多层图结构,在高层快速跳过不相关的节点,下沉到低层精确定位。
HNSW 有核心参数:
| 参数 | 含义 | 经验值 |
|---|---|---|
| M | 每个节点的最大邻居数,越大图越稠密,召回越高,内存越大 | 16~32 |
| efConstruction | 建图时搜索的候选集大小,越大图质量越高,建图越慢 | 100~200 |
| efSearch | 查询时的候选集大小,越大召回越高,查询越慢 | 64~256 |
这三个参数直接影响「召回率 vs 性能」的平衡。生产上线前一定要做压测,比如固定M=16,压测不同efSearch下查询 P99 延迟,找到可接受的拐点。我踩过一次教训:开发环境数据量小,用默认参数跑得很嗨,上了生产发现十万级向量查询要几百毫秒,后来把efSearch从 256 降到 64,延迟骤降到 50ms 内,召回率只掉了 1 个点。所以参数别盲调,拿自己的数据压后再定。
2.4 混合检索:向量搜索的黄金搭档
纯语义搜索不是万能的。有一类查询向量搜索会非常吃力:精确匹配类。比如用户搜「报错代码 50023」,或者公司内部的项目编号「SPR-2024-031」,文档里确实存在这个字符串,但 Embedding 模型对这种无意义 token 的语义编码很弱,向量检索的 topK 里往往召回不到。
解决办法是混合检索(Hybrid Search):向量召回一路,关键词召回一路(BM25),然后把两路结果用 RRF(Reciprocal Rank Fusion)融合。RRF 的思路很简单:两个列表里同一个文档的排名越靠前,融合分越高。
public Map<String, Double> mergeRankedResults(List<String> vectorRanked, List<String> keywordRanked, int k) { Map<String, 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_PASSWORD=password \ -e POSTGRES_DB=vector_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_MODE=revision - ETCD_AUTO_COMPACTION_RETENTION=1000 - ETCD_QUOTA_BACKEND_BYTES=4294967296 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> <groupId>io.milvus</groupId> <artifactId>milvus-sdk-java</artifactId> <version>2.4.3</version> </dependency> <!-- Spring Boot JDBC 用于 pgvector --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> </dependency>3.2 pgvector 快速跑通最小 Demo
pgvector 的好处在 Java 侧到极致:你不需要引入任何新概念,建表、插入、查询,全用 SQL。建表 DDL:
CREATE 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 ?"; List<Map<String, 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(字符串主键)、content(Chunk 原文)、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 索引状态。
Map<String, 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() );写入向量:
List<String> docIds = chunks.stream().map(Chunk::getDocId).collect(Collectors.toList()); List<String> contents = chunks.stream().map(Chunk::getContent).collect(Collectors.toList()); List<List<Float>> 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() );查询:
List<List<Float>> queryVector = List.of(getEmbedding("上个月的销售总额是多少")); R<SearchResults> 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 JSON | DocumentController, SearchController |
| Service | 编排流程:切片、调用 Embedding、入库/查询、混合检索 | DocumentService, SearchService |
| Repository/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. 切片 List<Chunk> chunks = splitDocument(fullText, 800, 100); // 3. 逐批向量化 + 入库 List<Chunk> 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); } } }检索的 Service:
@Service public class SearchService { private final VectorStoreClient vectorStore; private final KeywordSearchClient keywordSearchClient; private final EmbeddingClient embeddingClient; public List<SearchResult> search(String query, int topK) { List<Float> queryVector = embeddingClient.getEmbedding(query); List<SearchResult> vectorResults = vectorStore.search(queryVector, topK); List<SearchResult> 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 <dim> - pgvector 报错:
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 加到 100~150),再换句子边界切分,最后再考虑调整 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字段。查询时先按向量库捞出候选,再用元数据过滤掉deleted=true的记录。全量重建更干脆——文档更新频繁的场景,直接删掉旧的 collection 重新建表重灌,配合消息队列做异步任务,对用户无感。
写到最后,分享一点个人体会
这个项目做完之后,我的感受是:接入向量数据库本身并不难,难的是切分策略、模型选择和检索质量调优这些非功能性工作。很多人一上来就问「哪个向量数据库最好」,其实先把一个最简链路跑通,再拿真实数据压测调参,比反复调研选型有用得多。
还有一个小建议:给你的项目留好「向后兼容」的余地。Embedding 模型迭代速度快,今天 BGE 是 SOTA,半年后可能就有更强的。我在代码里统一封装了EmbeddingClient抽象层,未来更换模型只需新增一个实现类,不必改动业务代码。
如果你正在准备 Java 面试,这个链路也是一个很好的实践案例——能讲清楚 Embedding 是什么、向量数据库为什么用 ANN 索引、检索时为什么需要混合召回,往往比背八股文更能体现真实水平。后续如果有余力,还可以把这个语义检索系统延伸成 RAG 问答系统:检索出的 chunk 喂给大模型生成答案,那就是另一个值得单开一篇的项目了。