PostgreSQL笔记50: 基于PGVECTOR的实时推荐与搜索系统构建

纲要

本文档梳理了基于 PostgreSQLpgvector 扩展构建实时推荐与搜索系统的核心概念、技术流程与代码实现。

  • 核心概念
    • 实时推荐与搜索
    • 向量相似性检索
    • 全文检索
    • 混合检索
    • 个性化推荐
  • 技术栈与组件
    • PostgreSQL (数据库核心)
    • pgvector (向量存储与检索扩展)
    • tsvector / tsquery (全文检索)
    • GIN 索引 (全文检索加速)
    • HNSW / IVFFlat 索引 (向量检索加速)
    • 距离操作符: <-> (L2距离), <=> (余弦距离)
    • 排序与评分函数: ts_rank(), 自定义热度与时序衰减函数
  • 系统架构流程
    • 数据层: 商品表, 用户画像表, 用户行为表
    • 计算层: SQL 动态查询, 向量计算, 业务过滤, 排序逻辑
    • 加速层: 多类型索引, 可选的缓存层与连接池 (PgBouncer)
  • 核心查询场景
    • 业务过滤 + 向量 Top-K 检索
    • 全文检索 + 语义检索混合排序
    • 引入热度与时效性的复合排序
    • 基于用户画像的个性化推荐

实时推荐与搜索概述

在现代电商、内容平台和资讯应用中,实时推荐与搜索已成为核心用户体验组件。其关键在于系统能够在极短时间内(毫秒级),基于用户当前的输入、历史行为和实时上下文,动态计算并返回最相关的结果。

传统实现通常依赖复杂的组件栈,如 Elasticsearch 用于全文检索,Faiss 用于向量检索,再辅以独立的特征服务和排序服务。这种架构带来了显著的运维复杂度和性能开销。

PostgreSQL 结合 pgvector 扩展提供了一种极具吸引力的替代方案。它将全文检索、向量语义检索、业务数据过滤和复杂排序能力整合在同一个数据库中,极大地简化了技术栈,降低了数据一致性和运维的复杂性。

系统核心流程

一个典型的实时推荐或搜索请求,其核心处理流程可在数据库单次查询中完成:
#mermaid-svg-N2HLf3UUyyRvRQ0K{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-N2HLf3UUyyRvRQ0K .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-N2HLf3UUyyRvRQ0K .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-N2HLf3UUyyRvRQ0K .error-icon{fill:#552222;}#mermaid-svg-N2HLf3UUyyRvRQ0K .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-N2HLf3UUyyRvRQ0K .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-N2HLf3UUyyRvRQ0K .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-N2HLf3UUyyRvRQ0K .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-N2HLf3UUyyRvRQ0K .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-N2HLf3UUyyRvRQ0K .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-N2HLf3UUyyRvRQ0K .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-N2HLf3UUyyRvRQ0K .marker{fill:#333333;stroke:#333333;}#mermaid-svg-N2HLf3UUyyRvRQ0K .marker.cross{stroke:#333333;}#mermaid-svg-N2HLf3UUyyRvRQ0K svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-N2HLf3UUyyRvRQ0K p{margin:0;}#mermaid-svg-N2HLf3UUyyRvRQ0K .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-N2HLf3UUyyRvRQ0K .cluster-label text{fill:#333;}#mermaid-svg-N2HLf3UUyyRvRQ0K .cluster-label span{color:#333;}#mermaid-svg-N2HLf3UUyyRvRQ0K .cluster-label span p{background-color:transparent;}#mermaid-svg-N2HLf3UUyyRvRQ0K .label text,#mermaid-svg-N2HLf3UUyyRvRQ0K span{fill:#333;color:#333;}#mermaid-svg-N2HLf3UUyyRvRQ0K .node rect,#mermaid-svg-N2HLf3UUyyRvRQ0K .node circle,#mermaid-svg-N2HLf3UUyyRvRQ0K .node ellipse,#mermaid-svg-N2HLf3UUyyRvRQ0K .node polygon,#mermaid-svg-N2HLf3UUyyRvRQ0K .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-N2HLf3UUyyRvRQ0K .rough-node .label text,#mermaid-svg-N2HLf3UUyyRvRQ0K .node .label text,#mermaid-svg-N2HLf3UUyyRvRQ0K .image-shape .label,#mermaid-svg-N2HLf3UUyyRvRQ0K .icon-shape .label{text-anchor:middle;}#mermaid-svg-N2HLf3UUyyRvRQ0K .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-N2HLf3UUyyRvRQ0K .rough-node .label,#mermaid-svg-N2HLf3UUyyRvRQ0K .node .label,#mermaid-svg-N2HLf3UUyyRvRQ0K .image-shape .label,#mermaid-svg-N2HLf3UUyyRvRQ0K .icon-shape .label{text-align:center;}#mermaid-svg-N2HLf3UUyyRvRQ0K .node.clickable{cursor:pointer;}#mermaid-svg-N2HLf3UUyyRvRQ0K .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-N2HLf3UUyyRvRQ0K .arrowheadPath{fill:#333333;}#mermaid-svg-N2HLf3UUyyRvRQ0K .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-N2HLf3UUyyRvRQ0K .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-N2HLf3UUyyRvRQ0K .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-N2HLf3UUyyRvRQ0K .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-N2HLf3UUyyRvRQ0K .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-N2HLf3UUyyRvRQ0K .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-N2HLf3UUyyRvRQ0K .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-N2HLf3UUyyRvRQ0K .cluster text{fill:#333;}#mermaid-svg-N2HLf3UUyyRvRQ0K .cluster span{color:#333;}#mermaid-svg-N2HLf3UUyyRvRQ0K div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-N2HLf3UUyyRvRQ0K .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-N2HLf3UUyyRvRQ0K rect.text{fill:none;stroke-width:0;}#mermaid-svg-N2HLf3UUyyRvRQ0K .icon-shape,#mermaid-svg-N2HLf3UUyyRvRQ0K .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-N2HLf3UUyyRvRQ0K .icon-shape p,#mermaid-svg-N2HLf3UUyyRvRQ0K .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-N2HLf3UUyyRvRQ0K .icon-shape .label rect,#mermaid-svg-N2HLf3UUyyRvRQ0K .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-N2HLf3UUyyRvRQ0K .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-N2HLf3UUyyRvRQ0K .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-N2HLf3UUyyRvRQ0K :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 数据存储
用户发起请求
应用层构建SQL
PostgreSQL 执行引擎
业务过滤 WHERE
向量相似度检索 ORDER BY dist
全文检索匹配 tsvector AND tsquery
结果排序与打分 ts_rank 自定义评分
返回Top-K结果
商品表 Products
用户画像 Profiles
用户行为 Events

