一、方案总览

1. 核心目标

解决商超商品搜索的语义匹配不足与人工干预缺失两大痛点,弥补 Elasticsearch(ES)在语义理解上的局限,实现:

  • 语义相似排序(如搜 “西蓝花” 时,按 “有机西蓝花→普通西蓝花→有机花菜→普通花菜” 优先级排序);
  • 人工自定义关联(如 “大料” 强制关联 “花椒、八角、桂皮”,且优先级高于自动匹配);
  • 架构简化(基于 PostgreSQL 原生能力,无需额外部署向量数据库)。

2. 技术栈

组件选型说明
数据库 PostgreSQL 16.9+(支持分区表)+ pgvector 0.8.1+(向量存储与相似度计算)
NLP 向量模型 Sentence-BERT(中文模型:uer/sbert-base-chinese-nli
文本过滤 Elasticsearch 8.x(负责 “粗过滤”,如商品名称模糊匹配,减少向量计算量)
后端服务 Python 3.9+(FastAPI:接口开发;psycopg2:PG 交互;elasticsearch-py:ES 交互)

2.1 postgresql

[root@uctg-yjfb-db-7-105 ~]# psql -Upostgres
psql (16.9)
Type "help" for help.

postgres=# select version();
                                                 version                                                 
---------------------------------------------------------------------------------------------------------
 PostgreSQL 16.9 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 8.5.0 20210514 (Red Hat 8.5.0-26), 64-bit
(1 row)

2.2 pgvector 

postgres=# CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION
postgres=# SELECT extname, extversion 
postgres-# FROM pg_extension 
postgres-# WHERE extname = 'vector';
 extname | extversion 
---------+------------
 vector  | 0.8.1
(1 row)

postgres=# 

二、方案架构设计

1. 整体流程

image 

2. 组件分工

组件核心职责
ES 1. 商品名称模糊匹配(如 “西蓝” 匹配 “西蓝花”);2. 过滤无效商品(如下架商品);3. 输出匹配商品 ID 列表,减少 PG 计算量
PostgreSQL 1. 存储商品基础数据(名称、价格、库存);2. 存储商品语义向量(pgvector 字段);3. 存储人工自定义关联规则;4. 执行向量相似度计算与排序
Python 服务 1. 协调 ES 过滤与 PG 向量查询;2. 生成语义向量;3. 融合 “自动相似度结果” 与 “人工关联结果”;4. 提供 RESTful 搜索接口

三、数据层设计(PostgreSQL+pgvector)

1. 分表策略

按商品分类做列表分区(符合商超业务逻辑,如蔬菜类、调料类单独分区),优势:

  • 减少单表数据量,提升查询性能;
  • 便于按分类管理数据(如蔬菜类促销活动仅操作蔬菜分区表)。

2. 表结构创建 SQL

(1)主表与分区表(商超商品表)

-- 1. 主表:商超商品表(启用分区)
CREATE TABLE IF NOT EXISTS product (
    product_id BIGSERIAL PRIMARY KEY,          -- 商品唯一ID
    product_name VARCHAR(255) NOT NULL,        -- 商品名称(如“有机西蓝花”)
    product_desc TEXT,                         -- 商品描述(如“无农药残留”)
    category_id INT NOT NULL,                  -- 分区键:1=蔬菜类,2=调料类
    price DECIMAL(10,2) NOT NULL,              -- 售价
    stock INT NOT NULL DEFAULT 0,              -- 库存
    product_vec VECTOR(768) NOT NULL           -- 768维语义向量(pgvector核心字段)
) PARTITION BY LIST (category_id);             -- 按分类列表分区

-- 2. 分区表:蔬菜类(category_id=1)
CREATE TABLE IF NOT EXISTS product_vegetable 
PARTITION OF product FOR VALUES IN (1);

-- 3. 分区表:调料类(category_id=2)
CREATE TABLE IF NOT EXISTS product_seasoning 
PARTITION OF product FOR VALUES IN (2);

-- 4. 索引设计(提升查询效率)
CREATE INDEX IF NOT EXISTS idx_product_veg_vec ON product_vegetable USING hnsw (product_vec vector_cosine_ops); -- 蔬菜类向量索引(HNSW算法,适合高并发)
CREATE INDEX IF NOT EXISTS idx_product_sea_vec ON product_seasoning USING hnsw (product_vec vector_cosine_ops); -- 调料类向量索引
CREATE INDEX IF NOT EXISTS idx_product_name ON product (product_name); -- 商品名称模糊查询索引
CREATE INDEX IF NOT EXISTS idx_product_category ON product (category_id); -- 分类过滤索引
 

(2)商品自定义关联表(人工干预用)

CREATE TABLE IF NOT EXISTS product_custom_relation (
    relation_id BIGSERIAL PRIMARY KEY,
    main_product_id BIGINT NOT NULL,           -- 主商品ID(如“大料”的ID)
    related_product_id BIGINT NOT NULL,        -- 关联商品ID(如“花椒”的ID)
    custom_weight FLOAT NOT NULL DEFAULT 1.0,  -- 自定义权重(0-10,越高排序越靠前)
    is_active BOOLEAN NOT NULL DEFAULT TRUE,   -- 是否生效
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    -- 外键约束(确保关联商品存在)
    FOREIGN KEY (main_product_id) REFERENCES product(product_id),
    FOREIGN KEY (related_product_id) REFERENCES product(product_id),
    -- 唯一约束(避免重复关联)
    UNIQUE (main_product_id, related_product_id)
);

-- 关联表索引(加速人工关联查询)
CREATE INDEX IF NOT EXISTS idx_main_product ON product_custom_relation (main_product_id, is_active);
 

四、核心代码实现(Python)

1. 环境依赖

pip install fastapi uvicorn psycopg2-binary sqlalchemy sentence-transformers elasticsearch pandas

2. 工具类封装

(1)向量生成工具(Sentence-BERT 中文模型)

from sentence_transformers import SentenceTransformer

class VectorGenerator:
    """语义向量生成器(单例模式,避免重复加载模型)"""
    _instance = None

    def __new__(cls, model_name: str = "uer/sbert-base-chinese-nli"):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            # 加载中文预训练模型(兼顾语义准确性与速度)
            cls._instance.model = SentenceTransformer(model_name)
        return cls._instance

    def text_to_vector(self, text: str) -> list[float]:
        """将文本(商品名称/搜索词)转化为768维向量"""
        if not text.strip():
            raise ValueError("输入文本不能为空")
        # 生成向量并转为列表(适配pgvector存储格式)
        vector = self.model.encode(text, convert_to_tensor=False)
        return vector.tolist()

# 实例化(全局唯一)
vector_generator = VectorGenerator()

(2)PostgreSQL+pgvector 交互工具

from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from typing import List, Dict

class PGVectorClient:
    def __init__(self, db_url: str):
        """初始化PG客户端(支持向量查询与分区表操作)"""
        self.engine = create_engine(db_url)
        self.Session = sessionmaker(bind=self.engine)

    def get_similar_products(self,
                            query_vector: list[float],
                            category_id: int = None,
                            filter_product_ids: List[int] = None,  # ES过滤后的商品ID列表
                            top_k: int = 20) -> List[Dict]:
        """
        按向量相似度查询商品(核心方法)
        :param query_vector: 搜索词向量
        :param category_id: 商品分类ID(可选,进一步过滤)
        :param filter_product_ids: ES过滤后的商品ID(必选,减少计算量)
        :param top_k: 返回Top N结果
        """
        session = self.Session()
        try:
            # 基础SQL:计算余弦相似度(1 - 向量距离 = 相似度,范围0-1)
            base_sql = """
                SELECT 
                    p.product_id, p.product_name, p.price, p.stock,
                    1 - (p.product_vec <=> :query_vec) AS similarity_score
                FROM product p
                WHERE 1=1
            """
            params = {"query_vec": query_vector, "top_k": top_k}

            # 条件1:ES过滤后的商品ID(必选)
            if filter_product_ids and len(filter_product_ids) > 0:
                base_sql += " AND p.product_id = ANY(:filter_ids)"
                params["filter_ids"] = filter_product_ids

            # 条件2:分类过滤(可选)
            if category_id:
                base_sql += " AND p.category_id = :category_id"
                params["category_id"] = category_id

            # 完整SQL:按相似度降序,限制结果数量
            full_sql = f"{base_sql} ORDER BY similarity_score DESC LIMIT :top_k"
            result = session.execute(text(full_sql), params).mappings().all()
            return [dict(row) for row in result]
        finally:
            session.close()

    def get_custom_related(self, main_product_id: int) -> List[Dict]:
        """获取人工自定义关联的商品(如“大料”关联“花椒、八角”)"""
        session = self.Session()
        try:
            sql = """
                SELECT 
                    p.product_id, p.product_name, p.price, p.stock,
                    r.custom_weight AS similarity_score,  -- 人工权重作为相似度分数
                    '人工关联' AS match_type
                FROM product_custom_relation r
                JOIN product p ON r.related_product_id = p.product_id
                WHERE r.main_product_id = :main_id AND r.is_active = TRUE
                ORDER BY r.custom_weight DESC
            """
            result = session.execute(text(sql), {"main_id": main_product_id}).mappings().all()
            return [dict(row) for row in result]
        finally:
            session.close()

    def add_custom_relation(self, main_id: int, related_id: int, weight: float = 1.0) -> bool:
        """添加人工关联规则(如“大料”→“花椒”)"""
        session = self.Session()
        try:
            # 避免重复关联
            exists = session.execute(
                text("SELECT 1 FROM product_custom_relation WHERE main_product_id=:m AND related_product_id=:r"),
                {"m": main_id, "r": related_id}
            ).scalar()
            if exists:
                return False

            # 插入关联规则
            session.execute(
                text("INSERT INTO product_custom_relation (main_product_id, related_product_id, custom_weight) VALUES (:m, :r, :w)"),
                {"m": main_id, "r": related_id, "w": weight}
            )
            session.commit()
            return True
        except Exception as e:
            session.rollback()
            print(f"添加人工关联失败:{str(e)}")
            return False
        finally:
            session.close()
 

(3)Elasticsearch 交互工具(粗过滤)

from elasticsearch import Elasticsearch
from typing import List

class ESClient:
    def __init__(self, es_hosts: List[str], index_name: str = "supermarket_products"):
        """初始化ES客户端(负责商品名称模糊匹配)"""
        self.es = Elasticsearch(es_hosts)
        self.index_name = index_name

    def create_index(self):
        """创建ES商品索引(含中文分词)"""
        mapping = {
            "mappings": {
                "properties": {
                    "product_id": {"type": "integer"},
                    "product_name": {
                        "type": "text",
                        "analyzer": "ik_max_word",  # IK分词器(中文精确分词)
                        "search_analyzer": "ik_smart"
                    },
                    "category_id": {"type": "integer"}
                }
            }
        }
        if not self.es.indices.exists(index=self.index_name):
            self.es.indices.create(index=self.index_name, body=mapping)
            print(f"ES索引 {self.index_name} 创建成功")

    def batch_insert(self, products: List[Dict]):
        """批量插入商品数据到ES(用于初始化)"""
        actions = []
        for p in products:
            actions.append({"index": {"_index": self.index_name, "_id": p["product_id"]}})
            actions.append({
                "product_id": p["product_id"],
                "product_name": p["product_name"],
                "category_id": p["category_id"]
            })
        if actions:
            self.es.bulk(body=actions)
            print(f"ES批量插入 {len(products)} 条商品数据")

    def filter_products(self, query: str) -> List[int]:
        """按商品名称模糊匹配,返回商品ID列表(粗过滤)"""
        try:
            body = {
                "query": {
                    "match_phrase_prefix": {  # 短语前缀匹配(如“西蓝”→“西蓝花”)
                        "product_name": {"query": query, "max_expansions": 100}
                    }
                },
                "_source": ["product_id"],  # 仅返回商品ID,减少数据传输
                "size": 1000  # 最大返回1000条(足够商超场景)
            }
            response = self.es.search(index=self.index_name, body=body)
            return [hit["_source"]["product_id"] for hit in response["hits"]["hits"]]
        except Exception as e:
            print(f"ES过滤失败:{str(e)}")
            return []
 

3. FastAPI 搜索服务(核心接口)

from fastapi import FastAPI, Query, HTTPException
from pydantic import BaseModel
from typing import List, Dict

# 初始化服务
app = FastAPI(title="商超商品搜索服务(pgvector+ES)")

# 配置(替换为生产环境地址)
PG_CONFIG = "postgresql://user:password@localhost:5432/supermarket_db"
ES_CONFIG = ["http://localhost:9200"]

# 初始化客户端
pg_client = PGVectorClient(PG_CONFIG)
es_client = ESClient(ES_CONFIG)
es_client.create_index()  # 初始化ES索引(首次运行时执行)

# 数据模型:搜索请求
class SearchRequest(BaseModel):
    query: str               # 搜索词(如“西蓝花”“大料”)
    category_id: int = None  # 可选:分类ID(1=蔬菜,2=调料)
    top_k: int = 10          # 返回Top N结果
    use_custom: bool = True  # 是否启用人工关联

# 1. 商品搜索接口(核心)
@app.post("/api/product/search", response_model=List[Dict])
def search_products(req: SearchRequest):
    # 步骤1:文本预处理
    clean_query = req.query.strip().replace(" ", "").replace("\t", "")
    if not clean_query:
        raise HTTPException(status_code=400, detail="搜索词不能为空")

    # 步骤2:ES粗过滤(获取匹配商品ID)
    es_product_ids = es_client.filter_products(clean_query)
    if not es_product_ids:
        return []  # 无匹配商品,直接返回

    # 步骤3:生成搜索词向量
    try:
        query_vector = vector_generator.text_to_vector(clean_query)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"向量生成失败:{str(e)}")

    # 步骤4:PG向量精排序(自动相似度匹配)
    auto_similar = pg_client.get_similar_products(
        query_vector=query_vector,
        category_id=req.category_id,
        filter_product_ids=es_product_ids,
        top_k=req.top_k * 2  # 多查一倍,留足人工关联融合空间
    )
    # 标记自动匹配类型
    for p in auto_similar:
        p["match_type"] = "自动相似度"

    # 步骤5:融合人工关联结果(若启用)
    final_result = []
    if req.use_custom:
        # 5.1 查找“搜索词对应的主商品ID”(如“大料”的ID)
        # 简化逻辑:通过商品名称模糊查询主商品(实际可优化为ES精确查询)
        main_product_id = pg_client.Session().execute(
            text("SELECT product_id FROM product WHERE product_name LIKE :q LIMIT 1"),
            {"q": f"%{clean_query}%"}
        ).scalar()

        if main_product_id:
            # 5.2 获取人工关联商品
            custom_related = pg_client.get_custom_related(main_product_id)
            # 5.3 融合规则:人工关联在前(权重高),自动匹配在后(去重)
            custom_ids = {p["product_id"] for p in custom_related}
            final_result.extend(custom_related)
            # 补充自动匹配商品(排除已在人工关联中的)
            final_result.extend([p for p in auto_similar if p["product_id"] not in custom_ids])
        else:
            # 无人工关联,直接用自动匹配结果
            final_result = auto_similar
    else:
        final_result = auto_similar

    # 步骤6:返回Top N结果
    return final_result[:req.top_k]

