ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

商品属性建模与品类推荐:Python加权相似度实战

商品属性建模与品类推荐:Python加权相似度实战

最近在做电商商品侧的数据整理时,遇到了一个很典型的问题:同一个品类下的商品,标题写法五花八门,尺码、颜色、款式混在一起,想按"品类 + 属性"给用户推荐相似商品,光靠字符串匹配根本跑不通。像波司登 B260245171DS 羽绒服 品类推荐情侣款长款黑色XL这样一条商品信息,里面其实压缩了商品编码、品牌、品类、款式、颜色、尺码六七个维度的信息,如果不先做结构化拆解,后面无论是做推荐、做筛选还是做搜索,都会陷入"每来一批数据就改一次规则"的循环。

本文就从这个场景出发,完整实现一个可运行的商品属性建模 + 品类推荐服务:用 Python 把商品标题拆成结构化属性,用加权相似度算候选商品和目标商品的匹配分,再用 Flask 暴露成 HTTP 接口,最后给出数据初始化、运行验证、排错清单和工程化建议。读者对象是需要处理商品数据、做相似商品推荐、或者刚开始接触推荐逻辑的开发者,跟着走完能拿到一套可以直接套到自己业务数据上的最小可用代码。示例中的数据只用于演示算法流程,不代表任何真实商品信息,实际使用时请替换为你自己的商品库。

1. 从一条商品标题说起:品类推荐在解决什么问题

1.1 商品属性建模到底是什么

先说得通俗一点:商品属性建模,就是把一段人看着能懂的描述,变成程序能比较、能计算的字段。人看到"情侣款长款黑色XL",脑子里立刻能浮现出一件黑色长款外套,还能判断它和另一件"米色短款L"不太像。但程序看到的只是一个字符串,它不知道"长款"和"短款"是同一个维度的两个取值,也不知道"XL"和"XXL"只差一档。

专业一点的描述是:属性建模就是为某一类实体定义一组维度(Dimension)和每个维度的取值域(Value Domain),把非结构化文本映射到这套结构上。对于服装类商品,常见维度包括品类、品牌、版型、长度、颜色、尺码;对于数码类商品,维度会变成品牌、型号、内存、存储、颜色。维度选得对不对,直接决定了后面的推荐效果和代码复杂度。

这一步的价值在于,它把"像不像"这种模糊判断,变成了一组可以做数学运算的向量。一旦落到数值上,排序、过滤、阈值控制就都能用代码表达了。

1.2 SPU 与 SKU:先把概念边界理清

这两个词在实际开发中非常容易被混用,先在这里划清界限。

SPU(Standard Product Unit,标准产品单元)描述的是"这是什么商品",比如"某品牌情侣款长款羽绒服"。它是一层抽象,不涉及具体尺码和颜色。

SKU(Stock Keeping Unit,库存量单位)描述的是"具体卖哪一件",比如"黑色 + XL"就是这条商品线下的一个具体可售卖单元。标题里的B260245171DS这类编码,通常就是 SKU 级别的唯一标识。

为什么必须分清?因为推荐的粒度不同,逻辑完全不同。如果按 SPU 推荐,用户看的是"同款还有哪些颜色尺码";如果按 SKU 推荐,代码需要额外处理"同款不同码"的关联关系,避免把同一个 SPU 下的兄弟 SKU 当成"相似商品"推给用户。本文的示例采用单表结构,同时保留spu_code字段,就是为了后面能按 SPU 做分组去重。这一点在需求评审阶段就要确认清楚,否则重构成本很高。

1.3 为什么先用规则加权,而不是直接上复杂模型

很多同学一听到"推荐"就想到协同过滤、向量召回。但对于品类推荐这个具体场景,初期数据量不大、业务规则明确的情况下,规则 + 加权相似度反而是性价比最高的选择。

原因有三点。第一,可解释。运营同学问"为什么给我推这件",你能直接回答"同品类、同版型、长度一致、只差一档尺码",而不是"模型算出来的"。第二,可调试。权重就是一个字典,调参不需要重新训练,改一个数字立刻生效。第三,冷启动友好。新商品没有行为数据,协同过滤完全推不出来,但属性是有的,规则推荐照样能工作。

复杂模型当然有价值,但它应该建立在属性建模已经做扎实的前提上。属性字段乱,喂给什么模型都是垃圾进垃圾出。所以本文的路线是:先建好结构,先把规则跑通,再考虑要不要上模型。