环境准备与数据模型

为演示实时推荐与搜索,我们构建一个电商商品库示例。该示例包含数万条商品数据,每条记录包含基本属性、文本描述以及对应的文本嵌入向量。

启用扩展

首先,确保数据库已安装并启用 pgvector 扩展:

sql 复制代码
CREATE EXTENSION IF NOT EXISTS vector;

核心表结构设计

数据模型主要围绕三张表构建:products (商品表), user_profiles (用户画像表), user_events (用户行为表)。

sql 复制代码
-- 商品表
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    category VARCHAR(100),
    price NUMERIC(10, 2),
    description TEXT,
    -- 存储文本嵌入向量,维度为8,实际场景如384, 768, 1536等
    embedding VECTOR(8),
    -- 使用生成列自动维护全文检索向量,避免手动更新
    tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', title || ' ' || COALESCE(description, ''))) STORED
);

-- 用户画像表
CREATE TABLE user_profiles (
    user_id VARCHAR(50) PRIMARY KEY,
    preference_vector VECTOR(8),
    user_segment VARCHAR(20) -- 如:'高端用户', '普通用户'
);

-- 用户行为表
CREATE TABLE user_events (
    id SERIAL PRIMARY KEY,
    user_id VARCHAR(50) REFERENCES user_profiles(user_id),
    product_id INTEGER REFERENCES products(id),
    event_type VARCHAR(20), -- 'view', 'click', 'purchase'
    event_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

辅助函数与数据填充

为了演示,我们创建辅助函数生成随机向量,并插入示例数据。

sql 复制代码
-- 创建生成随机向量的函数
CREATE OR REPLACE FUNCTION random_vector(dim INTEGER) RETURNS VECTOR AS $$
BEGIN
    RETURN (SELECT array_agg(random() * 100)::VECTOR FROM generate_series(1, dim));
END;
$$ LANGUAGE plpgsql;

-- 插入商品示例数据 (5万条)
INSERT INTO products (title, category, price, description, embedding)
SELECT
    '商品-' || generate_series,
    (ARRAY['手机', '电脑', '家居', '相机', '耳机'])[floor(random() * 5 + 1)],
    round((random() * 5000 + 100)::numeric, 2),
    '这是描述信息-' || generate_series,
    random_vector(8)
FROM generate_series(1, 50000);

-- 模拟特定品牌商品
INSERT INTO products (title, category, price, description, embedding)
VALUES
    ('Apple iPhone 15 Pro', '手机', 799.00, '高端智能手机', random_vector(8)),
    ('Samsung Galaxy S24 Ultra', '手机', 999.00, '安卓旗舰手机', random_vector(8)),
    ('Xiaomi 14 Pro', '手机', 599.00, '高性价比旗舰手机', random_vector(8));

-- 插入用户画像
INSERT INTO user_profiles (user_id, preference_vector, user_segment)
VALUES
    ('user_tech', random_vector(8), '高端用户'),
    ('user_budget', random_vector(8), '普通用户');

-- 插入用户行为
INSERT INTO user_events (user_id, product_id, event_type)
VALUES
    ('user_tech', 1, 'view'),
    ('user_tech', 2, 'click'),
    ('user_budget', 3, 'view');

索引创建

为保障查询性能,需为全文检索和向量检索分别创建索引。

sql 复制代码
-- 为全文检索创建 GIN 索引
CREATE INDEX idx_products_tsv ON products USING GIN (tsv);

-- 为向量检索创建索引 (此处创建 IVFFlat 和 HNSW 两种以供对比)
-- IVFFlat 索引: 构建速度快,适合大数据集
CREATE INDEX idx_products_embedding_ivfflat ON products USING IVFFLAT (embedding vector_l2_ops) WITH (lists = 100);

-- HNSW 索引: 查询速度极快,但构建和内存消耗较大
-- 可根据实际情况选择其一或同时创建
CREATE INDEX idx_products_embedding_hnsw ON products USING HNSW (embedding vector_l2_ops);

核心查询场景详解

以下通过四个典型查询场景,展示 PostgreSQL + pgvector 在实时推荐与搜索中的强大能力。

场景一: 业务过滤与向量Top-K检索

需求 : 用户搜索"价格在500-1500元之间的高端手机",返回最匹配的10款商品。

逻辑: 先通过业务字段(分类、价格)进行过滤,再与目标向量计算相似度,按距离升序排列。

sql 复制代码
-- 使用 L2 距离操作符 <->,按距离升序排列
SELECT
    id,
    title,
    category,
    price,
    embedding <-> '[1, 2, 3, 4, 5, 6, 7, 8]'::VECTOR AS distance
FROM
    products
WHERE
    category = '手机'
    AND price BETWEEN 500 AND 1500
ORDER BY
    embedding <-> '[1, 2, 3, 4, 5, 6, 7, 8]'::VECTOR
LIMIT 10;

性能分析 :

默认情况下,优化器可能因代价估算选择全表扫描。通过 EXPLAIN 可查看执行计划。若需强制使用向量索引,可调整查询或通过 enable_seqscan 等参数进行调优(生产环境不推荐随意更改全局设置)。此场景为经典的 Top-K 查询,在毫秒级即可完成。

场景二: 全文检索与语义检索的混合排序

需求 : 搜索标题或描述中包含"iPhone"或"Camera"的商品,并结合向量语义相似度进行综合排序。

逻辑 : 使用 tsvector 进行关键词匹配,并使用 ts_rank 计算文本相关度。同时,计算向量相似度(距离越小,相似度越高),最后加权整合为最终得分 hybrid_score

sql 复制代码
WITH search_query AS (
    SELECT
        to_tsquery('simple', 'iphone | camera') AS query_vector,
        '[1, 2, 3, 4, 5, 6, 7, 8]'::VECTOR AS target_embedding
)
SELECT
    p.id,
    p.title,
    p.category,
    p.price,
    -- 计算最终混合得分:文本相关度 + 向量相似度
    (COALESCE(ts_rank(p.tsv, sq.query_vector), 0) * 2 +
     (1 - (p.embedding <-> sq.target_embedding)) * 3) AS hybrid_score
FROM
    products p,
    search_query sq
WHERE
    p.tsv @@ sq.query_vector -- 全文检索匹配条件
    AND p.embedding <-> sq.target_embedding < 2 -- 向量距离过滤条件
ORDER BY
    hybrid_score DESC
LIMIT 10;

场景三: 引入热度和时效性的复合排序

需求 : 在相关性的基础上,考虑商品的流行度(如点击量)和时效性(如发布时间),推荐"爆款"和"新品"。

逻辑: 构建一个综合评分函数,包含语义相似度、热度(对数函数压缩)和时效性(指数衰减)三个因子。

sql 复制代码
-- 假设 products 表中增加了 popularity (整数) 和 created_at (时间) 字段
-- ALTER TABLE products ADD COLUMN popularity INTEGER DEFAULT 0;
-- ALTER TABLE products ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;

WITH search_context AS (
    SELECT
        '[1, 2, 3, 4, 5, 6, 7, 8]'::VECTOR AS target_embedding,
        NOW() AS query_time
)
SELECT
    p.id,
    p.title,
    p.price,
    -- 复合评分逻辑:语义相似度(权重0.5) + 热度(权重0.25) + 时效性(权重0.25)
    ((1 - (p.embedding <-> sc.target_embedding)) * 0.5 +
     LOG(GREATEST(p.popularity, 1) + 1) / LOG(10) * 0.25 +
     EXP(-EXTRACT(EPOCH FROM (sc.query_time - p.created_at)) / 86400) * 0.25) AS final_score
FROM
    products p,
    search_context sc
ORDER BY
    final_score DESC
LIMIT 20;

场景四: 基于用户画像的个性化推荐

需求 : 根据用户画像向量和行为数据,为用户推荐其可能感兴趣的商品。

逻辑: 首先,基于用户行为(如浏览、点击)召回候选商品类别;然后,根据用户画像向量与商品向量相似度进行精排。

sql 复制代码
WITH user_context AS (
    SELECT
        up.user_id,
        up.preference_vector,
        -- 聚合用户行为,找出最常浏览的类别
        (SELECT p.category
         FROM user_events ue
         JOIN products p ON ue.product_id = p.id
         WHERE ue.user_id = 'user_tech'
         GROUP BY p.category
         ORDER BY COUNT(*) DESC
         LIMIT 1) AS preferred_category
    FROM user_profiles up
    WHERE up.user_id = 'user_tech'
)
SELECT
    p.id,
    p.title,
    p.category,
    p.price,
    (p.embedding <-> uc.preference_vector) AS similarity_distance
FROM
    products p,
    user_context uc
WHERE
    p.category = uc.preferred_category -- 第一阶段: 行为召回
ORDER BY
    p.embedding <-> uc.preference_vector -- 第二阶段: 向量精排
LIMIT 10;

API 速览

本部分汇总了上述示例中涉及的核心 PostgreSQL API。

1. pgvector 向量操作

  • 类型 : VECTOR(n)
    • 用于定义向量列。n 为维度。
  • 距离操作符 :
    • <-> : L2 距离(欧几里得距离),值越小越相似。
    • <=> : 余弦距离,值越小越相似。
    • <#> : 负内积,值越小越相似。
  • 索引加速 :
    • IVFFLAT : 基于倒排索引的近似最近邻搜索,构建速度快。
    • HNSW : 基于分层可导航小世界图的索引,查询速度极快,但构建成本高。

代码示例:

sql 复制代码
-- 创建向量列
ALTER TABLE products ADD COLUMN embedding VECTOR(384);

-- 使用 L2 距离查询
SELECT * FROM products ORDER BY embedding <-> '[0.1, 0.2, ...]'::VECTOR LIMIT 10;

-- 创建 IVFFlat 索引
CREATE INDEX ON products USING IVFFLAT (embedding vector_l2_ops) WITH (lists = 100);

2. 全文检索

  • 类型 : TSVECTOR, TSQUERY
    • to_tsvector() : 将文本转换为 TSVECTOR 类型。
    • to_tsquery() : 将查询词转换为 TSQUERY 类型。
  • 操作符 :
    • @@ : 匹配操作符,用于判断 TSVECTOR 是否满足 TSQUERY
  • 函数 :
    • ts_rank() : 计算文本查询的相关度分数。

代码示例:

sql 复制代码
-- 使用生成列自动创建 TSVECTOR
ALTER TABLE products ADD COLUMN tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || description)) STORED;