# 2. 添加人工关联接口(管理员用)
@app.post("/api/admin/custom-relation", response_model=Dict[str, bool])
def add_custom_relation(
    main_product_id: int = Query(..., description="主商品ID(如大料)"),
    related_product_id: int = Query(..., description="关联商品ID(如花椒)"),
    custom_weight: float = Query(1.0, ge=0, le=10, description="权重(0-10)")
):
    success = pg_client.add_custom_relation(main_product_id, related_product_id, custom_weight)
    return {"success": success}

# 启动服务(命令:uvicorn main:app --reload)
if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
 

五、测试案例(全流程验证)

测试准备

  1. 环境初始化:
    • 启动 PostgreSQL(需启用 pgvector 插件)、Elasticsearch(需安装 IK 分词器);
    • 执行 “数据层设计” 中的 SQL,创建表结构与索引;
    • 运行 Python 服务,初始化 ES 索引。
  2. 测试数据说明:
    • 商品分类:1 = 蔬菜类(西蓝花、花菜等),2 = 调料类(大料、花椒等);
    • 向量生成:提供两种方案(真实语义向量 / 模拟向量),覆盖无 Python 环境场景。

案例 1:测试数据生成与插入

方案 A:Python 生成真实语义向量(推荐)

# 测试数据生成脚本(生成INSERT SQL或直接插入)
from main import pg_client, es_client, vector_generator  # 导入之前的客户端

