优化数据布局

已完成

数据建模决策显著影响矢量搜索性能。 如何构建表、选择元数据的数据类型,以及创建支持索引来确定查询在数据集增长时是否高效执行。

注释

本单元中的代码示例演示了使用元数据的矢量数据的架构设计模式。 根据特定的数据模型和查询要求调整这些模式。

矢量存储注意事项

矢量列消耗大量存储和处理资源。 了解存储特征有助于就架构设计做出明智的决策。

每个向量维度增加 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); 在所有分区上自动创建匹配索引。 每个分区都有自己的索引,可以独立生成或重新生成。 对于重新生成单个全局索引需要数小时的大型数据集来说,这非常有用。

分区会增加复杂性。 跨多个分区的查询可能比单个表慢。 跨分区唯一约束需要约束中的分区键。 应用程序逻辑可能需要了解分区边界。 在实现分区之前,评估查询模式是否与潜在的分区键保持一致。

其他资源