-- 创建 GIN 索引加速
CREATE INDEX ON products USING GIN (tsv);

-- 执行全文检索查询
SELECT title, ts_rank(tsv, to_tsquery('english', 'smartphone & camera')) AS rank
FROM products
WHERE tsv @@ to_tsquery('english', 'smartphone & camera')
ORDER BY rank DESC;

3. 通用函数

  • LOG() : 计算自然对数,常用于压缩大数值范围。
  • EXP() : 指数函数,常用于实现时间衰减。
  • EXTRACT(EPOCH FROM ...) : 从时间戳中提取秒数,便于进行时间差计算。

Demo 完整示例

本示例提供一个基于 Node.jspg 客户端的完整演示程序,模拟上述实时推荐与搜索的核心流程。

运行说明

  1. 环境准备 :
    • 安装 Node.js (v16+) 和 npm。
    • 准备一个运行中的 PostgreSQL 数据库实例,并已安装 pgvector 扩展。
  2. 配置数据库 :
    • 执行前文"环境准备"章节中的 SQL 脚本,创建表结构、函数并插入演示数据。
  3. 安装依赖 :
    • 创建项目目录,并执行 npm init -y
    • 安装必要依赖: npm install pg
  4. 运行脚本 :
    • 将以下代码保存为 demo.js,根据实际情况修改数据库连接配置,然后执行 node demo.js