2. 环境准备与项目结构

2.1 运行环境与依赖说明

本文示例的技术选型刻意保持轻量,目的是让你复制下来就能跑,不需要装数据库服务、不需要配虚拟环境管理工具。

运行环境如下:

  • 操作系统:Windows 10/11、macOS、主流 Linux 发行版均可,代码不依赖平台特性。
  • Python 版本:3.8 及以上。代码用到了dataclasses和typing,3.7 理论上也能跑,但建议至少 3.8,避免类型注解上出现兼容问题。
  • 数据库:SQLite,Python 标准库自带sqlite3,无需额外安装服务。
  • Web 框架:Flask。不同大版本之间接口有小差异,具体版本以你环境中实际安装的稳定版本为准,本文示例只使用Flask、jsonify、request这些长期稳定的基础 API,跨版本兼容性较好。
  • 命令行工具:任意终端即可,curl或浏览器可直接验证接口。

如果你所在的项目已经统一了依赖管理,用公司的规范即可。本文重点是演示配置和算法思路,版本细节可以按实际情况调整。

2.2 项目目录结构

新建一个目录,结构如下:

goods-recommend/ ├── app.py # Flask 接口层 ├── recommend.py # 推荐核心算法 ├── init_data.py # 建表 + 示例数据初始化 ├── requirements.txt # 依赖声明 └── data/ └── goods.db # 运行 init_data.py 后自动生成

这个分层是刻意设计的。recommend.py只负责纯计算,不碰数据库、不碰 HTTP,这样才能单独写单元测试;app.py只负责参数校验和数据组装;init_data.py只负责数据准备。职责分开之后,后面想换数据库或者换 Web 框架,改动范围都很小。

2.3 安装依赖

在项目根目录执行:

pip install -r requirements.txt

requirements.txt内容如下。这里用版本下界约束,不写死小版本号,实际项目里建议按团队锁定的版本填:

Flask>=2.2

SQLite 不需要安装,sqlite3是 Python 标准库模块,直接用import sqlite3即可。如果你在部分精简版 Linux 环境下遇到sqlite3相关报错,通常是系统缺少 SQLite 开发库,安装对应的系统包即可解决,这一点在后面的排查章节会展开。

3. 核心原理拆解:从属性字段到推荐分数

3.1 属性字段该怎么选

字段设计是整个方案的地基。选少了区分度不够,选多了计算复杂还容易互相干扰。针对服装类商品,我建议至少保留下面五个业务维度:

字段含义示例值是否参与硬过滤
category品类羽绒服是
fit版型/适用人群情侣款否
length长度长款否
color颜色黑色否
size尺码XL否

category之所以设为硬过滤,是因为跨品类推荐在绝大多数场景下都是错的——用户在看羽绒服,你推一条棉服,即使颜色尺码全一致,转化率也很难看。其余字段参与加权打分,是因为它们存在"部分相似"的合理情况:颜色不同可以接受,尺码差一档也可以接受,但如果版型完全不同,相似度就该掉下去。

这里需要特别提醒一点:不要把整条标题直接当特征做字符串相似度。标题里包含商品编码、品牌词、营销词,这些噪声会让相似度计算严重失真。两个商品编码完全不同但实际是同款的情况非常常见,标题文本相似度却会给出很低的分数。

3.2 加权相似度模型

模型本身不复杂,核心公式就是一个加权平均:

score = Σ(weight_i × similarity_i) / Σ(weight_i)

其中similarity_i是第 i 个维度的相似度,取值 0 到 1;weight_i是该维度的重要性权重。权重越大,说明这个维度"不一致"对总分的影响越大。

这套设计的妙处在于,所有维度的分数都被归一化到了 0 到 1,最终总分也落在 0 到 1 之间,阈值非常好设。比如设 0.75,就意味着只保留整体比较接近的候选,而不是任由某一项不同就把结果拉低。

权重的初始值建议这样设置:品类给最高分(但注意品类已经做硬过滤了,权重主要在其他维度发力),版型和长度次之,颜色再次,尺码最低。原因是尺码属于"可替换"属性——用户穿 XL,推 XXL 在只有一档差距时是可以接受的,但推 S 码就毫无意义。所以尺码这个维度不能简单用"相等为 1、不等为 0",必须做档位距离计算。

3.3 尺码的档位距离与可替代性

尺码是有序的,这是它和颜色最本质的区别。所以尺码相似度应该随档位差的增大而衰减,而不是二值判断。