# 1. 测试商品数据
test_products = [
    # 蔬菜类(category_id=1)
    {"product_name": "有机西蓝花", "product_desc": "有机种植,无农药,富含维生素C", "category_id": 1, "price": 5.99, "stock": 100},
    {"product_name": "普通西蓝花", "product_desc": "常规种植,脆嫩,适合清炒", "category_id": 1, "price": 3.99, "stock": 200},
    {"product_name": "有机花菜", "product_desc": "有机认证,松软,适合干锅", "category_id": 1, "price": 4.59, "stock": 150},
    {"product_name": "普通花菜", "product_desc": "新鲜上市,实惠,适合炖煮", "category_id": 1, "price": 2.99, "stock": 250},
    {"product_name": "胡萝卜", "product_desc": "富含β-胡萝卜素,生吃凉拌均可", "category_id": 1, "price": 1.99, "stock": 300},
    # 调料类(category_id=2)
    {"product_name": "大料", "product_desc": "卤味调料,香味浓,炖肉必备", "category_id": 2, "price": 8.99, "stock": 80},
    {"product_name": "花椒", "product_desc": "四川大红袍,麻味醇厚,火锅调料", "category_id": 2, "price": 12.99, "stock": 60},
    {"product_name": "八角", "product_desc": "广西八角,香气浓,卤菜必备", "category_id": 2, "price": 9.99, "stock": 70},
    {"product_name": "桂皮", "product_desc": "肉桂皮,甜味浓,炖肉增香", "category_id": 2, "price": 7.99, "stock": 90},
    {"product_name": "香叶", "product_desc": "中西餐通用,增香提味", "category_id": 2, "price": 5.99, "stock": 50}
]