代码说明

以下 demo.js 脚本依次执行场景一、场景二和场景四的查询,并输出结果。

ts 复制代码
// demo.js
const { Client } = require('pg');

// 数据库连接配置
const config = {
    user: 'your_user',
    host: '127.0.0.1',
    database: 'your_db',
    password: 'your_password',
    port: 5432,
};

const client = new Client(config);

// 辅助函数: 生成随机向量 (模拟)
function generateRandomVector(dim) {
    const arr = [];
    for (let i = 0; i < dim; i++) {
        arr.push(Math.random() * 100);
    }
    return `[${arr.join(',')}]`;
}

async function runDemo() {
    try {
        await client.connect();
        console.log('数据库连接成功');

        // 1. 场景一: 业务过滤 + 向量 Top-K
        console.log('\n--- 场景一: 业务过滤 + 向量 Top-K ---');
        const targetVector = generateRandomVector(8);
        const query1 = `
            SELECT id, title, price, embedding <-> $1::VECTOR AS distance
            FROM products
            WHERE category = '手机' AND price BETWEEN 500 AND 1500
            ORDER BY embedding <-> $1::VECTOR
            LIMIT 10;
        `;
        const res1 = await client.query(query1, [targetVector]);
        console.log('查询结果:', res1.rows.map(r => ({ id: r.id, title: r.title, price: r.price, distance: r.distance })));

        // 2. 场景二: 全文检索 + 语义检索混合排序
        console.log('\n--- 场景二: 全文检索 + 语义检索混合排序 ---');
        const query2 = `
            WITH search_query AS (
                SELECT to_tsquery('simple', 'iphone | camera') AS query_vector,
                       $1::VECTOR AS target_embedding
            )
            SELECT id, title, price,
                   (ts_rank(p.tsv, sq.query_vector) * 2 + (1 - (p.embedding <-> sq.target_embedding)) * 3) AS hybrid_score
            FROM products p, search_query sq
            WHERE p.tsv @@ sq.query_vector
            ORDER BY hybrid_score DESC
            LIMIT 10;
        `;
        const res2 = await client.query(query2, [targetVector]);
        console.log('混合查询结果:', res2.rows.map(r => ({ id: r.id, title: r.title, price: r.price, score: r.hybrid_score })));

        // 3. 场景四: 个性化推荐 (基于用户画像)
        console.log('\n--- 场景四: 个性化推荐 ---');
        const userId = 'user_tech';
        const query4 = `
            WITH user_context AS (
                SELECT up.preference_vector, p.category
                FROM user_profiles up
                JOIN user_events ue ON up.user_id = ue.user_id
                JOIN products p ON ue.product_id = p.id
                WHERE up.user_id = $1
                GROUP BY up.preference_vector, p.category
                ORDER BY COUNT(*) DESC
                LIMIT 1
            )
            SELECT p.id, p.title, p.category, p.price,
                   (p.embedding <-> uc.preference_vector) AS similarity_distance
            FROM products p, user_context uc
            WHERE p.category = uc.category
            ORDER BY p.embedding <-> uc.preference_vector
            LIMIT 10;
        `;
        const res4 = await client.query(query4, [userId]);
        console.log('个性化推荐结果:', res4.rows.map(r => ({ id: r.id, title: r.title, category: r.category, price: r.price })));

    } catch (err) {
        console.error('执行出错:', err);
    } finally {
        await client.end();
    }
}