先定义一个有序序列:

SIZE_ORDER = ["XS", "S", "M", "L", "XL", "XXL", "3XL"]

然后计算档位差gap,相似度取max(0, 1 - gap × 0.4)。这样:

  • 同码,gap = 0,相似度 1.0
  • 相邻码(XL 与 L、XL 与 XXL),gap = 1,相似度 0.6
  • 隔一档(XL 与 M),gap = 2,相似度 0.2
  • 隔两档以上,相似度归零

这个衰减系数 0.4 可以直接写成常量,方便后续统一调整。如果业务上发现"推相邻码用户接受度很高",就把系数调小;如果发现"必须同码才准",就把系数调大甚至直接改成二值判断。

需要留意的是,尺码口径在不同品类下差异很大。鞋类用欧码数字,裤装常用腰围,某些品类还会出现均码、F、One Size这样的非序数值。遇到这类值,SIZE_ORDER里找不到就会返回 0,逻辑上不会报错,但会丢掉这部分相似度。生产环境里更稳妥的做法是给尺码单独维护一张映射表,把各品类的尺码口径统一到一个内部编码上。

4. 完整实战:实现一个品类推荐服务

4.1 建表与示例数据初始化

先写数据层。创建init_data.py,负责建表并插入演示数据。注意示例数据仅用于验证算法流程,商品编码和属性值都是演示用的,请替换成你自己的真实数据。

# 文件路径:init_data.py import os import sqlite3 DB_DIR = "data" DB_PATH = os.path.join(DB_DIR, "goods.db") CREATE_SQL = """ CREATE TABLE IF NOT EXISTS goods_sku ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku_code TEXT NOT NULL UNIQUE, spu_code TEXT NOT NULL, title TEXT NOT NULL, brand TEXT, category TEXT, fit TEXT, length TEXT, color TEXT, size TEXT, price REAL DEFAULT 0, stock INTEGER DEFAULT 0 ); """ # 说明:以下均为演示数据,不代表真实商品信息,price/stock 为占位值 SAMPLE_ROWS = [ ("B260245171DS", "SPU-B260245171", "示例商品-情侣款长款黑色XL", "示例品牌", "羽绒服", "情侣款", "长款", "黑色", "XL", 0, 20), ("B260245171DM", "SPU-B260245171", "示例商品-情侣款长款黑色M", "示例品牌", "羽绒服", "情侣款", "长款", "黑色", "M", 0, 15), ("DEMO-A001", "SPU-DEMO-A001", "示例商品-情侣款长款黑色XXL", "示例品牌", "羽绒服", "情侣款", "长款", "黑色", "XXL", 0, 8), ("DEMO-A002", "SPU-DEMO-A002", "示例商品-情侣款中长款米色XL", "示例品牌", "羽绒服", "情侣款", "中长款", "米色", "XL", 0, 12), ("DEMO-A003", "SPU-DEMO-A003", "示例商品-男款短款黑色L", "示例品牌", "羽绒服", "男款", "短款", "黑色", "L", 0, 30), ("DEMO-A004", "SPU-DEMO-A004", "示例商品-女款长款白色XL", "示例品牌", "羽绒服", "女款", "长款", "白色", "XL", 0, 5), ("DEMO-B001", "SPU-DEMO-B001", "示例商品-情侣款长款黑色XL", "示例品牌", "棉服", "情侣款", "长款", "黑色", "XL", 0, 18), ("DEMO-B002", "SPU-DEMO-B002", "示例商品-情侣款长款黑色L", "示例品牌", "风衣", "情侣款", "长款", "黑色", "L", 0, 10), ] def init_db() -> None: os.makedirs(DB_DIR, exist_ok=True) conn = sqlite3.connect(DB_PATH) try: conn.execute(CREATE_SQL) conn.executemany( """ INSERT OR REPLACE INTO goods_sku (sku_code, spu_code, title, brand, category, fit, length, color, size, price, stock) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, SAMPLE_ROWS, ) conn.commit() print(f"[OK] 初始化完成,数据库:{DB_PATH},写入 {len(SAMPLE_ROWS)} 条记录") finally: conn.close() if __name__ == "__main__": init_db()