# 2. 生成向量并插入PG(分区表自动路由)
session = pg_client.Session()
try:
    for p in test_products:
        # 生成商品名称+描述的联合向量(更精准)
        text = f"{p['product_name']} {p['product_desc']}"
        p["product_vec"] = vector_generator.text_to_vector(text)
        
        # 插入PG(自动路由到对应分区表)
        session.execute(
            text("""
                INSERT INTO product (product_name, product_desc, category_id, price, stock, product_vec)
                VALUES (:name, :desc, :cid, :price, :stock, :vec)
                RETURNING product_id
            """),
            {
                "name": p["product_name"],
                "desc": p["product_desc"],
                "cid": p["category_id"],
                "price": p["price"],
                "stock": p["stock"],
                "vec": p["product_vec"]
            }
        )
        # 获取插入后的product_id,用于ES插入
        p["product_id"] = session.execute(text("SELECT LASTVAL()")).scalar()
    
    session.commit()
    print("PG商品数据插入成功")

    # 3. 插入ES(用于后续粗过滤)
    es_client.batch_insert(test_products)
    print("ES商品数据插入成功")

finally:
    session.close()

# 4. 添加人工关联规则(大料→花椒、八角、桂皮)
main_id = session.execute(text("SELECT product_id FROM product WHERE product_name='大料'")).scalar()
related_ids = {
    "花椒": 1.5,
    "八角": 1.2,
    "桂皮": 1.0
}
for name, weight in related_ids.items():
    related_id = session.execute(text("SELECT product_id FROM product WHERE product_name=:name"), {"name": name}).scalar()
    pg_client.add_custom_relation(main_id, related_id, weight)