runDemo();

技术点总结

  • 单库多模 : 演示了如何在同一个 PostgreSQL 数据库中同时处理结构化数据、全文检索和向量检索。
  • 查询简化 : 通过 SQL 将复杂的多阶段推荐逻辑(召回、过滤、排序)整合为单一数据库操作,减少了应用层代码量和数据往返。
  • 索引加速 : 通过创建 GINIVFFLAT/HNSW 索引,保障了查询在万级数据量下的毫秒级响应。
  • 灵活排序 : 展示了如何使用 SQL 函数自由组合向量距离、文本相关度、热度和时效性因子,实现高度定制化的排序策略。

多语言示例

Go 语言示例

本示例使用 Go 语言和 pgx 驱动(pgx/v5),实现与 Node.js 版本相同的实时推荐与搜索演示。

运行说明
  1. 环境准备
    • 安装 Go 1.21+。
    • 准备 PostgreSQL 数据库实例,已安装 pgvector 扩展,并完成前文"环境准备"章节中的表结构与数据初始化。
  2. 项目初始化
    • 创建项目目录,执行 go mod init demo
    • 安装依赖:go get github.com/jackc/pgx/v5
  3. 运行脚本
    • 将以下代码保存为 main.go,修改数据库连接字符串,执行 go run main.go
代码说明
go 复制代码
// main.go
package main

import (
    "context"
    "fmt"
    "log"
    "math/rand"
    "strconv"
    "strings"
    "time"

    "github.com/jackc/pgx/v5"
)

func main() {
    // 数据库连接配置
    connStr := "postgresql://your_user:your_password@127.0.0.1:5432/your_db"
    ctx := context.Background()

    conn, err := pgx.Connect(ctx, connStr)
    if err != nil {
        log.Fatal("数据库连接失败:", err)
    }
    defer conn.Close(ctx)
    fmt.Println("数据库连接成功")

    // 辅助函数:生成随机向量字符串
    generateVector := func(dim int) string {
        vals := make([]string, dim)
        for i := 0; i < dim; i++ {
            vals[i] = strconv.FormatFloat(rand.Float64()*100, 'f', 4, 64)
        }
        return "[" + strings.Join(vals, ",") + "]"
    }

    targetVec := generateVector(8)

    // 场景一:业务过滤 + 向量 Top-K
    fmt.Println("\n--- 场景一: 业务过滤 + 向量 Top-K ---")
    query1 := `
        SELECT id, title, price, embedding <-> $1::VECTOR AS distance
        FROM products
        WHERE category = '手机' AND price BETWEEN 500 AND 1500
        ORDER BY embedding <-> $1::VECTOR
        LIMIT 10;
    `
    rows, err := conn.Query(ctx, query1, targetVec)
    if err != nil {
        log.Fatal("查询失败:", err)
    }
    defer rows.Close()
    fmt.Println("查询结果:")
    for rows.Next() {
        var id int
        var title string
        var price float64
        var distance float64
        rows.Scan(&id, &title, &price, &distance)
        fmt.Printf("ID: %d, Title: %s, Price: %.2f, Distance: %.4f\n", id, title, price, distance)
    }

    // 场景二:全文检索 + 语义检索混合排序
    fmt.Println("\n--- 场景二: 全文检索 + 语义检索混合排序 ---")
    query2 := `
        WITH search_query AS (
            SELECT to_tsquery('simple', 'iphone | camera') AS query_vector,
                   $1::VECTOR AS target_embedding
        )
        SELECT id, title, price,
               (ts_rank(p.tsv, sq.query_vector) * 2 + (1 - (p.embedding <-> sq.target_embedding)) * 3) AS hybrid_score
        FROM products p, search_query sq
        WHERE p.tsv @@ sq.query_vector
        ORDER BY hybrid_score DESC
        LIMIT 10;
    `
    rows2, err := conn.Query(ctx, query2, targetVec)
    if err != nil {
        log.Fatal("查询失败:", err)
    }
    defer rows2.Close()
    fmt.Println("混合查询结果:")
    for rows2.Next() {
        var id int
        var title string
        var price float64
        var score float64
        rows2.Scan(&id, &title, &price, &score)
        fmt.Printf("ID: %d, Title: %s, Price: %.2f, Score: %.4f\n", id, title, price, score)
    }

    // 场景四:个性化推荐
    fmt.Println("\n--- 场景四: 个性化推荐 ---")
    userId := "user_tech"
    query4 := `
        WITH user_context AS (
            SELECT up.preference_vector, p.category
            FROM user_profiles up
            JOIN user_events ue ON up.user_id = ue.user_id
            JOIN products p ON ue.product_id = p.id
            WHERE up.user_id = $1
            GROUP BY up.preference_vector, p.category
            ORDER BY COUNT(*) DESC
            LIMIT 1
        )
        SELECT p.id, p.title, p.category, p.price,
               (p.embedding <-> uc.preference_vector) AS similarity_distance
        FROM products p, user_context uc
        WHERE p.category = uc.category
        ORDER BY p.embedding <-> uc.preference_vector
        LIMIT 10;
    `
    rows4, err := conn.Query(ctx, query4, userId)
    if err != nil {
        log.Fatal("查询失败:", err)
    }
    defer rows4.Close()
    fmt.Println("个性化推荐结果:")
    for rows4.Next() {
        var id int
        var title string
        var category string
        var price float64
        var distance float64
        rows4.Scan(&id, &title, &category, &price, &distance)
        fmt.Printf("ID: %d, Title: %s, Category: %s, Price: %.2f, Distance: %.4f\n", id, title, category, price, distance)
    }
}
技术点总结
  • 驱动选择 :使用 pgx/v5,这是 Go 生态中性能优异的 PostgreSQL 驱动,支持 pgvector 的向量类型传递(通过 $1::VECTOR 显式转换)。
  • 上下文管理 :使用 context.Context 控制查询超时和取消。
  • 类型映射Scan 方法直接将查询结果映射到 Go 基本类型,简单高效。
  • 连接管理 :使用 defer 确保连接和 Rows 正确关闭,防止资源泄露。