这段代码有几个细节值得说明。第一,用了INSERT OR REPLACE,配合sku_code上的UNIQUE约束,重复执行脚本不会产生重复数据,方便反复调试。第二,建表语句里品类、版型、长度这些字段都允许为空,因为实际数据里一定会存在属性缺失的商品,如果设成NOT NULL,导入阶段就会大面积失败。第三,conn.commit()和conn.close()放在try/finally里,保证异常时连接也能释放。

4.2 推荐引擎实现

核心算法放在recommend.py。这个文件只做纯计算,不引入任何 IO。

# 文件路径:recommend.py from dataclasses import dataclass from typing import Dict, List # 尺码有序序列,用于计算档位距离 SIZE_ORDER = ["XS", "S", "M", "L", "XL", "XXL", "3XL"] # 属性权重:数值越大,该维度不一致时对总分的影响越强 ATTR_WEIGHTS: Dict[str, float] = { "category": 3.0, "fit": 1.5, "length": 1.5, "color": 1.2, "size": 1.0, } # 尺码每相差一档扣减的系数 SIZE_GAP_PENALTY = 0.4 @dataclass class Goods: sku_code: str spu_code: str title: str brand: str category: str fit: str length: str color: str size: str price: float = 0.0 stock: int = 0 def attr(self, name: str) -> str: """安全读取属性值,统一转成去空格的大写/原值字符串。""" value = getattr(self, name, "") if value is None: return "" return str(value).strip() def size_score(a: str, b: str) -> float: """按档位距离计算尺码相似度,同码为 1.0,差距越大越低。""" a, b = a.upper(), b.upper() if not a or not b: return 0.0 if a == b: return 1.0 if a not in SIZE_ORDER or b not in SIZE_ORDER: return 0.0 gap = abs(SIZE_ORDER.index(a) - SIZE_ORDER.index(b)) return max(0.0, 1.0 - gap * SIZE_GAP_PENALTY) def similarity(target: Goods, candidate: Goods) -> float: """计算两个商品的加权属性相似度,返回值区间 [0, 1]。""" total = 0.0 for name, weight in ATTR_WEIGHTS.items(): va, vb = target.attr(name), candidate.attr(name) if name == "size": total += weight * size_score(va, vb) else: total += weight * (1.0 if va and va == vb else 0.0) return round(total / sum(ATTR_WEIGHTS.values()), 4) def recommend( target: Goods, candidates: List[Goods], limit: int = 5, min_score: float = 0.5, ) -> List[dict]: """返回按相似度倒序排列的推荐结果,附带可解释的推荐理由。""" results = [] for item in candidates: if item.sku_code == target.sku_code: continue # 品类硬过滤:品类不同直接跳过 if target.attr("category") and item.attr("category") != target.attr("category"): continue score = similarity(target, item) if score < min_score: continue results.append( { "sku_code": item.sku_code, "spu_code": item.spu_code, "title": item.title, "category": item.category, "fit": item.fit, "length": item.length, "color": item.color, "size": item.size, "stock": item.stock, "score": score, "reasons": build_reasons(target, item), } ) results.sort(key=lambda x: (-x["score"], x["sku_code"])) return results[:limit] def build_reasons(target: Goods, candidate: Goods) -> List[str]: """生成人类可读的推荐理由,便于运营侧核对和前端展示。""" reasons: List[str] = [] if target.attr("category") and target.attr("category") == candidate.attr("category"): reasons.append(f"同品类:{candidate.attr('category')}") if target.attr("fit") and target.attr("fit") == candidate.attr("fit"): reasons.append(f"同版型:{candidate.attr('fit')}") if target.attr("length") and target.attr("length") == candidate.attr("length"): reasons.append(f"同长度:{candidate.attr('length')}") if target.attr("color") and target.attr("color") == candidate.attr("color"): reasons.append(f"同颜色:{candidate.attr('color')}") if target.attr("size") == candidate.attr("size"): reasons.append(f"同尺码:{candidate.attr('size')}") elif size_score(target.attr("size"), candidate.attr("size")) > 0: reasons.append(f"尺码接近:{target.attr('size')} → {candidate.attr('size')}") return reasons

这里重点解释三个设计决策。

第一,品类过滤放在打分之前。如果先算分再过滤,会白算一堆注定要丢弃的候选,数据量大时性能浪费明显。而且先过滤能让后续的阈值设置更有意义,因为剩下的候选都已经是同品类了。