print("人工关联规则添加成功")
 

方案 B:SQL 生成模拟向量(无 Python 环境)

方案B说明:
1
. 随机向量的本质:无语义关联 random()::float生成的是无意义的随机浮点数,这些向量不包含任何商品的 “语义信息”: 比如 “有机西蓝花” 的向量是[0.12, 0.34, ..., 0.56](随机值),“普通西蓝花” 的向量是[0.78, 0.90, ..., 0.23](另一个随机值)—— 两者的向量距离可能很远,甚至比 “有机西蓝花” 和 “胡萝卜” 的距离还远; 真实语义向量(如 Sentence-BERT 生成)会让 “有机西蓝花” 和 “普通西蓝花” 的向量距离极近(语义相似),和 “胡萝卜” 的距离极远(语义无关),这才是 pgvector 实现 “相似排序” 的核心。 2. “无区别” 的具体表现 用随机向量插入后,执行 “搜索西蓝花” 的查询,结果排序是随机的: 可能 “胡萝卜” 排在 “普通西蓝花” 前面,也可能 “普通花菜” 排在 “有机西蓝花” 前面; 因为向量是随机的,无法体现 “西蓝花类商品” 的语义关联性,这和 “无区别” 的效果一致(无法实现预期的相似排序)。 3. 总结 “无区别” 不是指 “向量值相同”,而是指 “向量无法区分商品的语义差异”—— 随机向量只能用于测试 SQL 语法正确性,无法实现 pgvector 的核心价值(语义相似匹配)。
-- 1. 插入蔬菜类测试数据(自动路由到product_vegetable分区表)
INSERT INTO product (product_name, product_desc, category_id, price, stock, product_vec)
VALUES 
('有机西蓝花', '有机种植,无农药,富含维生素C', 1, 5.99, 100, (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768)),
('普通西蓝花', '常规种植,脆嫩,适合清炒', 1, 3.99, 200, (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768)),
('有机花菜', '有机认证,松软,适合干锅', 1, 4.59, 150, (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768)),
('普通花菜', '新鲜上市,实惠,适合炖煮', 1, 2.99, 250, (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768)),
('胡萝卜', '富含β-胡萝卜素,生吃凉拌均可', 1, 1.99, 300, (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768));