Python 语言示例

本示例使用 Pythonpsycopg2 驱动,实现相同的演示逻辑。

运行说明
  1. 环境准备
    • 安装 Python 3.9+。
    • 准备 PostgreSQL 数据库实例(含 pgvector 和初始化数据)。
  2. 安装依赖
    • pip install psycopg2-binary
  3. 运行脚本
    • 将以下代码保存为 demo.py,修改连接字符串,执行 python demo.py
代码说明
py 复制代码
# demo.py
import psycopg2
import random

def generate_vector(dim):
    return '[' + ','.join([f'{random.random() * 100:.4f}' for _ in range(dim)]) + ']'

def main():
    # 数据库连接
    conn = psycopg2.connect(
        host="127.0.0.1",
        port=5432,
        database="your_db",
        user="your_user",
        password="your_password"
    )
    cur = conn.cursor()
    print("数据库连接成功")

    target_vec = generate_vector(8)

    # 场景一
    print("\n--- 场景一: 业务过滤 + 向量 Top-K ---")
    cur.execute("""
        SELECT id, title, price, embedding <-> %s::VECTOR AS distance
        FROM products
        WHERE category = '手机' AND price BETWEEN 500 AND 1500
        ORDER BY embedding <-> %s::VECTOR
        LIMIT 10;
    """, (target_vec, target_vec))
    rows = cur.fetchall()
    for row in rows:
        print(f"ID: {row[0]}, Title: {row[1]}, Price: {row[2]:.2f}, Distance: {row[3]:.4f}")

    # 场景二
    print("\n--- 场景二: 全文检索 + 语义检索混合排序 ---")
    cur.execute("""
        WITH search_query AS (
            SELECT to_tsquery('simple', 'iphone | camera') AS query_vector,
                   %s::VECTOR AS target_embedding
        )
        SELECT id, title, price,
               (ts_rank(p.tsv, sq.query_vector) * 2 + (1 - (p.embedding <-> sq.target_embedding)) * 3) AS hybrid_score
        FROM products p, search_query sq
        WHERE p.tsv @@ sq.query_vector
        ORDER BY hybrid_score DESC
        LIMIT 10;
    """, (target_vec,))
    rows = cur.fetchall()
    for row in rows:
        print(f"ID: {row[0]}, Title: {row[1]}, Price: {row[2]:.2f}, Score: {row[3]:.4f}")

    # 场景四
    print("\n--- 场景四: 个性化推荐 ---")
    user_id = "user_tech"
    cur.execute("""
        WITH user_context AS (
            SELECT up.preference_vector, p.category
            FROM user_profiles up
            JOIN user_events ue ON up.user_id = ue.user_id
            JOIN products p ON ue.product_id = p.id
            WHERE up.user_id = %s
            GROUP BY up.preference_vector, p.category
            ORDER BY COUNT(*) DESC
            LIMIT 1
        )
        SELECT p.id, p.title, p.category, p.price,
               (p.embedding <-> uc.preference_vector) AS similarity_distance
        FROM products p, user_context uc
        WHERE p.category = uc.category
        ORDER BY p.embedding <-> uc.preference_vector
        LIMIT 10;
    """, (user_id,))
    rows = cur.fetchall()
    for row in rows:
        print(f"ID: {row[0]}, Title: {row[1]}, Category: {row[2]}, Price: {row[3]:.2f}, Distance: {row[4]:.4f}")

    cur.close()
    conn.close()

if __name__ == "__main__":
    main()