第二,同 SPU 的兄弟 SKU 没有被排除。上面的代码只跳过了sku_code完全相同的自己。这意味着"同款不同码"会被推荐出来。这在很多场景下其实是正确行为——用户在看 XL,告诉他同款还有 M 码,是很自然的引导。但如果你的业务明确要求"推荐必须跨 SPU",就在循环里加一行if item.spu_code == target.spu_code: continue,按需取舍。

第三,min_score默认给了 0.5。这个值是经验值,不是绝对标准。它的作用是挡掉明显不相关的候选,避免结果列表里出现一堆 0.2 分的凑数项。上线前建议用真实数据跑一批样本,观察分数分布再决定阈值。

4.3 HTTP 接口层

有了算法,再包一层接口。创建app.py:

# 文件路径:app.py import sqlite3 from flask import Flask, jsonify, request from recommend import Goods, recommend DB_PATH = "data/goods.db" app = Flask(__name__) def get_conn() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def row_to_goods(row: sqlite3.Row) -> Goods: return Goods( sku_code=row["sku_code"], spu_code=row["spu_code"], title=row["title"], brand=row["brand"] or "", category=row["category"] or "", fit=row["fit"] or "", length=row["length"] or "", color=row["color"] or "", size=row["size"] or "", price=row["price"] or 0.0, stock=row["stock"] or 0, ) @app.get("/api/recommend") def api_recommend(): sku = (request.args.get("sku") or "").strip() if not sku: return jsonify({"code": 400, "msg": "缺少参数 sku"}), 400 try: limit = int(request.args.get("limit", 5)) min_score = float(request.args.get("min_score", 0.5)) except ValueError: return jsonify({"code": 400, "msg": "limit 或 min_score 参数格式不正确"}), 400 limit = max(1, min(limit, 20)) min_score = max(0.0, min(min_score, 1.0)) conn = get_conn() try: target_row = conn.execute( "SELECT * FROM goods_sku WHERE sku_code = ?", (sku,) ).fetchone() if target_row is None: return jsonify({"code": 404, "msg": f"商品不存在:{sku}"}), 404 target = row_to_goods(target_row) candidate_rows = conn.execute( "SELECT * FROM goods_sku WHERE category = ?", (target.category,) ).fetchall() candidates = [row_to_goods(r) for r in candidate_rows] finally: conn.close() items = recommend(target, candidates, limit=limit, min_score=min_score) return jsonify( { "code": 200, "msg": "ok", "target": {"sku_code": target.sku_code, "title": target.title}, "total": len(items), "items": items, } ) if __name__ == "__main__": app.run(host="127.0.0.1", port=5000, debug=True)

接口层做了几件必要的事。参数缺失返回 400 而不是 500,语义更准确。limit做上下限截断,防止有人传limit=99999把数据库拖垮。min_score同样限制在 0 到 1,避免非法值导致结果异常。数据库连接用try/finally保证关闭。

这里用的是 SQL 参数化查询?占位符,千万不要用字符串拼接 SQL。SKU 编码来自用户输入,拼接会直接引入 SQL 注入风险,这个问题在商品查询类接口里非常常见,必须从一开始就养成习惯。

另外,示例里app.run(debug=True)只适合本地调试。生产环境必须关掉 debug,改用 WSGI 服务器部署,否则调试页面会泄露源码和堆栈信息。

4.4 运行与验证

按顺序执行三步:

# 1. 安装依赖 pip install -r requirements.txt # 2. 初始化数据库 python init_data.py # 3. 启动服务 python app.py

第二步的预期输出:

[OK] 初始化完成,数据库:data/goods.db,写入 8 条记录

第三步看到Running on http://127.0.0.1:5000就说明服务起来了。另开一个终端验证接口:

curl "http://127.0.0.1:5000/api/recommend?sku=B260245171DS&limit=5"

4.5 结果说明

预期返回类似下面的结构(具体分数以实际运行为准):

{ "code": 200, "msg": "ok", "target": { "sku_code": "B260245171DS", "title": "示例商品-情侣款长款黑色XL" }, "total": 3, "items": [ { "sku_code": "DEMO-A001", "spu_code": "SPU-DEMO-A001", "title": "示例商品-情侣款长款黑色XXL", "category": "羽绒服", "fit": "情侣款", "length": "长款", "color": "黑色", "size": "XXL", "stock": 8, "score": 0.9, "reasons": ["同品类:羽绒服", "同版型:情侣款", "同长度:长款", "同颜色:黑色", "尺码接近:XL → XXL"] } ] }

对照着看结果是否符合预期,这是验证算法是否正确的关键一步。

