本章目标
第 10 章我们讲了 embedding。
它解决的是这个问题:
text
如何把文本变成向量?
但向量生成出来以后,还不能只放在 Java 内存里。
文档上传后,系统要长期保存文档 chunk 的向量。用户提问时,系统要从这些向量中找出最相似的片段。
所以这一章要解决的是:
text
向量如何保存?
向量如何检索?
在 KnowHub 中,这件事由 PostgreSQL + pgvector 完成。
本章要讲清楚:
- PostgreSQL 和 pgvector 是什么。
- 为什么不直接把向量放在 MySQL 里检索。
- MySQL 业务库和 pgvector 向量库如何分工。
document_chunk_vector表如何设计。embedding vector(1024)字段是什么意思。- 项目如何配置独立的 pgvector 数据源。
float[]如何写入 pgvector。ON CONFLICT (chunk_id) DO UPDATE如何支持重复索引。<=>余弦距离如何用于相似度检索。- TopK 和相似度阈值如何影响检索结果。
- IVFFlat 索引和
ANALYZE的作用。 - 常见 pgvector 问题如何排查。
11.1 为什么需要专门的向量库
传统业务系统中,我们经常使用 MySQL。
MySQL 很适合保存这类数据:
text
用户表
角色表
知识库表
文档元数据表
任务表
问答日志表
这些数据有清晰的字段,可以用条件查询:
sql
SELECT * FROM knowledge_base WHERE user_id = ?;
也可以用事务保证一致性。
但向量检索不是普通条件查询。
用户提问后,系统要做的是:
text
在几百、几千、几万甚至更多个 chunk 向量中,
找出和问题向量最接近的前几个。
这不是简单的等值查询,也不是普通的 LIKE 查询。
它需要计算向量之间的距离。
例如:
text
问题向量:[0.1, 0.2, 0.3, ...]
chunk 向量:[0.1, 0.19, 0.31, ...]
系统要判断这两个向量是否相似。
如果每次都把所有向量查出来,再在 Java 里循环计算距离,数据量一大就会很慢。
所以需要一个能直接在数据库层面支持向量类型、向量距离和向量索引的组件。
pgvector 就是为这个场景准备的。
11.2 PostgreSQL + pgvector 是什么
PostgreSQL 是一个关系型数据库。
它和 MySQL 一样,也可以建表、写 SQL、做事务、建索引。
pgvector 是 PostgreSQL 的一个扩展。
安装 pgvector 以后,PostgreSQL 就能支持一种新的字段类型:
sql
vector
比如:
sql
embedding vector(1024)
这表示 embedding 字段保存的是一个 1024 维向量。
pgvector 还提供了向量距离计算能力。
比如本项目中用到的:
sql
embedding <=> ?::vector
它表示计算表中 embedding 向量和查询向量之间的余弦距离。
距离越小,表示越相似。
所以在 RAG 检索中,我们可以这样理解 pgvector:
text
PostgreSQL:负责可靠保存数据
pgvector:负责让 PostgreSQL 看懂向量并计算向量距离
11.2.1 启动 PostgreSQL + pgvector(Docker)
本章代码运行前,需要先启动带 pgvector 扩展的 PostgreSQL。
可以使用 pgvector 官方镜像:
powershell
docker run -d `
--name knowhub-pgvector `
-p 5432:5432 `
-e POSTGRES_USER=knowhub `
-e POSTGRES_PASSWORD=change-me `
-e POSTGRES_DB=rag_vector `
pgvector/pgvector:pg17
如果本机已经有 PostgreSQL 占用了 5432 端口,可以把左侧端口改成 5433:
powershell
-p 5433:5432
等价的 Docker Compose 最小配置如下:
yaml
services:
pgvector:
image: pgvector/pgvector:pg17
container_name: knowhub-pgvector
restart: unless-stopped
environment:
POSTGRES_USER: knowhub
POSTGRES_PASSWORD: change-me
POSTGRES_DB: rag_vector
TZ: Asia/Shanghai
ports:
- "5432:5432"
volumes:
- knowhub-pgvector-data:/var/lib/postgresql/data
volumes:
knowhub-pgvector-data:
启动后可以进入容器验证数据库是否可用:
powershell
docker exec -it knowhub-pgvector psql -U knowhub -d rag_vector -c "SELECT 1;"
如果返回:
text
?column?
----------
1
说明 PostgreSQL 可以正常连接。后面还要在 rag_vector 数据库中执行 CREATE EXTENSION IF NOT EXISTS vector; 和建表脚本。
11.2.2 Maven 依赖
knowledge-service 需要连接 PostgreSQL,因此要引入 PostgreSQL JDBC 驱动:
xml
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
如果父工程已经统一管理版本,子模块可以不写 <version>。这个依赖放在 knowledge-service 中,因为当前项目里的向量写入、删除、重建和检索都发生在 knowledge-service 的向量模块。
11.3 MySQL 和 pgvector 的分工
Knowhub 没有把所有数据都放进 pgvector。
也没有把所有数据都放进 MySQL。
而是做了分工。
11.3.1 MySQL 保存业务主数据
MySQL 负责保存平台的业务数据。
比如:
text
user_info
knowledge_base
document_info
document_chunk
index_task
qa_log
这些数据是业务系统的主数据。
它们需要满足:
text
权限校验
状态管理
列表查询
后台管理
事务更新
审计追踪
例如 document_chunk 表保存的是切片的原始文本和业务关系。
它属于业务数据。
11.3.2 pgvector 保存向量检索副本
pgvector 保存的是用于相似度检索的向量副本。
项目中对应的表是:
text
document_chunk_vector
它保存的是 document_chunk 的检索副本。
也就是说,同一个 chunk 会在两个地方出现:
text
MySQL `document_chunk`:保存业务切片
PostgreSQL `document_chunk_vector`:保存向量检索副本
为什么要复制一份 content?
因为检索命中后,系统需要直接返回片段内容,用于后续拼接 Prompt。
如果向量库只保存 chunk_id 和 embedding,每次检索完成后还要回 MySQL 查询 chunk 文本。
这样链路会更长。
当前项目选择在 document_chunk_vector 中保存一份 content,是为了让检索结果可以直接携带片段内容。
这是一种典型的读优化设计。
它牺牲了一点存储空间,换取检索链路更简单。
11.4 初始化 pgvector 表
项目的 PostgreSQL 初始化脚本位于:
text
sql/postgresql/01_rag_vector_schema.sql
拿到脚本后,可以用 DBeaver、DataGrip 或 psql 执行。
psql 示例。先在终端切换到 D:\rag\rag-platform 项目根目录,再执行:
powershell
docker exec -i knowhub-pgvector psql -U knowhub -d rag_vector < sql/postgresql/01_rag_vector_schema.sql
如果使用 DBeaver,先连接 rag_vector 数据库,再打开 sql/postgresql/01_rag_vector_schema.sql 执行。注意不要连到默认的 postgres 数据库后就直接执行,否则扩展和表可能建在错误的数据库里。
脚本第一步是启用扩展:
sql
CREATE EXTENSION IF NOT EXISTS vector;
这一步非常关键。
如果没有安装或启用 pgvector 扩展,PostgreSQL 不认识 vector 类型。
后面的建表语句会失败。
11.4.1 document_chunk_vector 表
核心建表语句如下:
sql
CREATE TABLE IF NOT EXISTS document_chunk_vector (
id BIGSERIAL PRIMARY KEY,
chunk_id BIGINT NOT NULL UNIQUE,
document_id BIGINT NOT NULL,
kb_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
chunk_index INTEGER NOT NULL,
document_name VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
embedding vector(1024) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
这张表不是完整业务表。
它是向量检索表。
它的核心职责是:
text
保存每个文档 chunk 的 embedding 向量,
并保留足够的业务字段用于检索范围过滤和结果返回。
11.4.2 范围索引
脚本中有一个普通索引:
sql
CREATE INDEX IF NOT EXISTS idx_document_chunk_vector_scope
ON document_chunk_vector (user_id, kb_id, document_id);
这个索引用于缩小检索范围。
用户提问时,系统不能在所有人的所有知识库中检索。
它必须先限定:
text
当前用户
当前知识库
所以检索 SQL 中会有:
sql
WHERE kb_id = ?
AND user_id = ?
idx_document_chunk_vector_scope 就是为了这类过滤服务的。
11.4.3 向量索引
脚本中还有一个向量索引:
sql
CREATE INDEX IF NOT EXISTS idx_document_chunk_vector_embedding_cosine
ON document_chunk_vector
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
这里有几个关键词。
ivfflat 是 pgvector 支持的一种近似向量索引。
vector_cosine_ops 表示使用余弦距离相关的操作符类。
lists = 100 是索引参数,可以先理解成把向量空间分成若干个列表,以提升检索效率。
对于零基础读者,先记住一点:
text
没有向量索引时,数据库可能需要扫描大量向量。
有向量索引后,数据库可以更快地找到相似向量。
脚本最后还有一条提示:
sql
ANALYZE document_chunk_vector;
大量导入向量后,执行 ANALYZE 可以让 PostgreSQL 更新统计信息,帮助查询优化器选择更合适的执行计划。
11.5 表字段逐个拆解
现在我们逐个看 document_chunk_vector 的字段。
11.5.1 id
sql
id BIGSERIAL PRIMARY KEY
这是向量表自己的主键。
它只代表 document_chunk_vector 表中的记录 ID。
业务上真正和 MySQL chunk 对应的字段不是它,而是 chunk_id。
11.5.2 chunk_id
sql
chunk_id BIGINT NOT NULL UNIQUE
chunk_id 对应 MySQL 中 document_chunk.id。
它是非常重要的业务键。
为什么要唯一?
因为一个 chunk 在向量表中应该只有一条向量记录。
如果同一个 chunk 重复写入多条向量,检索时可能出现重复片段。
所以 chunk_id 加唯一约束,配合写入 SQL 中的:
sql
ON CONFLICT (chunk_id) DO UPDATE
可以实现:
text
第一次写入:插入新向量
再次写入:更新旧向量
11.5.3 document_id
sql
document_id BIGINT NOT NULL
表示这个 chunk 属于哪个文档。
它用于:
text
按文档删除旧向量
重建某个文档的向量
检索结果返回来源文档
项目中删除旧向量时会执行:
java
DELETE FROM document_chunk_vector WHERE document_id = ?
这就是为了文档重建索引服务。
11.5.4 kb_id 和 user_id
sql
kb_id BIGINT NOT NULL,
user_id BIGINT NOT NULL
这两个字段用于数据隔离。
在 KnowHub 中,用户只能检索自己的知识库。
所以向量检索必须带上:
text
user_id
kb_id
不能只靠 document_id。
因为 RAG 平台通常是多用户系统,向量表中会保存不同用户、不同知识库的 chunk。
如果检索 SQL 忘记加 user_id,就可能把其他用户的文档片段召回出来。
这属于严重的数据隔离问题。
11.5.5 chunk_index
sql
chunk_index INTEGER NOT NULL
表示该 chunk 在文档中的顺序。
它可以帮助前端或后续 Prompt 组装理解片段位置。
例如检索结果显示:
text
命中文档:员工报销制度.pdf
命中片段:第 6 段
这里的"第 6 段"就可以来自 chunk_index。
11.5.6 document_name
sql
document_name VARCHAR(255) NOT NULL
用于返回引用来源。
RAG 问答不仅要给答案,还要告诉用户答案参考了哪些文档。
如果检索结果中包含文档名,后面生成引用来源会更方便。
11.5.7 content
sql
content TEXT NOT NULL
这是 chunk 的文本内容。
向量本身不能直接给大模型当上下文。
向量只用于检索。
真正拼接 prompt 时,仍然需要原始文本。
所以检索结果必须能拿到 content。
11.5.8 embedding
sql
embedding vector(1024) NOT NULL
这是整张表最核心的字段。
它保存 chunk 对应的 1024 维向量。
第 10 章已经讲过,维度必须满足:
text
Embedding 模型输出维度
= rag.vector.dimension
= embedding vector(1024)
只要不一致,写入就会失败。
11.5.9 created_at 和 updated_at
sql
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
这两个字段用于记录向量创建和更新时间。
当文档重建索引时,旧向量会被删除或更新,updated_at 可以帮助排查向量是否已经刷新。
11.6 独立 pgvector 数据源
KnowHub 的业务主库是 MySQL。
pgvector 使用 PostgreSQL。
所以项目需要两个数据库连接。
业务数据走 MySQL。
向量数据走 PostgreSQL。
项目中 pgvector 的数据源配置类是:
text
com.luo.ragknowledge.vector.config.PgVectorJdbcConfig
核心代码是:
java
@Bean("pgVectorJdbcTemplate")
@ConditionalOnProperty(prefix = "rag.vector", name = "enabled", havingValue = "true")
public JdbcTemplate pgVectorJdbcTemplate(RagVectorProperties properties) {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName(properties.getDriverClassName());
dataSource.setUrl(properties.getUrl());
dataSource.setUsername(properties.getUsername());
dataSource.setPassword(properties.getPassword());
return new JdbcTemplate(dataSource);
}
这里有两个关键点。
第一,Bean 名称是:
text
pgVectorJdbcTemplate
业务代码注入时也指定了这个名称:
java
@Qualifier("pgVectorJdbcTemplate")
JdbcTemplate pgVectorJdbcTemplate
这样可以避免和 MySQL 的数据源混淆。
第二,只有在下面配置为 true 时才创建:
yaml
rag:
vector:
enabled: true
如果向量库没有启用,系统会使用 DisabledDocumentVectorServiceImpl。
这样做的好处是:
text
没有安装 PostgreSQL/pgvector 时,
文档上传和切片等前置功能仍然可以先运行。
不过只要进入向量检索或向量重建,就必须启用 pgvector。
pgvector 连接配置可以写在 knowledge-service/src/main/resources/application.yml 中:
yaml
rag:
vector:
# 是否启用向量能力;关闭时使用 DisabledDocumentVectorServiceImpl。
enabled: ${RAG_VECTOR_ENABLED:true}
# 下面四个字段是 PostgreSQL + pgvector 独立数据源连接配置。
driver-class-name: org.postgresql.Driver
url: ${RAG_VECTOR_URL:jdbc:postgresql://localhost:5432/rag_vector}
username: ${RAG_VECTOR_USERNAME:knowhub}
password: ${RAG_VECTOR_PASSWORD:change-me}
# 下面三个字段是向量检索业务配置。
dimension: ${RAG_VECTOR_DIMENSION:1024}
top-k: ${RAG_VECTOR_TOP_K:5}
similarity-threshold: ${RAG_VECTOR_SIMILARITY_THRESHOLD:0.30}
PgVectorJdbcConfig 会读取这些配置,创建名为 pgVectorJdbcTemplate 的 Bean。业务主库仍然走 MySQL 的默认数据源,向量表读写才走这个独立 JdbcTemplate。
完整的 RagVectorProperties 可以这样写:
java
package com.luo.knowhub.knowledge.vector.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "rag.vector")
public class RagVectorProperties {
// 业务配置:是否启用向量能力。
private boolean enabled = true;
// 连接配置:PostgreSQL JDBC 驱动。
private String driverClassName = "org.postgresql.Driver";
// 连接配置:pgvector 所在数据库 URL。
private String url = "jdbc:postgresql://localhost:5432/rag_vector";
// 连接配置:数据库用户名。
private String username = "knowhub";
// 连接配置:数据库密码。真实项目中不要写死,使用环境变量或配置中心注入。
private String password = "change-me";
// 业务配置:Embedding 向量维度,必须和模型输出、数据库 vector(n) 一致。
private int dimension = 1024;
// 业务配置:默认返回 TopK 个片段。
private int topK = 5;
// 业务配置:最低相似度阈值。
private double similarityThreshold = 0.30D;
}
这里可以把字段分成两组理解:
text
连接配置:driverClassName、url、username、password
业务配置:enabled、dimension、topK、similarityThreshold
连接配置决定能不能连上 pgvector;业务配置决定向量能力怎么运行。
11.7 float\[\] 如何写入 pgvector
第 10 章中,Embedding 模型返回的是:
java
float[]
但 PostgreSQL 不能直接接收 Java 的 float[] 对象。
pgvector 需要的是类似这样的文本格式:
text
[0.1,0.2,0.3]
项目中通过 PgVectorUtils.toVectorLiteral(...) 完成转换。
11.7.1 PgVectorUtils.toVectorLiteral
工具类的核心职责是:
text
把 Java float[] 转换成 pgvector 字面量字符串。
例如:
java
float[] vector = new float[] {0.1F, 0.2F, 0.3F};
String literal = PgVectorUtils.toVectorLiteral(vector);
转换结果是:
text
[0.1,0.2,0.3]
这个工具类还做了基础校验。
如果向量为空,会抛出异常。
如果向量中包含 NaN 或 Infinity,也会抛出异常。
这很重要。
因为数据库不应该写入非法向量。
如果非法向量进入库里,后续检索时问题会更难排查。
toVectorLiteral 的完整实现如下:
java
package com.luo.knowhub.knowledge.vector;
public final class PgVectorUtils {
private PgVectorUtils() {
}
public static String toVectorLiteral(float[] vector) {
if (vector == null || vector.length == 0) {
throw new IllegalArgumentException("向量不能为空");
}
StringBuilder builder = new StringBuilder(vector.length * 10);
builder.append('[');
for (int i = 0; i < vector.length; i++) {
float value = vector[i];
// pgvector 不应该接收 NaN 或 Infinity,否则后续检索结果不可控。
if (Float.isNaN(value) || Float.isInfinite(value)) {
throw new IllegalArgumentException("向量不能包含 NaN 或 Infinity");
}
if (i > 0) {
builder.append(',');
}
builder.append(Float.toString(value));
}
builder.append(']');
return builder.toString();
}
}
输入:
java
float[] vector = new float[] {0.1F, 0.2F, 0.3F};
输出:
text
[0.1,0.2,0.3]
这个方法看起来简单,但它是 Java 和 pgvector 之间的格式桥梁。没有这一步,JdbcTemplate 不能直接把 Java 的 float[] 写入 PostgreSQL 的 vector 字段。
11.7.2 ?::vector 是什么
写入 SQL 中有一段:
sql
?::vector
这里的 ? 是 JDBC 参数占位符。
::vector 是 PostgreSQL 的类型转换语法。
连起来表示:
text
把传入的字符串参数转换成 vector 类型。
也就是说,Java 传入的是:
text
[0.1,0.2,0.3]
PostgreSQL 收到后通过 ?::vector 把它转换成真正的 pgvector 向量类型。
11.8 向量写入 SQL
向量写入逻辑位于:
text
PgVectorDocumentVectorServiceImpl.upsertChunkVector(...)
核心 SQL 如下:
sql
INSERT INTO document_chunk_vector
(chunk_id, document_id, kb_id, user_id, chunk_index, document_name, content, embedding, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?::vector, ?, ?)
ON CONFLICT (chunk_id) DO UPDATE SET
document_id = EXCLUDED.document_id,
kb_id = EXCLUDED.kb_id,
user_id = EXCLUDED.user_id,
chunk_index = EXCLUDED.chunk_index,
document_name = EXCLUDED.document_name,
content = EXCLUDED.content,
embedding = EXCLUDED.embedding,
updated_at = EXCLUDED.updated_at
这段 SQL 是本章最重要的写入逻辑。
11.8.1 第一次索引
如果 chunk_id 不存在,执行插入。
系统会写入:
text
chunk 业务 ID
文档 ID
知识库 ID
用户 ID
chunk 顺序
文档名
chunk 文本
embedding 向量
创建时间
更新时间
11.8.2 重复索引
如果 chunk_id 已经存在,就触发:
sql
ON CONFLICT (chunk_id) DO UPDATE
这表示:
text
不要插入重复记录,
而是更新原来的向量和内容。
这对 RAG 平台非常重要。
因为索引任务可能会因为重试、重建、重复消费而再次写入同一个 chunk。
如果没有冲突更新,就可能出现两种问题:
- 唯一约束报错,任务失败。
- 如果没有唯一约束,重复记录进入向量库。
当前项目通过:
text
chunk_id UNIQUE
ON CONFLICT DO UPDATE
保证同一个 chunk 在向量库里只有一条记录。
调用这段 SQL 的 Java 方法如下:
java
private void upsertChunkVector(DocumentInfo documentInfo, DocumentChunk chunk, float[] embedding) {
String sql = """
INSERT INTO document_chunk_vector
(chunk_id, document_id, kb_id, user_id, chunk_index, document_name, content, embedding, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?::vector, ?, ?)
ON CONFLICT (chunk_id) DO UPDATE SET
document_id = EXCLUDED.document_id,
kb_id = EXCLUDED.kb_id,
user_id = EXCLUDED.user_id,
chunk_index = EXCLUDED.chunk_index,
document_name = EXCLUDED.document_name,
content = EXCLUDED.content,
embedding = EXCLUDED.embedding,
updated_at = EXCLUDED.updated_at
""";
Timestamp now = Timestamp.valueOf(LocalDateTime.now());
// ON CONFLICT 的含义:chunk_id 不存在就插入,已存在就更新内容和向量。
pgVectorJdbcTemplate.update(sql,
chunk.getId(),
chunk.getDocumentId(),
chunk.getKbId(),
chunk.getUserId(),
chunk.getChunkIndex(),
documentInfo.getFileName(),
chunk.getContent(),
PgVectorUtils.toVectorLiteral(embedding),
now,
now);
log.debug("chunk 向量写入完成,chunkId={}, documentId={}",
chunk.getId(), chunk.getDocumentId());
}
这段代码中的 documentInfo.getFileName() 用于保存检索结果的引用来源,chunk.getContent() 用于命中后直接拼接 Prompt。
11.9 删除旧向量与重建索引
文档重建索引时,旧向量需要清理。
项目中有一个方法:
java
public void deleteByDocumentId(Long documentId) {
int deleted = pgVectorJdbcTemplate.update(
"DELETE FROM document_chunk_vector WHERE document_id = ?",
documentId
);
}
它按 document_id 删除该文档的全部旧向量。
为什么不是按 chunk_id 一个个删?
因为重建索引后,chunk 数量可能变化。
例如:
text
旧版本:10 个 chunk
新版本:8 个 chunk
如果只更新前 8 个 chunk,旧的第 9、10 个 chunk 会残留。
按 document_id 删除更干净。
完整重建流程是:
text
读取文档信息
读取 MySQL 中的 document_chunk
删除 pgvector 中旧向量
重新 Embedding
重新写入 document_chunk_vector
返回 indexedCount
控制器中对应接口是:
text
POST /kb/{kbId}/documents/{documentId}/vectors/rebuild
这个接口适合用于:
- 旧文档补写向量。
- Embedding 模型更换后重建。
- 索引失败后人工重试。
- 文档切片参数调整后重新入库。
重建接口的 controller 方法可以这样写:
java
@PostMapping("/kb/{kbId}/documents/{documentId}/vectors/rebuild")
public ApiResponse<VectorRebuildResponse> rebuildDocumentVectors(
@PathVariable Long kbId,
@PathVariable Long documentId) {
Long userId = UserContext.requireUserId();
return ApiResponse.success(documentVectorService.rebuildDocumentVectors(userId, kbId, documentId));
}
Service 中的完整重建逻辑如下:
java
@Override
@Transactional(rollbackFor = Exception.class)
public VectorRebuildResponse rebuildDocumentVectors(Long userId, Long kbId, Long documentId) {
// 1. 校验知识库归属,防止重建别人的文档向量。
knowledgeBaseService.getActiveKnowledgeBase(userId, kbId);
// 2. 查询 document_info,必须同时带 userId 和 kbId。
DocumentInfo documentInfo = documentInfoMapper.selectOne(new LambdaQueryWrapper<DocumentInfo>()
.eq(DocumentInfo::getId, documentId)
.eq(DocumentInfo::getUserId, userId)
.eq(DocumentInfo::getKbId, kbId)
.last("LIMIT 1"));
if (documentInfo == null) {
throw new BusinessException("文档不存在");
}
// 3. 查询 MySQL 中已有的 document_chunk。
List<DocumentChunk> chunks = documentChunkService.listByDocument(documentId);
if (chunks == null || chunks.isEmpty()) {
throw new BusinessException("文档没有切片,无法重建向量");
}
// 4. 删除旧向量。
deleteByDocumentId(documentId);
// 5. 遍历 chunk,重新 Embedding 并写入 pgvector。
int indexedCount = 0;
for (DocumentChunk chunk : chunks) {
if (!StringUtils.hasText(chunk.getContent())) {
continue;
}
float[] embedding = embeddingModel.embed(chunk.getContent());
validateVectorDimension(embedding);
upsertChunkVector(documentInfo, chunk, embedding);
indexedCount++;
}
// 6. 更新 document_info 的索引状态和 chunk_count。
documentInfo.setIndexStatus(DocumentIndexStatus.INDEXED.name());
documentInfo.setChunkCount(indexedCount);
documentInfo.setErrorMessage(null);
documentInfo.setUpdatedAt(LocalDateTime.now());
documentInfoMapper.updateById(documentInfo);
VectorRebuildResponse response = new VectorRebuildResponse();
response.setUserId(userId);
response.setKbId(kbId);
response.setDocumentId(documentId);
response.setIndexedCount(indexedCount);
return response;
}
重建接口适合管理员或用户在"模型更换、切片规则调整、历史数据补向量"时使用。它不是普通问答链路的一部分,不应该在每次提问时调用。
11.10 向量检索 SQL
向量检索逻辑位于:
text
PgVectorDocumentVectorServiceImpl.searchSimilarChunks(...)
核心 SQL 是:
sql
SELECT chunk_id,
document_id,
kb_id,
user_id,
chunk_index,
document_name,
content,
1 - (embedding <=> ?::vector) AS similarity
FROM document_chunk_vector
WHERE kb_id = ?
AND user_id = ?
AND 1 - (embedding <=> ?::vector) >= ?
ORDER BY embedding <=> ?::vector
LIMIT ?
这段 SQL 可以拆成四部分理解。
11.10.1 先限定检索范围
sql
WHERE kb_id = ?
AND user_id = ?
这一步非常关键。
它保证只在当前登录用户自己的当前知识库中检索。
如果没有这两个条件,系统可能会从其他知识库甚至其他用户的文档里召回片段。
RAG 平台的权限隔离不能只做在文档列表接口上。
向量检索也必须做隔离。
11.10.2 计算相似度
sql
1 - (embedding <=> ?::vector) AS similarity
这里的 <=> 是 pgvector 的余弦距离操作符。
距离越小,表示越相似。
为了让返回值更符合直觉,项目把距离转换成相似度:
text
similarity = 1 - distance
这样数值越大,表示越相似。
例如,可以粗略理解为:
text
similarity 越接近 1,越相似
similarity 越低,越不相似
11.10.3 用阈值过滤
sql
AND 1 - (embedding <=> ?::vector) >= ?
这表示只保留相似度达到阈值的 chunk。
如果阈值是:
text
0.30
就表示相似度低于 0.30 的片段不返回。
阈值的作用是过滤明显不相关的内容。
11.10.4 按距离排序并限制数量
sql
ORDER BY embedding <=> ?::vector
LIMIT ?
因为距离越小越相似,所以按距离升序排序。
LIMIT ? 用于控制返回前几个结果,也就是 TopK。
完整逻辑可以翻译成:
text
在当前用户的当前知识库里,
找出相似度超过阈值的 chunk,
按向量距离从近到远排序,
返回前 K 个。
这就是 RAG 问答中的召回阶段。
searchSimilarChunks 的完整 Java 代码如下:
java
public List<VectorSearchResult> searchSimilarChunks(Long userId,
Long kbId,
float[] queryVector,
int topK,
double similarityThreshold) {
String queryVectorLiteral = PgVectorUtils.toVectorLiteral(queryVector);
String sql = """
SELECT chunk_id,
document_id,
kb_id,
user_id,
chunk_index,
document_name,
content,
1 - (embedding <=> ?::vector) AS similarity
FROM document_chunk_vector
WHERE kb_id = ?
AND user_id = ?
AND 1 - (embedding <=> ?::vector) >= ?
ORDER BY embedding <=> ?::vector
LIMIT ?
""";
return pgVectorJdbcTemplate.query(sql, ps -> {
// 11.10.2:计算相似度。
ps.setString(1, queryVectorLiteral);
// 11.10.1:限定检索范围。
ps.setLong(2, kbId);
ps.setLong(3, userId);
// 11.10.3:阈值过滤。
ps.setString(4, queryVectorLiteral);
ps.setDouble(5, similarityThreshold);
// 11.10.4:按距离排序并限制数量。
ps.setString(6, queryVectorLiteral);
ps.setInt(7, topK);
}, (rs, rowNum) -> {
VectorSearchResult result = new VectorSearchResult();
result.setChunkId(rs.getLong("chunk_id"));
result.setDocumentId(rs.getLong("document_id"));
result.setKbId(rs.getLong("kb_id"));
result.setUserId(rs.getLong("user_id"));
result.setChunkIndex(rs.getInt("chunk_index"));
result.setDocumentName(rs.getString("document_name"));
result.setContent(rs.getString("content"));
result.setSimilarity(rs.getDouble("similarity"));
return result;
});
}
对应的 DTO 可以这样定义:
java
@Data
public class VectorSearchResult {
private Long chunkId;
private Long documentId;
private Long kbId;
private Long userId;
private Integer chunkIndex;
private String documentName;
private String content;
private Double similarity;
}
这段方法必须坚持两个条件:kb_id = ? 和 user_id = ?。向量检索的权限隔离不能依赖前端传参,也不能只依赖文档列表接口。
11.11 TopK 和相似度阈值
TopK 和相似度阈值是两个非常重要的检索参数。
项目配置中默认值是:
yaml
rag:
vector:
top-k: 5
similarity-threshold: 0.30
11.11.1 TopK 是什么
TopK 表示返回最相似的前几个片段。
例如:
text
`topK = 5`
表示最多返回 5 个 chunk。
TopK 太小,可能漏掉有用信息。
TopK 太大,可能带来三个问题:
- 返回太多无关片段。
- 后续 Prompt 太长。
- 大模型回答成本变高。
所以项目中通过工具方法限制 TopK:
java
public static int normalizeTopK(Integer topK, int defaultTopK) {
int value = topK == null ? defaultTopK : topK;
if (value < 1) {
return 1;
}
return Math.min(value, 20);
}
这表示:
text
不传 `topK`:使用默认值
小于 1:按 1 处理
大于 20:最多按 20 处理
这是一个保护措施。
不能让前端随便传一个 topK=1000,否则一次问答可能召回大量片段,拖慢系统并增加模型调用成本。
11.11.2 相似度阈值是什么
相似度阈值用于控制"最低相关性"。
例如:
text
similarityThreshold = 0.30
表示相似度低于 0.30 的 chunk 不返回。
阈值太高,可能没有结果。
阈值太低,可能返回很多不相关内容。
项目中也做了范围归一化:
java
public static double normalizeThreshold(Double threshold, double defaultThreshold) {
double value = threshold == null ? defaultThreshold : threshold;
if (value < -1.0D) {
return -1.0D;
}
if (value > 1.0D) {
return 1.0D;
}
return value;
}
这表示:
text
不传阈值:使用默认值
小于 -1:按 -1 处理
大于 1:按 1 处理
实际调参时,可以先从 0.30 开始。
如果经常没有结果,可以适当降低。
如果经常召回不相关内容,可以适当提高。
11.12 向量检索接口
项目中向量检索接口位于:
text
com.luo.ragknowledge.vector.controller.KnowledgeSearchController
接口路径是:
text
GET /kb/{kbId}/search
请求参数包括:
text
question:用户问题,必传
topK:返回前几个片段,可选
similarityThreshold:相似度阈值,可选
示例:
text
GET /kb/2/search?question=这份文档主要验证什么&topK=5&similarityThreshold=0.3
控制器不会从请求参数中接收 userId。
它通过:
java
Long userId = UserContext.requireUserId();
读取 Gateway 透传下来的当前登录用户 ID。
这和前面权限章节的设计一致。
用户不能通过手动传 userId 去查询别人的知识库。
11.12.1 检索响应
接口返回:
text
kbId
userId
question
topK
similarityThreshold
resultCount
results
其中 results 中每个命中片段包括:
text
chunkId
documentId
kbId
userId
chunkIndex
documentName
content
similarity
这些字段已经足够支持下一步 RAG 问答。
因为后续拼 Prompt 时需要:
text
命中的片段内容 content
片段来源 documentName
片段顺序 chunkIndex
相似度 similarity
下一章中,我们会看到这些字段如何变成大模型 Prompt 的上下文和引用来源。
检索接口的完整 Controller 方法如下:
java
@GetMapping("/kb/{kbId}/search")
public ApiResponse<KnowledgeSearchResponse> search(
@PathVariable("kbId") @Positive Long kbId,
@RequestParam("question") @NotBlank String question,
@RequestParam(value = "topK", required = false) Integer topK,
@RequestParam(value = "similarityThreshold", required = false) Double similarityThreshold) {
Long userId = UserContext.requireUserId();
try {
KnowledgeSearchResponse response = documentVectorService.search(
userId,
kbId,
question,
topK,
similarityThreshold
);
return ApiResponse.success(response);
} catch (BusinessException e) {
// 向量功能关闭、知识库不存在、参数错误等业务异常,都返回明确错误。
throw e;
} catch (Exception e) {
throw new BusinessException("向量检索失败:" + e.getMessage());
}
}
这里仍然不允许前端传 userId。当前用户身份只能来自 Gateway 透传的请求头,再由 UserContext 读取。
11.13 向量功能关闭时怎么办
不是所有开发环境都一定安装了 PostgreSQL + pgvector。
尤其是在学习阶段,读者可能先完成:
text
用户登录
知识库管理
文档上传
文档切片
再单独安装向量库。
所以项目中提供了关闭向量能力的实现:
text
DisabledDocumentVectorServiceImpl
它在下面配置时生效:
yaml
rag:
vector:
enabled: false
当向量库关闭时:
text
文档索引时跳过向量写入
删除向量时只打印日志
向量重建接口直接报错
向量检索接口直接报错
错误提示是:
text
向量库未启用,请先配置 rag.vector.enabled=true 并启动 PostgreSQL + pgvector
这种设计对教学项目很友好。
因为读者可以分阶段运行系统。
但也要明确一点:
text
只要要做真正的 RAG 检索,就必须启用 pgvector。
DisabledDocumentVectorServiceImpl 的骨架代码如下:
java
package com.luo.knowhub.knowledge.vector.service.impl;
import com.luo.knowhub.common.exception.BusinessException;
import com.luo.knowhub.knowledge.document.entity.DocumentChunk;
import com.luo.knowhub.knowledge.document.entity.DocumentInfo;
import com.luo.knowhub.knowledge.vector.dto.KnowledgeSearchResponse;
import com.luo.knowhub.knowledge.vector.dto.VectorRebuildResponse;
import com.luo.knowhub.knowledge.vector.service.DocumentVectorService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import java.util.List;
@Slf4j
@Service
@ConditionalOnProperty(prefix = "rag.vector", name = "enabled", havingValue = "false", matchIfMissing = true)
public class DisabledDocumentVectorServiceImpl implements DocumentVectorService {
@Override
public int indexDocumentChunks(DocumentInfo documentInfo, List<DocumentChunk> chunks) {
// 向量库关闭时,文档解析和切片仍可继续,向量写入直接跳过。
log.info("向量库未启用,跳过文档向量写入,documentId={}, chunkCount={}",
documentInfo == null ? null : documentInfo.getId(),
chunks == null ? 0 : chunks.size());
return 0;
}
@Override
public void deleteByDocumentId(Long documentId) {
// 没有向量库时,删除旧向量也只打印日志。
log.info("向量库未启用,跳过删除旧向量,documentId={}", documentId);
}
@Override
public KnowledgeSearchResponse search(Long userId, Long kbId, String question,
Integer topK, Double similarityThreshold) {
throw vectorDisabled();
}
@Override
public VectorRebuildResponse rebuildDocumentVectors(Long userId, Long kbId, Long documentId) {
throw vectorDisabled();
}
private BusinessException vectorDisabled() {
return new BusinessException("向量库未启用,请先配置 rag.vector.enabled=true 并启动 PostgreSQL + pgvector");
}
}
这就是接口抽象的价值:业务调用的是 DocumentVectorService,具体启用哪个实现,由配置决定。
11.14 ivfflat 索引和 ANALYZE
向量表数据少的时候,没有索引也能查。
比如只有几十个 chunk,顺序扫描也很快。
但真实知识库里,chunk 数量可能越来越多:
text
1000
10000
100000
这时就需要向量索引。
项目使用的是:
sql
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100)
可以先这样理解:
text
ivfflat 会把向量按相似区域分组,
查询时优先在相关区域里找,
从而减少全量扫描成本。
lists 可以理解成分组数量。
列表太少,检索可能不够快。
列表太多,索引构建成本和查询调参也会变复杂。
学习项目中使用 lists = 100 是一个容易理解的默认值。
大量导入向量后,建议执行:
sql
ANALYZE document_chunk_vector;
它的作用是更新统计信息。
如果不执行,PostgreSQL 可能不知道表中数据分布已经变化,查询计划可能不理想。
这里不要求初学者一开始就理解所有数据库优化细节。
先记住两点即可:
text
数据少时,先保证功能正确。
数据多时,再关注向量索引和查询计划。
11.15 从检索到 RAG 问答
到目前为止,系统已经能完成语义召回。
完整流程是:
text
用户问题
-> EmbeddingModel 生成问题向量
-> PgVectorUtils 转成 pgvector 字面量
-> document_chunk_vector 中计算距离
-> 按 user_id 和 kb_id 限定范围
-> 按相似度阈值过滤
-> 按距离排序
-> 返回 TopK chunk
但这还不是完整 RAG。
现在系统只是找到了相关片段。
还没有把这些片段组织成回答。
下一步需要做的是:
text
把 TopK chunk 拼接成上下文
把用户问题放入 Prompt
调用聊天模型
生成答案
返回引用来源
记录问答日志
也就是说:
text
pgvector 负责找资料
聊天模型负责组织答案
第 12 章会进入 RAG 问答闭环,讲清检索结果如何变成最终答案。
11.16 常见问题排查
11.16.1 PostgreSQL 不认识 vector 类型
现象:
text
type "vector" does not exist
原因通常是 pgvector 扩展没有安装或没有在当前数据库启用。
排查顺序:
- PostgreSQL 是否使用了带 pgvector 的镜像或已安装扩展。
- 当前连接的数据库是否是
rag_vector。 - 是否执行了
CREATE EXTENSION IF NOT EXISTS vector;。 - 建表脚本是否在正确数据库中执行。
11.16.2 向量维度写入失败
现象:
text
expected 1024 dimensions, not 768
排查顺序:
- Embedding 模型是否换过。
/ai/embedding返回的dimension是多少。rag.vector.dimension是否为 1024。- 表字段是否为
embedding vector(1024)。 - 是否需要重建向量表和历史向量。
11.16.3 向量检索没有结果
排查顺序:
- 文档索引任务是否 SUCCESS。
- MySQL 中
document_chunk是否有数据。 - PostgreSQL 中
document_chunk_vector是否有数据。 user_id是否正确。kb_id是否正确。- 相似度阈值是否过高。
- 用户问题是否和文档内容相关。
可以先临时降低阈值,例如:
text
similarityThreshold=0.0
观察是否能召回结果。
如果降低阈值后有结果,说明原阈值过高。
如果仍然没有结果,要检查索引是否真的写入成功。
11.16.4 检索结果不相关
可能原因:
- chunk 切得太碎。
- chunk 切得太大。
- overlap 设置不合理。
- Embedding 模型效果不理想。
- 相似度阈值太低。
- TopK 太大,引入了弱相关片段。
解决思路:
text
先看 chunk 内容质量,
再调阈值和 TopK,
最后再考虑更换 Embedding 模型。
11.16.5 查询速度慢
排查顺序:
document_chunk_vector数据量是否变大。- 是否创建了 ivfflat 向量索引。
- 是否执行过
ANALYZE document_chunk_vector。 - 查询是否带了
user_id和kb_id。 - TopK 是否设置过大。
- PostgreSQL 所在机器资源是否不足。
11.16.6 检索到了别人的数据
这属于严重问题。
优先检查 SQL 是否包含:
sql
WHERE kb_id = ?
AND user_id = ?
同时检查:
- Gateway 是否正确透传用户 ID。
UserContext.requireUserId()是否拿到当前用户。- 写入向量表时
user_id是否来自正确文档。 - 手动测试时是否绕过了 Gateway。
向量检索的权限隔离必须和业务查询一样严格。
11.16.7 rag.vector.enabled=false
现象:
text
向量库未启用,请先配置 rag.vector.enabled=true 并启动 PostgreSQL + pgvector
说明当前环境关闭了向量能力。
解决:
yaml
rag:
vector:
enabled: true
同时确认:
text
PostgreSQL 已启动
pgvector 扩展已启用
document_chunk_vector 表已创建
连接 URL、用户名、密码正确
11.16.8 pgvector 扩展已安装但不在正确的数据库中
现象:执行初始化脚本时报错:
text
type "vector" does not exist
但你检查后发现 pgvector 好像已经安装过。
排查顺序:
- 确认当前连接的数据库是否正确,例如是否已经连接到
rag_vector。 - 在 psql 中执行
SELECT current_database();,确认当前库名。 - 执行
SELECT extname FROM pg_extension;,查看当前数据库已经启用的扩展。 - 如果当前库没有
vector,执行:
sql
CREATE EXTENSION IF NOT EXISTS vector;
注意:pgvector 扩展是按数据库启用的,不是全局启用。你在默认 postgres 数据库中创建了扩展,不代表 rag_vector 数据库也能使用 vector 类型。
11.16.9 写入向量成功但检索结果为空(向量索引未生效)
现象:document_chunk_vector 表中有数据,相似度阈值也看起来合理,但检索结果始终为空或查询计划异常。
排查顺序:
- 确认向量表中数据量是否很少。数据量少时,PostgreSQL 可能不使用 ivfflat 索引,这是正常现象。
- 执行
EXPLAIN ANALYZE查看检索 SQL 的实际执行计划。 - 确认是否创建了
idx_document_chunk_vector_embedding_cosine。 - 批量导入向量后,执行:
sql
ANALYZE document_chunk_vector;
- 如果仍然没有结果,把
similarityThreshold临时调成 0,确认是索引或数据问题,还是阈值过滤问题。
ivfflat 索引主要影响查询速度,不应该改变查询语义。如果阈值为 0 仍然没有结果,优先检查 user_id、kb_id 和向量表数据是否匹配。
本章小结
这一章我们讲了 PostgreSQL + pgvector 向量检索。
pgvector 让 PostgreSQL 支持 vector 字段和向量距离计算。KnowHub 使用 MySQL 保存业务主数据,使用 PostgreSQL + pgvector 保存 document_chunk 的向量检索副本。
document_chunk_vector 表是本章核心。它通过 chunk_id 关联 MySQL chunk,通过 user_id 和 kb_id 保证检索隔离,通过 embedding vector(1024) 保存 Embedding 向量。
写入向量时,项目先把 float[] 转换成 pgvector 字面量,再通过 ?::vector 写入数据库。ON CONFLICT (chunk_id) DO UPDATE 保证重复索引时不会插入重复记录。
检索向量时,系统先把用户问题向量化,再在当前用户、当前知识库范围内计算余弦距离。ORDER BY embedding <=> ?::vector 用于按距离从近到远排序,LIMIT 控制 TopK,similarityThreshold 控制最低相关性。
到这里,KnowHub 已经完成了 RAG 中的"召回"部分。下一章,我们会把召回到的 TopK chunk 变成 Prompt,上交给聊天模型,完成真正的 RAG 问答闭环。
本章涉及的关键类与文件
本章涉及这些关键文件:
text
sql/postgresql/
01_rag_vector_schema.sql
建表脚本:启用 vector 扩展、创建 document_chunk_vector、创建范围索引和向量索引
knowledge-service/src/main/java/.../
vector/
PgVectorUtils.java
float[] 与 pgvector 字面量互转
config/
PgVectorJdbcConfig.java
pgvector 独立数据源配置
service/
DocumentVectorService.java
向量服务接口
impl/
PgVectorDocumentVectorServiceImpl.java
向量写入、检索、删除、重建
DisabledDocumentVectorServiceImpl.java
向量功能关闭时的降级实现
controller/
KnowledgeSearchController.java
语义检索接口 GET /kb/{kbId}/search
knowledge-service/src/main/resources/
application.yml
rag.vector 连接配置和检索业务配置
动手验证:从向量写入到语义检索
步骤一:确认 PostgreSQL + pgvector 容器已启动。
powershell
docker ps
docker exec -it knowhub-pgvector psql -U knowhub -d rag_vector -c "SELECT extname FROM pg_extension WHERE extname='vector';"
预期能看到 vector 扩展。对应本章 11.2 和 11.4。
步骤二:启动 knowledge-service,确认日志中没有 pgVectorJdbcTemplate 创建失败、连接拒绝或认证失败。
对应本章 11.6。
步骤三:上传一个测试文档并等待索引完成。
查询 MySQL:
sql
SELECT id, index_status, chunk_count
FROM document_info
WHERE id = 文档ID;
预期 index_status=INDEXED。对应本章 11.8 和 11.9。
步骤四:查询 pgvector 表记录数。
sql
SELECT COUNT(*)
FROM document_chunk_vector
WHERE document_id = 文档ID;
预期记录数与 MySQL 的 document_chunk 数量一致。对应本章 11.8。
步骤五:查看向量字段的前几个值。
sql
SELECT chunk_id, embedding[1:5]
FROM document_chunk_vector
LIMIT 1;
预期看到正常浮点数,而不是全零、NaN 或空值。对应本章 11.7。
步骤六:调用 Embedding 调试接口确认查询向量维度。
http
GET /ai/embedding?text=测试检索问题
预期返回 dimension=1024。对应第 10 章和本章 11.5.8。
步骤七:调用向量检索接口。
http
GET /kb/{kbId}/search?question=你的测试问题&topK=5&similarityThreshold=0.3
Authorization: Bearer <token>
预期返回 200,响应中包含 results 数组。对应本章 11.10 和 11.12。
步骤八:检查返回结果。
确认每个结果里的 similarity 大于阈值,content 是可读文本,documentName 非空。对应本章 11.12 和 11.15。
步骤九:降低阈值再次检索。
http
GET /kb/{kbId}/search?question=你的测试问题&topK=5&similarityThreshold=0
预期返回结果更多。对应本章 11.11。
步骤十:上传多个不同主题文档到同一个知识库,再用明确问题检索。
例如上传"报销制度"和"请假制度",然后提问"发票最晚什么时候提交"。预期最相似片段来自"报销制度"。对应本章 11.10。
如果检索始终无结果,优先执行:
sql
SELECT count(*)
FROM document_chunk_vector
WHERE kb_id = ? AND user_id = ?;
先确认当前用户在当前知识库中确实有向量数据,再检查相似度阈值是否设置过高。
思考题
- 为什么 MySQL 适合保存业务数据,但不适合直接做高维向量相似度检索?
- pgvector 给 PostgreSQL 增加了哪些能力?
- 为什么
document_chunk_vector可以理解成document_chunk的向量检索副本? chunk_id为什么要加唯一约束?- 向量检索 SQL 为什么必须带上
user_id和kb_id? embedding vector(1024)和 Embedding 模型输出维度有什么关系?PgVectorUtils.toVectorLiteral(...)解决了什么问题??::vector在 SQL 中的作用是什么?ON CONFLICT (chunk_id) DO UPDATE为什么适合索引重建场景?<=>距离越小,为什么表示越相似?- TopK 太大和太小分别会带来什么问题?
- 相似度阈值过高和过低分别会导致什么现象?
思考题参考答案
1. 为什么 MySQL 适合保存业务数据,但不适合直接做高维向量相似度检索?
MySQL 适合保存有清晰字段、可以用条件查询和事务保证一致性的业务数据。但高维向量相似度检索需要计算向量之间的距离(如余弦距离),这不是简单的等值查询或 LIKE 查询。如果每次都把所有向量查出来再在 Java 里循环计算距离,数据量一大就会很慢。MySQL 本身不支持向量类型、向量距离计算和向量索引,所以不适合直接做高维向量相似度检索。
2. pgvector 给 PostgreSQL 增加了哪些能力?
pgvector 给 PostgreSQL 增加了三种能力:一是支持 vector 字段类型,可以保存高维浮点向量;二是提供向量距离计算操作符(如 <=> 余弦距离);三是支持向量索引(如 IVFFlat),可以加速相似度检索。
3. 为什么 document_chunk_vector 可以理解成 document_chunk 的向量检索副本?
因为同一个 chunk 在两个地方出现:MySQL 的 document_chunk 保存业务切片(原始文本和业务关系),PostgreSQL 的 document_chunk_vector 保存该 chunk 的 embedding 向量和一份 content 副本。document_chunk_vector 不是完整业务表,而是专门为向量检索设计的副本表,它的核心职责是保存 embedding 向量并保留足够的业务字段用于检索范围过滤和结果返回。
4. chunk_id 为什么要加唯一约束?
因为一个 chunk 在向量表中应该只有一条向量记录。如果同一个 chunk 重复写入多条向量,检索时可能出现重复片段。加上唯一约束后,配合 ON CONFLICT (chunk_id) DO UPDATE,可以实现第一次写入时插入新向量,再次写入时更新旧向量,保证同一个 chunk 在向量库里只有一条记录。
5. 向量检索 SQL 为什么必须带上 user_id 和 kb_id?
因为 RAG 平台通常是多用户系统,向量表中会保存不同用户、不同知识库的 chunk。如果不加 user_id 和 kb_id,系统可能会从其他知识库甚至其他用户的文档里召回片段,导致严重的数据隔离问题。这两个字段保证只在当前登录用户自己的当前知识库中检索。
6. embedding vector(1024) 和 Embedding 模型输出维度有什么关系?
embedding vector(1024) 中的 1024 表示该字段保存的是一个 1024 维向量。这个维度必须和 Embedding 模型的输出维度一致,即:Embedding 模型输出维度 = rag.vector.dimension = embedding vector(1024)。只要不一致,写入就会失败。
7. PgVectorUtils.toVectorLiteral(...) 解决了什么问题?
它解决了 Java float[] 和 pgvector 之间的格式转换问题。PostgreSQL 不能直接接收 Java 的 float[] 对象,pgvector 需要类似 [0.1,0.2,0.3] 的文本格式。toVectorLiteral 把 Java float[] 转换成这种 pgvector 字面量字符串,同时还会校验向量是否为空、是否包含 NaN 或 Infinity,防止非法向量写入数据库。
8. ?::vector 在 SQL 中的作用是什么?
? 是 JDBC 参数占位符,::vector 是 PostgreSQL 的类型转换语法。连起来表示把传入的字符串参数(如 [0.1,0.2,0.3])转换成 pgvector 的 vector 类型。没有这个类型转换,PostgreSQL 无法把字符串文本识别为向量类型。
9. ON CONFLICT (chunk_id) DO UPDATE 为什么适合索引重建场景?
因为索引任务可能会因为重试、重建、重复消费而再次写入同一个 chunk。如果没有冲突更新,可能出现两种问题:一是唯一约束报错导致任务失败;二是如果没有唯一约束,重复记录进入向量库。ON CONFLICT (chunk_id) DO UPDATE 保证同一个 chunk 在向量库里只有一条记录,第一次写入时插入,再次写入时更新,非常适合索引重建场景。
10. <=> 距离越小,为什么表示越相似?
<=> 是 pgvector 的余弦距离操作符。余弦距离衡量两个向量在方向上的差异,距离越小表示两个向量的方向越接近,也就是语义越相似。为了让返回值更符合直觉,项目把距离转换成相似度:similarity = 1 - distance,这样数值越大表示越相似。
11. TopK 太大和太小分别会带来什么问题?
TopK 太小,可能漏掉有用信息,导致大模型无法获取足够的上下文来回答问题。TopK 太大,可能带来三个问题:一是返回太多无关片段,引入噪声;二是后续 Prompt 太长,超出模型上下文窗口;三是大模型回答成本变高。项目中通过工具方法限制 TopK 最大为 20,防止前端随意传大值。
12. 相似度阈值过高和过低分别会导致什么现象?
相似度阈值过高,可能没有结果返回,因为所有 chunk 的相似度都达不到阈值。相似度阈值过低,可能返回很多不相关内容,引入噪声片段。实际调参时可以先从 0.30 开始,如果经常没有结果就适当降低,如果经常召回不相关内容就适当提高。