技术点总结
  • 驱动选择psycopg2 是 Python 最成熟的 PostgreSQL 适配器,支持 %s 占位符和显式类型转换(%s::VECTOR)。
  • 参数化查询:使用元组传递参数,自动处理引号和转义,防止 SQL 注入。
  • 简洁性 :Python 语法简洁,fetchall() 快速获取结果集,适合快速原型开发。
  • 游标管理:手动关闭游标和连接,确保资源释放。

Java 语言示例

本示例使用 JavaJDBC 驱动(org.postgresql:postgresql),实现相同功能。

运行说明
  1. 环境准备

    • 安装 JDK 17+。
    • 准备 PostgreSQL 数据库实例(含 pgvector 和初始化数据)。
  2. 项目配置

    • 使用 Maven 或 Gradle 添加依赖(见下方 pom.xml)。
    • 将以下代码保存为 Demo.java,编译运行。
  3. 依赖坐标 (Maven):

    xml 复制代码
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.2</version>
    </dependency>
代码说明
java 复制代码
// Demo.java
import java.sql.*;
import java.util.Random;

public class Demo {

    private static String generateVector(int dim) {
        Random rand = new Random();
        StringBuilder sb = new StringBuilder("[");
        for (int i = 0; i < dim; i++) {
            if (i > 0) sb.append(",");
            sb.append(String.format("%.4f", rand.nextFloat() * 100));
        }
        sb.append("]");
        return sb.toString();
    }