B260245171DS的同款 M 码B260245171DM被推荐出来了,因为它除了尺码之外全部一致,尺码差一档扣分不多,总分依然很高——这正是我们希望的"同款不同码"引导。

DEMO-A002(米色)和DEMO-A003(男款短款)会因为版型、长度、颜色多项不一致,分数被压到阈值以下,不会出现在结果里。

DEMO-B001(棉服)和DEMO-B002(风衣)虽然颜色尺码一模一样,但因为品类不同,在第一层硬过滤就被拦掉了。这一步如果没生效,说明 SQL 里的category条件或者recommend里的过滤逻辑出了问题,可以优先排查这两处。

5. 常见问题与排查思路

实际落地时踩的坑往往不在算法本身,而在数据、环境和边界条件上。下面这张表整理了高频问题,可以先按表格定位,再往下看详细分析。

问题现象常见原因解决思路
ModuleNotFoundError: No module named 'flask'依赖未安装或装到了别的解释器环境确认pip与python指向同一环境,重新安装
sqlite3.OperationalError: no such table: goods_sku未执行init_data.py,或数据库路径不一致先跑初始化脚本,检查DB_PATH是否为同一相对路径
接口返回 404 商品不存在SKU 大小写或前后空格不一致入库前统一strip(),查询前规范化标识
推荐结果为空min_score阈值过高,或品类字段为空导致过滤失效先用min_score=0查看全部候选分数分布
分数全是 0 或全一样属性字段大量为空,相似度算不出差异检查数据导入时字段是否正确映射
主键冲突UNIQUE constraint failed重复插入同一sku_code使用INSERT OR REPLACE或先删除再插入
启动后端口被占用5000 端口已被其他进程使用换端口启动,或结束占用进程
中文返回乱码客户端未按 UTF-8 解析确认响应头字符集,终端设置 UTF-8

挑几个展开说。

关于"推荐结果为空"这个最常见的问题。排查顺序建议是:第一步,把min_score临时设成 0,看是否能返回候选,如果还是空,问题在召回层而不是排序层;第二步,检查目标商品的category是否为空,如果为空,SQL 里WHERE category = ''会召不回任何数据,表现就是结果为空,但数据其实都在库里;第三步,检查候选商品的属性字段是不是也大量为空,全空的话相似度会接近 0,踩阈值被过滤掉。

关于数据库路径问题。这是新手最容易卡住的地方。DB_PATH = "data/goods.db"是相对路径,相对于你执行命令时所在的当前工作目录,而不是脚本所在目录。如果你在项目根目录执行python init_data.py,数据库生成在根目录/data/goods.db;但如果你cd到别的目录再执行python /path/to/init_data.py,数据库就会生成在别的目录,随后app.py找不到表就开始报错。稳妥做法是用绝对路径,基于__file__推算:

import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DB_PATH = os.path.join(BASE_DIR, "data", "goods.db")

关于属性缺失导致推荐失真。这是数据质量问题,不是代码问题,但会直接表现为"推荐不准"。建议在数据导入阶段就做一次体检,统计各字段的空值率,空值率超过一定比例的字段要么补全,要么在权重里临时降权。上面代码里attr()方法做了None和空字符串的统一处理,能保证不抛异常,但算不出区分度,所以最终还是要在数据侧解决。

6. 最佳实践与工程建议

6.1 数据层:属性标准化比算法更重要

推荐效果的天花板,基本由数据质量决定。这里有几条实践建议。

建立属性字典表。不要直接存"情侣款""男女同款""中性款"这种自由文本,而是维护一张字典表,把所有同义表述映射到统一的内部编码。同一个属性值出现三种写法,相似度计算就会得出三个不同结果,这类问题在数据量上来之后会非常难排查。

入库前统一清洗。包括去除首尾空格、全角转半角、大小写统一、颜色别名归一(比如"黑"和"黑色")。这些清洗动作应该在数据接入层做一次,而不是在推荐算法里每个地方都做一遍,否则代码会被重复的strip()淹没。

保留原始标题字段。结构化字段是为计算服务的,但运营排查问题时还是需要看原始标题。所以title字段要保留,不要只存拆解后的结果。

注意 SPU 与 SKU 的关联完整性。如果要做"同款不同码"的引导,spu_code必须在同款的所有 SKU 上保持一致。这个字段一旦错了,推荐结果会出现把完全无关的商品归为同款的情况,用户体验很糟糕。