-- 2. 插入调料类测试数据(自动路由到product_seasoning分区表)
INSERT INTO product (product_name, product_desc, category_id, price, stock, product_vec)
VALUES 
('大料', '卤味调料,香味浓,炖肉必备', 2, 8.99, 80, (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768)),
('花椒', '四川大红袍,麻味醇厚,火锅调料', 2, 12.99, 60, (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768)),
('八角', '广西八角,香气浓,卤菜必备', 2, 9.99, 70, (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768)),
('桂皮', '肉桂皮,甜味浓,炖肉增香', 2, 7.99, 90, (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768)),
('香叶', '中西餐通用,增香提味', 2, 5.99, 50, (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768));

-- 3. 插入人工关联规则(先获取大料ID)
WITH main AS (SELECT product_id FROM product WHERE product_name='大料')
INSERT INTO product_custom_relation (main_product_id, related_product_id, custom_weight)
VALUES 
((SELECT product_id FROM main), (SELECT product_id FROM product WHERE product_name='花椒'), 1.5),
((SELECT product_id FROM main), (SELECT product_id FROM product WHERE product_name='八角'), 1.2),
((SELECT product_id FROM main), (SELECT product_id FROM product WHERE product_name='桂皮'), 1.0);

-- 4. 插入ES(手动构造JSON,通过Kibana或ES API执行)
POST /supermarket_products/_bulk
{"index":{"_id":1}}
{"product_id":1,"product_name":"有机西蓝花","category_id":1}
{"index":{"_id":2}}
{"product_id":2,"product_name":"普通西蓝花","category_id":1}
{"index":{"_id":3}}
{"product_id":3,"product_name":"有机花菜","category_id":1}
{"index":{"_id":4}}
{"product_id":4,"product_name":"普通花菜","category_id":1}
{"index":{"_id":5}}
{"product_id":5,"product_name":"胡萝卜","category_id":1}
{"index":{"_id":6}}
{"product_id":6,"product_name":"大料","category_id":2}
{"index":{"_id":7}}
{"product_id":7,"product_name":"花椒","category_id":2}
{"index":{"_id":8}}
{"product_id":8,"product_name":"八角","category_id":2}
{"index":{"_id":9}}
{"product_id":9,"product_name":"桂皮","category_id":2}
{"index":{"_id":10}}
{"product_id":10,"product_name":"香叶","category_id":2}