    public static void main(String[] args) {
        String url = "jdbc:postgresql://127.0.0.1:5432/your_db";
        String user = "your_user";
        String password = "your_password";

        try (Connection conn = DriverManager.getConnection(url, user, password)) {
            System.out.println("数据库连接成功");

            String targetVec = generateVector(8);

            // 场景一
            System.out.println("\n--- 场景一: 业务过滤 + 向量 Top-K ---");
            String sql1 = """
                SELECT id, title, price, embedding <-> ?::VECTOR AS distance
                FROM products
                WHERE category = '手机' AND price BETWEEN 500 AND 1500
                ORDER BY embedding <-> ?::VECTOR
                LIMIT 10;
            """;
            try (PreparedStatement pstmt = conn.prepareStatement(sql1)) {
                pstmt.setString(1, targetVec);
                pstmt.setString(2, targetVec);
                ResultSet rs = pstmt.executeQuery();
                while (rs.next()) {
                    System.out.printf("ID: %d, Title: %s, Price: %.2f, Distance: %.4f%n",
                            rs.getInt("id"), rs.getString("title"),
                            rs.getDouble("price"), rs.getDouble("distance"));
                }
            }

            // 场景二
            System.out.println("\n--- 场景二: 全文检索 + 语义检索混合排序 ---");
            String sql2 = """
                WITH search_query AS (
                    SELECT to_tsquery('simple', 'iphone | camera') AS query_vector,
                           ?::VECTOR AS target_embedding
                )
                SELECT id, title, price,
                       (ts_rank(p.tsv, sq.query_vector) * 2 + (1 - (p.embedding <-> sq.target_embedding)) * 3) AS hybrid_score
                FROM products p, search_query sq
                WHERE p.tsv @@ sq.query_vector
                ORDER BY hybrid_score DESC
                LIMIT 10;
            """;
            try (PreparedStatement pstmt = conn.prepareStatement(sql2)) {
                pstmt.setString(1, targetVec);
                ResultSet rs = pstmt.executeQuery();
                while (rs.next()) {
                    System.out.printf("ID: %d, Title: %s, Price: %.2f, Score: %.4f%n",
                            rs.getInt("id"), rs.getString("title"),
                            rs.getDouble("price"), rs.getDouble("hybrid_score"));
                }
            }

            // 场景四
            System.out.println("\n--- 场景四: 个性化推荐 ---");
            String sql4 = """
                WITH user_context AS (
                    SELECT up.preference_vector, p.category
                    FROM user_profiles up
                    JOIN user_events ue ON up.user_id = ue.user_id
                    JOIN products p ON ue.product_id = p.id
                    WHERE up.user_id = ?
                    GROUP BY up.preference_vector, p.category
                    ORDER BY COUNT(*) DESC
                    LIMIT 1
                )
                SELECT p.id, p.title, p.category, p.price,
                       (p.embedding <-> uc.preference_vector) AS similarity_distance
                FROM products p, user_context uc
                WHERE p.category = uc.category
                ORDER BY p.embedding <-> uc.preference_vector
                LIMIT 10;
            """;
            try (PreparedStatement pstmt = conn.prepareStatement(sql4)) {
                pstmt.setString(1, "user_tech");
                ResultSet rs = pstmt.executeQuery();
                while (rs.next()) {
                    System.out.printf("ID: %d, Title: %s, Category: %s, Price: %.2f, Distance: %.4f%n",
                            rs.getInt("id"), rs.getString("title"),
                            rs.getString("category"), rs.getDouble("price"),
                            rs.getDouble("similarity_distance"));
                }
            }

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}
技术点总结
  • JDBC 标准 :使用 java.sql 包,兼容所有 PostgreSQL JDBC 驱动。
  • 文本块语法 :Java 15+ 支持 """ 多行字符串,提高 SQL 可读性。
  • PreparedStatement :使用 ? 占位符,通过 setString 传递向量参数,类型由 ::VECTOR 在 SQL 中转换。
  • 资源管理:使用 try-with-resources 自动关闭连接和语句,避免内存泄露。
  • 格式化输出 :使用 System.out.printf 美化输出。

多语言对比总结

特性 Node.js (pg) Go (pgx) Python (psycopg2) Java (JDBC)
驱动/库 pg github.com/jackc/pgx/v5 psycopg2-binary org.postgresql:postgresql
参数占位符 $1, $2 $1, $2 %s ?
向量参数传递 直接作为字符串,SQL 中 ::VECTOR 直接作为字符串,SQL 中 ::VECTOR 直接作为字符串,SQL 中 ::VECTOR 直接作为字符串,SQL 中 ::VECTOR
错误处理 try-catch / async-await error 返回值 try-except try-catch / SQLException
连接管理 客户端手动 close defer conn.Close() 手动 close try-with-resources
依赖大小 轻量 (纯 JS) 轻量 (静态编译) 中等 (C 扩展) 中等 (JDBC 驱动)
异步支持 原生 async/await 支持 pgx 原生异步 支持异步 (asyncpg) 支持异步 (R2DBC)
类型安全性 弱类型 (运行时) 强类型 (编译时) 弱类型 (运行时) 强类型 (编译时)
适用场景 快速原型、全栈 JS 高并发、微服务 数据分析、脚本自动化 企业级应用、Spring 生态

各语言在实现相同业务逻辑时的核心差异在于驱动 API 风格、错误处理机制和类型系统,但对 PostgreSQL + pgvector 的 SQL 语义支持完全一致,均可通过标准 SQL 完成实时推荐与搜索的混合查询任务。

项目难点与解决方案

核心难点

  • 混合查询性能调优 : 当 WHERE 子句中同时包含业务过滤条件和向量距离计算时,优化器可能无法选择最优索引,导致查询性能下降。
  • 索引选择与参数调优 : IVFFLATHNSW 索引有不同的适用场景和参数(如 lists, m, ef_construction),不当配置会影响召回率或查询速度。
  • 向量维度与存储成本: 高维向量(如1536维)会显著增加存储开销和计算时间,需在精度和资源间取得平衡。

解决方案

  • 查询规划调优 : 使用 CTE 或子查询强制将业务过滤和向量检索分离执行,或通过设置 enable_seqscan 等参数(谨慎使用)引导优化器选择索引扫描。
  • 索引实验与监控 : 在实际数据分布下,对比 IVFFLATHNSW 在不同参数下的查询延迟和召回率,选择最佳配置。例如,调整 IVFFLATlists 参数约为 rows / 1000
  • 向量降维与量化 : 在应用层考虑使用更高效的嵌入模型或降维技术(如 PCA)。pgvector 也支持 halfvec 类型以降低存储开销。

广度

涵盖了构建实时推荐系统所需的多方面技术,包括数据库设计、索引优化、全文检索、向量检索、排序算法设计以及与后端应用的集成。

深度

深入探讨了 PostgreSQL 内部机制,如生成列、多种索引类型的实现原理与适用场景、SQL 层复杂评分函数的构建,以及查询性能的调优策略。

复杂度

面对混合检索、个性化推荐和实时性要求,系统复杂度较高。通过将所有逻辑集中在数据库层,有效降低了应用层和系统整体的复杂度,但同时也对数据库管理和 SQL 编写能力提出了更高要求。

官方文档

参考链接

总结

本文详细阐述了如何利用 PostgreSQL 及其扩展 pgvector 构建一个功能完备的实时推荐与搜索系统。我们深入探讨了从数据建模、索引创建到复杂混合查询(融合业务过滤、全文检索、向量相似度、热度及时效性排序)的全过程。通过具体的 SQL 示例和 Node.js Demo 演示,证明了 PostgreSQL 作为一个多模态数据库,能够有效整合传统检索与 AI 向量检索能力,简化技术栈,并在中小规模场景下提供卓越的实时性能。其核心价值在于将复杂的召回、过滤与排序逻辑整合于数据库内核,为开发者提供了一种高效、简洁的解决方案。

相关推荐
liuyicenysabel12 小时前
多服务上线日记三:
运维·服务器·笔记·学习
CHENGQUAN_kenan12 小时前
佛山亚马逊卖家出口退税合规指南|9610免税、一般贸易退税、无票采购、申报误区全覆盖
大数据·经验分享·笔记·百度
一只小小的芙厨13 小时前
基础数论总结
笔记·学习·算法
@Mike@16 小时前
09-数据库学习笔记(数据库索引与过滤器)
数据库·笔记
一米阳光866116 小时前
软考(中级)软件设计师核心笔记(9)算法——背包问题
笔记·算法·职场发展·软考·软件设计师·中级职称
Chris _data16 小时前
WPF 上位机开发学习笔记 - 第四天
笔记·学习·wpf
树的枝17 小时前
【STM32】03.中断和EXTI外设
笔记·stm32·单片机·嵌入式硬件
Wang's Blog18 小时前
PostgreSQL笔记34:索引优化策略全景解析——从B-tree到HOT的核心原理与实践
数据库·笔记·postgresql
金字塔頂の蝸牛18 小时前
商务日语口语实用手册
笔记·学习笔记·日语·外语