6.2 代码层:权重配置化、逻辑可测试

权重不要硬编码在函数里。示例里ATTR_WEIGHTS放在模块顶层已经比写在函数内部好,但生产环境更推荐放到配置文件或者配置中心。原因是权重调整属于业务动作,运营改一次权重不应该走一次代码发布流程。如果项目里已经有配置中心,把权重做成可热更新的配置项是最合适的。

给核心算法补单元测试。recommend.py是纯函数式的,不依赖数据库和网络,非常适合写测试。至少要覆盖这几类用例:同码同属性得满分、相邻码扣分符合预期、跨品类被过滤、属性为空不抛异常、limit截断生效。这些测试写起来很快,但能在你后续调权重时提供安全网。

控制函数的输入输出边界。recommend()接收的是List[Goods],返回的是List[dict]。返回 dict 是为了方便直接序列化,但这也意味着接口层和算法层耦合了展示结构。如果后续要接多个出口(App、小程序、后台),更干净的做法是让算法层返回List[Tuple[Goods, float]],由各自的出口层去组装展示结构。

注意浮点数比较。示例里用了round(score, 4),是为了避免前端展示出现0.9000000000000001这种值。排序时用-x["score"]是好习惯,但要注意浮点精度在极端情况下可能造成排序不稳定,必要时加个次级排序键(示例里用了sku_code)保证结果可复现。

6.3 接口与安全:把边界条件当成必答题

参数校验要完整。示例里对sku、limit、min_score都做了校验,但真实场景还要考虑:超长字符串、特殊字符、非数字字符。缺少校验的接口很容易被恶意请求打出异常堆栈,进而暴露内部实现细节。

SQL 必须参数化。前面强调过一次,这里再强调一次。所有涉及用户输入的地方统一用?占位符,不要图省事做字符串拼接。这是底线,不是可选项。

接口要有超时和限流。示例代码没有做,但生产环境必须考虑。一个limit被人为放大到几万,配合大表全表扫描,足以把服务拖垮。限流可以在网关层做,也可以在应用层用简单的计数器实现。

日志要能定位问题。至少记录三类信息:请求入参、召回候选数量、最终返回数量。当运营反馈"这个 SKU 推不出东西"时,这三个数字基本能直接定位到是参数问题、数据问题还是阈值问题。

生产环境关闭调试模式。debug=True会在出错时展示交互式调试页面,包含源码片段和环境变量,风险很高。部署时务必改为debug=False,并用生产级 WSGI 服务器启动。

7. 继续往下走的方向

把上面的代码跑通之后,这套方案已经能支撑一个中等规模商品库的品类推荐了。如果想继续往前推,有几个方向值得试。

第一个方向是给属性加细分权重。现在所有商品共用一套权重,但实际业务里"长款"和"短款"的区分度,在羽绒服品类下可能远高于在 T 恤品类下。按品类维护独立的权重表,效果通常会有明显提升,而且改动量不大——只需要把ATTR_WEIGHTS从单一字典改成一个以品类为键的嵌套字典。

第二个方向是引入库存和价格做二次排序。相似度解决的是"像不像",但最终展示给用户的还应该考虑"能不能买"和"买不买得起"。在results.sort()之前插入一层业务规则排序,比如库存为 0 的直接降权、价格偏离目标商品超过一定比例的直接过滤,都是很实用的补充。

第三个方向是把推荐理由结构化。示例里reasons返回的是中文字符串列表,直接给前端展示够用,但如果要做多维筛选或者效果归因分析,就更适合返回结构化的标签数组,比如[{"dim": "color", "value": "黑色"}],让消费方自己决定怎么渲染。

第四个方向是补一层属性抽取。本文是假设属性字段已经在库里了,但实际业务中这些字段往往需要从标题里抽出来。这部分可以用规则加词典打底,先覆盖高频品类,再逐步引入文本分类模型。规则打底的好处是可控,出错能立刻定位到具体规则,模型则需要更多的标注数据来保证稳定性。

最后给几个上手就能用的检查项,建议在每次调整完权重之后跑一遍:用一个属性完整的目标商品查一次,确认同款不同码排在第一位;用一个属性缺失的目标商品查一次,确认接口不报错、结果不为空;用一个不存在的 SKU 查一次,确认返回 404 而不是 500;用一个品类下单条记录的目标商品查一次,确认返回空列表而不是异常。这四条覆盖了绝大多数边界情况,成本很低,收益很直接。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进