优化数据布局
数据建模决策显著影响矢量搜索性能。 如何构建表、选择元数据的数据类型,以及创建支持索引来确定查询在数据集增长时是否高效执行。
注释
本单元中的代码示例演示了使用元数据的矢量数据的架构设计模式。 根据特定的数据模型和查询要求调整这些模式。
矢量存储注意事项
矢量列消耗大量存储和处理资源。 了解存储特征有助于就架构设计做出明智的决策。
每个向量维度增加 4 字节的存储(用于单精度浮点数),外加固定开销。 维度和存储之间的关系是线性的:
| 尺寸 | 每个向量字节数 | 100 万向量 |
|---|---|---|
| 384 | 约 1.5 KB | ~1.5 GB |
| 768 | 约 3 KB | ~3 GB |
| 1536 | 约 6 KB | ~6 GB |
| 3072 | 约 12 KB | ~12 GB |
对于使用 1536 维嵌入项的 200 万项的产品目录,仅矢量列需要大约 12 GB 的存储。 ** 添加 HNSW 索引会使数量增加大约 50%。
许多嵌入模型提供了多个维度选项。 较低的维度可降低存储和计算成本,同时为许多用例保持合理的质量。 在列定义中指定维度可提供验证。 尝试插入不同维度的向量会失败,并出现错误,从而防止了嵌入模型不匹配带来的细微错误。 使用 embedding vector(768) 在列定义中定义具有显式维度约束的表。
某些应用程序需要来自不同模型的矢量。 例如,可以单独存储产品标题嵌入、图像嵌入和用户行为嵌入。 每个向量列都需要自己的索引,因为无法创建涵盖多个向量列的单个索引。
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
title_embedding vector(768), -- Text embedding model
image_embedding vector(512), -- Image embedding model
category_id INTEGER,
price NUMERIC(10,2)
);
-- Create separate indexes for each embedding type
CREATE INDEX ON products USING hnsw (title_embedding vector_cosine_ops);
CREATE INDEX ON products USING hnsw (image_embedding vector_cosine_ops);
元数据数据类型:结构化列与 JSONB
产品建议很少仅使用矢量相似性。 查询通常在矢量搜索之前或旁边按类别、价格范围、可用性或其他属性进行筛选。 存储此元数据的方式会影响查询性能。
结构化列使用 PostgreSQL 的本机数据类型(INTEGER、TIMESTAMP、NUMERIC、TEXT)和显式架构。 这些列提供了查询性能优势,因为本机类型为等式和范围查询提供了高效的 B 树索引,通过优化存储格式实现了存储效率,通过插入时间验证实现了类型安全,并通过准确的计划器统计实现了查询优化。 在设计时已知属性时使用结构化列,经常按特定属性进行筛选或排序,或者查询性能至关重要。
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
embedding vector(1536),
category_id INTEGER NOT NULL,
price NUMERIC(10,2) NOT NULL,
in_stock BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
brand TEXT,
rating NUMERIC(2,1)
);
JSONB 将半结构化数据存储为二进制 JSON,为动态属性提供灵活性。 JSONB 提供架构的灵活性(不同的产品可以具有不同的属性)、便捷的升级能力(可以添加新属性而无需进行架构迁移)和嵌套结构(存储复杂的层次数据)。 但是,JSONB 具有查询开销(提取值需要分析)、索引限制(GIN 索引适用于包含查询而不是范围查询),以及规划器不确定性(JSONB 内容的统计信息不太精确)。
对于筛选的矢量搜索,元数据筛选器性能直接影响到总查询时间。 具有 B 树索引的结构化列使 PostgreSQL 能够在矢量距离计算之前快速缩小候选项,而 JSONB 需要不同的查询模式和索引类型。 结构化列筛选器,例如 WHERE category_id = 5 AND price BETWEEN 100 AND 500 ,可以使用 B 树索引 (category_id, price) 快速识别候选行。 JSONB 筛选器(如 WHERE attributes @> '{"category": "electronics"}' AND (attributes->>'price')::numeric BETWEEN 100 AND 500)需要 GIN 索引(这不会帮助价格范围查询)或对 JSONB 列的顺序扫描。
许多应用程序受益于组合结构化列和 JSONB:对查询性能重要的频繁筛选属性使用结构化列,对于动态或很少筛选的属性,JSONB 具有更有价值的架构灵活性。 此模式使你可以优化常见情况,而不会牺牲边缘事例的灵活性。
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
embedding vector(1536),
-- Structured columns for common filters
category_id INTEGER NOT NULL,
price NUMERIC(10,2) NOT NULL,
in_stock BOOLEAN DEFAULT true,
-- JSONB for dynamic attributes
attributes JSONB DEFAULT '{}'
);
筛选搜索的元数据索引
元数据索引通过加速查询的筛选阶段来补充矢量索引。 如果没有适当的元数据索引,PostgreSQL 可能需要扫描所有行以在矢量搜索之前应用筛选器。
针对 WHERE 子句中使用的列创建 B 树索引。 单列索引处理精确匹配,而复合索引处理过滤条件组合。 当查询筛选最左侧的列时,复合索引最有效。
(category_id, price) 上的索引有效地处理了 WHERE category_id = 5 和 WHERE category_id = 5 AND price < 100,但仅对 WHERE price < 100 没有帮助,因为价格不是最左侧的列。
-- Single-column index for exact matches
CREATE INDEX idx_products_category ON products (category_id);
-- Composite index for common filter combinations
CREATE INDEX idx_products_category_price ON products (category_id, price);
如果大多数查询根据相同条件(如库存产品)进行筛选,则部分索引会减少索引大小并提高性能。 此索引小于完整索引,仅用于包含 WHERE in_stock = true的查询。 对于几乎所有查询都面向可用产品的电子商务建议引擎,这可以显著减少索引维护开销。 使用 CREATE INDEX idx_products_instock_category ON products (category_id) WHERE in_stock = true;. 创建分部索引。
如果将 JSONB 用于属性,则 GIN 索引支持使用 @> (包含)、<@ (被包含)、? (键存在) 和 ?|/?& (任何/所有键存在)运算符的包含查询。 它们不会加速范围查询或任意 JSON 路径表达式。 使用 CREATE INDEX idx_products_attributes ON products USING gin (attributes);. 创建 GIN 索引。 对于需要范围查询的频繁查询的 JSONB 字段,请考虑表达式索引。 在提取为数值 CREATE INDEX idx_products_json_price ON products (((attributes->>'price')::numeric)); 的 JSONB 字段上创建表达式索引,以便在该字段上启用范围查询。
将矢量搜索与元数据筛选器组合在一起
PostgreSQL 通过将索引扫描与筛选相结合来执行查询。 了解执行模式有助于编写高效的查询。
最有效的模式首先应用元数据筛选器,从而减少需要相似性计算的矢量集。 PostgreSQL 使用元数据索引来标识与筛选器匹配的产品,然后将向量相似性仅应用于这些候选项。 如果 5% 的产品与你的筛选条件匹配,那么你只需搜索 100,000 个矢量,而不是 200 万个矢量。
-- Efficient: filter narrows candidates before vector search
SELECT id, name, embedding <=> $1 AS distance
FROM products
WHERE category_id = 5
AND in_stock = true
AND price BETWEEN 100 AND 500
ORDER BY embedding <=> $1
LIMIT 10;
用于 EXPLAIN ANALYZE 验证查询是否使用预期的索引并确定性能瓶颈。 查询计划显示 PostgreSQL 是否使用元数据索引在矢量搜索之前筛选候选项,还是使用顺序扫描来检查每一行。 在元数据列上查找 索引扫描 或 位图索引扫描 (高效)、使用矢量索引(高效)的 索引扫描 和大型表上的 Seq 扫描 (可能效率低下)。 如果看到意外的顺序扫描,请检查是否存在适当的索引,并使用 ANALYZE products; 检查统计信息是否是最新的。
某些查询不适合高效筛选。 当筛选器没有过滤掉很多行(例如 WHERE price > 0 这种几乎适用于所有产品的情况),PostgreSQL 可能会完全跳过元数据索引,并且仅依靠矢量索引。 这是预期行为,因为优化器做出基于成本的决策。
有时,你需要来自矢量索引的结果,这些结果也满足事先无法有效筛选的约束。 后过滤模式将提取比需要更多的矢量相似候选项,然后应用筛选器。 根据预期的筛选器选择性调整内部 LIMIT。
-- Get more candidates than needed, then filter
WITH candidates AS (
SELECT id, name, price, in_stock, embedding <=> $1 AS distance
FROM products
ORDER BY embedding <=> $1
LIMIT 100
)
SELECT id, name, distance
FROM candidates
WHERE in_stock = true AND price BETWEEN 100 AND 500
ORDER BY distance
LIMIT 10;
大型数据集的表分区
分区将大型表划分为更小、更易于管理的部分。 对于矢量工作负荷,分区可以提高查询性能并简化维护。
当表超过数千万行时,考虑分区,查询自然按分区键(日期、类别、租户)进行筛选,你需要有效地删除旧数据(分区裁剪优化),或者整个表的索引构建时间变得难以接受。
对于处理时序嵌入(如用户活动向量或随时间发布的内容)的应用程序,按日期划分的范围是有效的。 按日期筛选的查询仅扫描相关的分区。 每个分区都有自己的索引,使维护更易于管理。
-- Create partitioned table
CREATE TABLE user_interactions (
id BIGSERIAL,
user_id BIGINT NOT NULL,
embedding vector(768),
created_at TIMESTAMP NOT NULL,
interaction_type TEXT
) PARTITION BY RANGE (created_at);
-- Create partitions for each month
CREATE TABLE user_interactions_2025_01
PARTITION OF user_interactions
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
对于具有自然类别的多租户应用程序或产品目录,列表或哈希分区可以帮助。 按类别扫描筛选的查询仅对相关分区进行筛选,从而减少了扫描的数据和索引大小。
在父表上创建索引,以使用 CREATE INDEX ON products USING hnsw (embedding vector_cosine_ops); 在所有分区上自动创建匹配索引。 每个分区都有自己的索引,可以独立生成或重新生成。 对于重新生成单个全局索引需要数小时的大型数据集来说,这非常有用。
分区会增加复杂性。 跨多个分区的查询可能比单个表慢。 跨分区唯一约束需要约束中的分区键。 应用程序逻辑可能需要了解分区边界。 在实现分区之前,评估查询模式是否与潜在的分区键保持一致。