
最近在做电商商品侧的数据整理时遇到了一个很典型的问题同一个品类下的商品标题写法五花八门尺码、颜色、款式混在一起想按品类 属性给用户推荐相似商品光靠字符串匹配根本跑不通。像波司登 B260245171DS 羽绒服 品类推荐情侣款长款黑色XL这样一条商品信息里面其实压缩了商品编码、品牌、品类、款式、颜色、尺码六七个维度的信息如果不先做结构化拆解后面无论是做推荐、做筛选还是做搜索都会陷入每来一批数据就改一次规则的循环。本文就从这个场景出发完整实现一个可运行的商品属性建模 品类推荐服务用 Python 把商品标题拆成结构化属性用加权相似度算候选商品和目标商品的匹配分再用 Flask 暴露成 HTTP 接口最后给出数据初始化、运行验证、排错清单和工程化建议。读者对象是需要处理商品数据、做相似商品推荐、或者刚开始接触推荐逻辑的开发者跟着走完能拿到一套可以直接套到自己业务数据上的最小可用代码。示例中的数据只用于演示算法流程不代表任何真实商品信息实际使用时请替换为你自己的商品库。1. 从一条商品标题说起品类推荐在解决什么问题1.1 商品属性建模到底是什么先说得通俗一点商品属性建模就是把一段人看着能懂的描述变成程序能比较、能计算的字段。人看到情侣款长款黑色XL脑子里立刻能浮现出一件黑色长款外套还能判断它和另一件米色短款L不太像。但程序看到的只是一个字符串它不知道长款和短款是同一个维度的两个取值也不知道XL和XXL只差一档。专业一点的描述是属性建模就是为某一类实体定义一组维度Dimension和每个维度的取值域Value Domain把非结构化文本映射到这套结构上。对于服装类商品常见维度包括品类、品牌、版型、长度、颜色、尺码对于数码类商品维度会变成品牌、型号、内存、存储、颜色。维度选得对不对直接决定了后面的推荐效果和代码复杂度。这一步的价值在于它把像不像这种模糊判断变成了一组可以做数学运算的向量。一旦落到数值上排序、过滤、阈值控制就都能用代码表达了。1.2 SPU 与 SKU先把概念边界理清这两个词在实际开发中非常容易被混用先在这里划清界限。SPUStandard Product Unit标准产品单元描述的是这是什么商品比如某品牌情侣款长款羽绒服。它是一层抽象不涉及具体尺码和颜色。SKUStock 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和typing3.7 理论上也能跑但建议至少 3.8避免类型注解上出现兼容问题。数据库SQLitePython 标准库自带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.txtrequirements.txt内容如下。这里用版本下界约束不写死小版本号实际项目里建议按团队锁定的版本填Flask2.2SQLite 不需要安装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 到 1weight_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 与 XXLgap 1相似度 0.6隔一档XL 与 Mgap 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_okTrue) 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(keylambda 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_coderow[sku_code], spu_coderow[spu_code], titlerow[title], brandrow[brand] or , categoryrow[category] or , fitrow[fit] or , lengthrow[length] or , colorrow[color] or , sizerow[size] or , pricerow[price] or 0.0, stockrow[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, limitlimit, min_scoremin_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(host127.0.0.1, port5000, debugTrue)接口层做了几件必要的事。参数缺失返回 400 而不是 500语义更准确。limit做上下限截断防止有人传limit99999把数据库拖垮。min_score同样限制在 0 到 1避免非法值导致结果异常。数据库连接用try/finally保证关闭。这里用的是 SQL 参数化查询?占位符千万不要用字符串拼接 SQL。SKU 编码来自用户输入拼接会直接引入 SQL 注入风险这个问题在商品查询类接口里非常常见必须从一开始就养成习惯。另外示例里app.run(debugTrue)只适合本地调试。生产环境必须关掉 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?skuB260245171DSlimit54.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_score0查看全部候选分数分布分数全是 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 推不出东西时这三个数字基本能直接定位到是参数问题、数据问题还是阈值问题。生产环境关闭调试模式。debugTrue会在出错时展示交互式调试页面包含源码片段和环境变量风险很高。部署时务必改为debugFalse并用生产级 WSGI 服务器启动。7. 继续往下走的方向把上面的代码跑通之后这套方案已经能支撑一个中等规模商品库的品类推荐了。如果想继续往前推有几个方向值得试。第一个方向是给属性加细分权重。现在所有商品共用一套权重但实际业务里长款和短款的区分度在羽绒服品类下可能远高于在 T 恤品类下。按品类维护独立的权重表效果通常会有明显提升而且改动量不大——只需要把ATTR_WEIGHTS从单一字典改成一个以品类为键的嵌套字典。第二个方向是引入库存和价格做二次排序。相似度解决的是像不像但最终展示给用户的还应该考虑能不能买和买不买得起。在results.sort()之前插入一层业务规则排序比如库存为 0 的直接降权、价格偏离目标商品超过一定比例的直接过滤都是很实用的补充。第三个方向是把推荐理由结构化。示例里reasons返回的是中文字符串列表直接给前端展示够用但如果要做多维筛选或者效果归因分析就更适合返回结构化的标签数组比如[{dim: color, value: 黑色}]让消费方自己决定怎么渲染。第四个方向是补一层属性抽取。本文是假设属性字段已经在库里了但实际业务中这些字段往往需要从标题里抽出来。这部分可以用规则加词典打底先覆盖高频品类再逐步引入文本分类模型。规则打底的好处是可控出错能立刻定位到具体规则模型则需要更多的标注数据来保证稳定性。最后给几个上手就能用的检查项建议在每次调整完权重之后跑一遍用一个属性完整的目标商品查一次确认同款不同码排在第一位用一个属性缺失的目标商品查一次确认接口不报错、结果不为空用一个不存在的 SKU 查一次确认返回 404 而不是 500用一个品类下单条记录的目标商品查一次确认返回空列表而不是异常。这四条覆盖了绝大多数边界情况成本很低收益很直接。