案例 2:搜索 “西蓝花”(解决 ES 语义排序问题)

测试目标

验证 pgvector 能否解决 “ES 搜西蓝花时花菜排首位” 的问题,实现 “西蓝花类商品优先排序”。

测试步骤

  1. 调用搜索接口:
    • 方式 1:通过 FastAPI docs(http://localhost:8000/docs),发送 POST 请求到/api/product/search
      {
          "query": "西蓝花",
          "category_id": 1,  // 只查蔬菜类
          "top_k": 5,
          "use_custom": true
      }
      
       
    • 方式 2:通过 Python 脚本调用:
      import requests
      url = "http://localhost:8000/api/product/search"
      data = {"query": "西蓝花", "category_id": 1, "top_k": 5}
      response = requests.post(url, json=data)
      print("搜索结果:", response.json())
      
       
  2. 预期结果:
    排序商品名称相似度分数匹配类型说明
    1 有机西蓝花 0.95-0.98 自动相似度 语义最接近 “西蓝花”
    2 普通西蓝花 0.90-0.94 自动相似度 语义次接近
    3 有机花菜 0.60-0.70 自动相似度 含 “花菜” 词根,语义较远
    4 普通花菜 0.55-0.65 自动相似度 语义更远
    5 胡萝卜 0.20-0.30 自动相似度 无关联,仅为填充结果
  3. SQL 验证(直接查询 PG):
    -- 生成“西蓝花”的向量(方案A用真实向量,方案B用模拟向量)
    WITH query_vec AS (
        -- 方案A(真实向量,替换为Python生成的“西蓝花”向量)
        SELECT '{0.123456,0.789012,...}'::vector(768) AS vec
        -- 方案B(模拟向量)
        -- SELECT (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768) AS vec
    )
    SELECT 
        p.product_name,
        1 - (p.product_vec <=> q.vec) AS similarity_score
    FROM product p
    CROSS JOIN query_vec q
    WHERE p.category_id = 1
    ORDER BY similarity_score DESC
    LIMIT 5;
    
     例子:方案B
    pgvector=# WITH query_vec AS (
    pgvector(#     -- 修正1:用方括号[]包裹向量;修正2:用random()生成完整的768维随机向量(无...占位符)
    pgvector(#     SELECT (SELECT array_agg(random()::float) FROM generate_series(1,768))::vector(768) AS vec
    pgvector(# )
    pgvector-# SELECT 
    pgvector-#     p.product_name,
    pgvector-#     1 - (p.product_vec <=> q.vec) AS similarity_score  -- 计算余弦相似度
    pgvector-# FROM product p
    pgvector-# CROSS JOIN query_vec q  -- 让每个商品都与查询向量匹配
    pgvector-# WHERE p.category_id = 1  -- 只查蔬菜类商品(过滤无关数据)
    pgvector-# ORDER BY similarity_score DESC  -- 按相似度降序排序(分数越高越相似)
    pgvector-# LIMIT 5; 
     product_name |  similarity_score  
    --------------+--------------------
     普通西蓝花   | 0.7604846243418141
     有机西蓝花   |  0.758759288440175
     普通花菜     | 0.7553687238098321
     胡萝卜       | 0.7386963989869387
     有机花菜     | 0.7295817579681287
    (5 rows)
    
    pgvector=# 

案例 3:搜索 “大料”(验证人工关联功能)

测试目标

验证人工关联规则是否生效,即 “大料” 搜索结果中,“花椒、八角、桂皮” 优先排序。

测试步骤

  1. 调用搜索接口:
    {
        "query": "大料",
        "category_id": 2,  // 只查调料类
        "top_k": 5,
        "use_custom": true
    }
    
     
  2. 预期结果:
    排序商品名称相似度分数匹配类型说明
    1 花椒 1.5 人工关联 人工设置权重最高
    2 八角 1.2 人工关联 人工设置权重次之
    3 桂皮 1.0 人工关联 人工设置权重最低
    4 香叶 0.65-0.75 自动相似度 语义接近调料类,自动匹配
    5 - - - 无更多结果(仅 5 个调料)
  3. SQL 验证(查询人工关联结果):
    -- 先获取大料的product_id
    WITH main AS (SELECT product_id FROM product WHERE product_name='大料')
    SELECT 
        p.product_name,
        r.custom_weight AS similarity_score,
        '人工关联' AS match_type
    FROM product_custom_relation r
    JOIN product p ON r.related_product_id = p.product_id
    WHERE r.main_product_id = (SELECT product_id FROM main)
    ORDER BY r.custom_weight DESC;
    pgvector=# WITH main AS (SELECT product_id FROM product WHERE product_name='大料')
    pgvector-# SELECT 
    pgvector-#     p.product_name,
    pgvector-#     r.custom_weight AS similarity_score,
    pgvector-#     '人工关联' AS match_type
    pgvector-# FROM product_custom_relation r
    pgvector-# JOIN product p ON r.related_product_id = p.product_id
    pgvector-# WHERE r.main_product_id = (SELECT product_id FROM main)
    pgvector-# ORDER BY r.custom_weight DESC;
     product_name | similarity_score | match_type 
    --------------+------------------+------------
     花椒         |              1.5 | 人工关联
     八角         |              1.2 | 人工关联
     桂皮         |                1 | 人工关联
    (3 rows)

案例 4:索引有效性验证

测试目标

确认 pgvector 的 HNSW 向量索引是否生效,避免全表扫描。

测试步骤

  1. 执行 EXPLAIN ANALYZE 查询:
    EXPLAIN ANALYZE
    SELECT 
        product_name,
        1 - (product_vec <=> (SELECT product_vec FROM product WHERE product_name='有机西蓝花')) AS similarity_score
    FROM product_vegetable  -- 蔬菜类分区表
    ORDER BY similarity_score DESC
    LIMIT 5;
    
     
  2. 预期结果:
    执行计划中包含 Index Scan using idx_product_veg_vec on product_vegetable,说明查询走了 HNSW 向量索引,未触发全表扫描(若显示Seq Scan,则索引未生效,需检查索引创建语句)。

六、方案效果验证与总结

1. 核心痛点解决效果

痛点解决方案验证结果
ES 语义排序颠倒 pgvector 语义向量匹配 搜 “西蓝花” 时,西蓝花类排首位
同类商品无序 余弦相似度降序排序 有机西蓝花→普通西蓝花→花菜
人工关联需求 product_custom_relation 表 + 权重融合 大料关联花椒、八角、桂皮并优先排序
架构复杂 PostgreSQL+pgvector 原生支持 无需额外部署向量数据库,简化运维

2. 性能指标(参考值)

测试场景数据量响应时间优化点
搜索 “西蓝花”(蔬菜类) 10 万商品 50-80ms ES 过滤后仅 100 + 商品参与向量计算
搜索 “大料”(调料类) 10 万商品 30-50ms 人工关联结果直接返回,无向量计算
向量索引查询(HNSW) 100 万商品 100-150ms 分区表 + 向量索引,性能达标

3. 后续优化方向

  • 向量降维:若商品量超 100 万,可将 768 维向量降维至 128 维(PCA/TSNE),减少存储与计算开销;
  • 混合排序:结合商品销量、库存、用户偏好(如 “常购商品”),调整最终排序分数(如 最终分数=相似度×0.7 + 销量权重×0.3);
  • 缓存优化:对高频搜索词(如 “西蓝花”“大米”)的结果缓存至 Redis,减少 PG/ES 查询次数。

本方案通过 “ES 粗过滤 + PG 向量精排序 + 人工关联融合” 的三层架构,完整解决了商超搜索的语义匹配与人工干预需求,同时提供可落地的 Python 代码与 SQL 测试案例,支持快速部署验证。
 posted on 2025-09-10 16:40  xibuhaohao  阅读(12)  评论(0)    收藏  举报