Python电商知识图谱实战:从商品表到推荐问答的完整链路

发布时间:2026/10/12 12:02:03
Python电商知识图谱实战:从商品表到推荐问答的完整链路
简介这是一套面向计算机相关专业学生与开发者的电商行业知识图谱实战项目围绕实体关系构建展开可应用于商品推荐、商品搭配与智能问答等场景适合作为毕业设计、课程设计或知识图谱入门进阶的学习素材。压缩包共59个文件、约15.49MB以21个Python源码为核心配合10个CSV与5个JSON数据文件承载商品、关系等图谱数据另有TXT词典、Markdown说明、XMind思维导图及配置与前端页面文件结构清晰便于按模块研读。目前已有130人学习下载。项目代码经测试运行成功答辩评审平均分达96分读者可据此掌握从数据加载、图谱构建到问答与推荐应用的完整链路并可在源码基础上修改扩展实现个性化功能需求。1. 电商知识图谱落地从商品表到推荐问答的一条完整链路电商后台里躺着的商品表、订单表、用户行为日志单看都是结构化数据可一旦要回答“买了这款咖啡机的人还常配什么滤纸”“这套穿搭能不能凑单”“这款手机和那款壳兼容吗”传统 SQL 和协同过滤就开始力不从心。Python 电商行业知识图谱要解决的正是这类跨实体、多跳、带语义的查询把商品、品类、品牌、属性、用户、场景抽成实体把“属于”“兼容”“搭配”“购买过”抽成关系再往上挂商品推荐、商品搭配和问答系统三个应用。适合已经会用 Python 处理数据、想给推荐和搜索加一层语义推理的工程师也适合做电商中台、想统一商品理解口径的团队。下面按“建图 → 存图 → 推荐 → 搭配 → 问答 → 避坑”的顺序把这条链路拆开讲透。2. 电商实体关系怎么抽本体设计与三元组生成知识图谱的上限由本体决定本体设计歪了后面推荐和问答全是玄学。电商场景的本体不需要一上来就追求学术完备先把业务真正会查的关系列出来再倒推实体和属性。2.1 先定本体电商场景的 6 类实体与 8 种关系我一般从业务问题反推本体。把“用户会怎么问、推荐会怎么召回”写成清单再归纳成实体和关系。电商里高频的实体无非这几类实体类型典型属性举例商品 Product标题、价格、销量、评分某款 500ml 保温杯品类 Category层级、父类家居 水具 保温杯品牌 Brand名称、产地某国产厨具品牌属性 Attribute颜色、容量、材质304 不锈钢、500ml用户 User画像标签、会员等级母婴人群、高复购场景 Scene使用场合通勤、露营、送礼关系则围绕“连接”和“行为”两类展开属于品类、属于品牌、具有属性、兼容、搭配、购买过、浏览过、相似于。前四种是商品侧静态关系后四种是行为侧动态关系。本体一旦定下来就写进配置文件别散落在代码里否则后期加一个关系要改十几个文件。提示本体不要一次求全。先覆盖 80% 的查询场景留一个其他关系兜底跑起来再迭代比憋一个完美本体快得多。2.2 从商品标题和属性表抽三元组真实数据源通常是商品表加一份类目属性表。结构化字段直接映射成三元组非结构化的标题和详情页文本才需要抽。下面这段代码把一张商品 DataFrame 转成三元组列表是整条链路里最该先跑通的一步。import pandas as pd # 商品表id, title, category, brand, price, attrs(JSON字符串) df pd.read_csv(products.csv) triples [] for _, row in df.iterrows(): pid fproduct:{row[id]} # 静态关系品类、品牌 triples.append((pid, 属于品类, fcategory:{row[category]})) triples.append((pid, 属于品牌, fbrand:{row[brand]})) # 属性关系attrs 形如 {容量:500ml,材质:304不锈钢} attrs eval(row[attrs]) if isinstance(row[attrs], str) else {} for k, v in attrs.items(): triples.append((pid, f具有属性_{k}, fvalue:{v})) # 价格分档方便后续规则推荐 price float(row[price]) band 低价 if price 50 else 中价 if price 300 else 高价 triples.append((pid, 价格档位, fband:{band})) print(len(triples), triples[:3])逻辑说明每条三元组是(头实体, 关系, 尾实体)实体统一加前缀product:、category:避免不同表 ID 撞车。属性键拼进关系名具有属性_容量好处是查询时能直接按属性过滤坏处是关系种类会膨胀属性超过几十个时建议改成统一的具有属性加属性实体。参数上价格分档阈值要按你的品类调3C 和日用品的“高价”完全不是一个量级别照抄。2.3 用 jieba 从标题补抽属性控制噪声标题里藏着属性表没有的信息比如“便携”“大容量”“送礼”。用 jieba 做词性标注把名词和形容词抽出来当候选属性再和已有属性词表做匹配。import jieba.posseg as pseg ATTR_WHITELIST {便携, 大容量, 防水, 保温, 轻奢, 送礼} def extract_from_title(title): hits [] for word, flag in pseg.cut(title): if word in ATTR_WHITELIST: hits.append(word) return hits # 只保留白名单命中避免把包邮正品这类营销词也当属性 for _, row in df.iterrows(): for attr in extract_from_title(row[title]): triples.append((fproduct:{row[id]}, 具有属性_卖点, fvalue:{attr}))逻辑说明白名单是关键。开放抽取在电商标题上噪声极大“包邮”“秒杀”“正品”都会被抽成属性污染图谱。我一般先跑一遍统计词频人工挑出真正有区分度的词进白名单再回填。参数上pseg.cut默认精确模式够用标题短不需要全模式。3. 图数据库选型与入库Neo4j 还是内存图三元组有了得找个地方存。选型不看名气看查询模式如果你的问答和推荐需要多跳推理、路径查询图数据库比关系库省事得多如果只是几百个实体做演示NetworkX 内存图就够。3.1 Neo4j 与 NetworkX 的取舍维度Neo4jNetworkX数据规模百万级以上十万级以内多跳查询Cypher 原生支持手写遍历持久化自带需自己序列化部署成本要起服务一个 pip 包适用阶段生产原型验证我的习惯是原型阶段用 NetworkX 快速验证本体和查询逻辑逻辑稳定后再迁 Neo4j。别一上来就搭集群本体还没定就上生产库改一次结构迁移一次血泪经验。3.2 用 py2neo 批量写入 Neo4j批量写入一定要用UNWIND逐条CREATE在几万条数据上能跑到你怀疑人生。from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) def bulk_insert(triples, batch1000): for i in range(0, len(triples), batch): chunk triples[i:i batch] # 用 UNWIND 一次提交一批减少网络往返 graph.run( UNWIND $rows AS row MERGE (h:Entity {id: row[0]}) MERGE (t:Entity {id: row[2]}) MERGE (h)-[r:REL {type: row[1]}]-(t) , rowschunk) bulk_insert(triples)逻辑说明MERGE而不是CREATE保证重复实体不会写重。所有实体统一用Entity标签加id属性关系统一用REL加type属性这样查询时不用关心具体类型先跑通再考虑给高频类型单独建标签。参数上batch取 500 到 2000 之间太大单次事务内存吃紧太小往返开销高。写入前记得给Entity(id)建唯一约束否则 MERGE 会全表扫。graph.run(CREATE CONSTRAINT IF NOT EXISTS FOR (e:Entity) REQUIRE e.id IS UNIQUE)3.3 验证入库结果三个必查 Cypher写完别急着上层应用先跑三条查询确认图谱结构对。// 1. 实体和关系总量 MATCH (n:Entity) RETURN count(n) AS nodes; MATCH ()-[r:REL]-() RETURN count(r) AS rels; // 2. 某商品的直接关系 MATCH (p:Entity {id:product:1001})-[r:REL]-(t) RETURN r.type, t.id; // 3. 两跳买了 A 的人也买了什么 MATCH (a:Entity {id:product:1001})-[:REL {type:购买过}]-(u)-[:REL {type:购买过}]-(b) RETURN b.id, count(u) AS freq ORDER BY freq DESC LIMIT 10;逻辑说明第一条看规模是否符合预期第二条验证单商品关系完整第三条就是最朴素的协同召回也是后面推荐章节的基础。如果第三条查不出东西多半是行为数据还没入库先补购买过关系。4. 基于图谱的商品推荐多跳召回与规则打分图谱推荐和协同过滤的区别在于协同过滤靠共现统计图谱能沿着关系路径解释“为什么推荐”。这一章把召回和排序拆开做。4.1 多跳召回从购买行为到属性相似召回阶段目标是把候选集拉大宁滥勿缺。三条常用路径同品类热销、共同购买、属性相似。def recall_by_category(graph, pid, limit50): query MATCH (p:Entity {id:$pid})-[:REL {type:属于品类}]-(c)-[:REL {type:属于品类}]-(other) WHERE other.id $pid OPTIONAL MATCH (other)-[:REL {type:购买过}]-(u) RETURN other.id AS item, count(u) AS heat ORDER BY heat DESC LIMIT $limit return graph.run(query, pidpid, limitlimit).data() def recall_by_cooccur(graph, pid, limit50): query MATCH (p:Entity {id:$pid})-[:REL {type:购买过}]-(u)-[:REL {type:购买过}]-(other) WHERE other.id $pid RETURN other.id AS item, count(u) AS freq ORDER BY freq DESC LIMIT $limit return graph.run(query, pidpid, limitlimit).data()逻辑说明recall_by_category走品类路径解决冷启动recall_by_cooccur走用户共购路径精度高但覆盖窄。两路结果合并去重后再进排序。参数limit按业务定召回太少排序没得选太多排序压力大一般每路 50 到 200。4.2 规则打分把图谱路径变成可解释分数排序阶段用加权规则每条规则对应一条图谱路径分数可解释运营也看得懂。def score_candidate(graph, pid, cand): score 0.0 # 同品牌 2 q1 MATCH (a:Entity {id:$a})-[:REL {type:属于品牌}]-(b)-[:REL {type:属于品牌}]-(c:Entity {id:$c}) RETURN count(b) AS n if graph.run(q1, apid, ccand).data()[0][n] 0: score 2.0 # 价格档位相同 1 q2 MATCH (a:Entity {id:$a})-[:REL {type:价格档位}]-(band)-[:REL {type:价格档位}]-(c:Entity {id:$c}) RETURN count(band) AS n if graph.run(q2, apid, ccand).data()[0][n] 0: score 1.0 # 共购频次归一化后加权 q3 MATCH (a:Entity {id:$a})-[:REL {type:购买过}]-(u)-[:REL {type:购买过}]-(c:Entity {id:$c}) RETURN count(u) AS n score min(graph.run(q3, apid, ccand).data()[0][n] / 10.0, 3.0) return score逻辑说明每条规则独立查询权重是拍出来的初始值上线后用点击率回归校准。min(..., 3.0)是防止爆款共购频次过高把分数拉爆。这套打分慢适合离线批量算线上用预计算好的分数表。4.3 离线预计算与在线查询分离线上实时跑多跳查询扛不住 QPS标准做法是离线把每个商品的 TopN 推荐算好写回图或缓存。import json def precompute(graph, all_pids, topn20): result {} for pid in all_pids: cands recall_by_category(graph, pid) recall_by_cooccur(graph, pid) seen, scored set(), [] for c in cands: if c[item] in seen: continue seen.add(c[item]) scored.append((c[item], score_candidate(graph, pid, c[item]))) scored.sort(keylambda x: -x[1]) result[pid] scored[:topn] with open(rec_cache.json, w) as f: json.dump(result, f) precompute(graph, [fproduct:{i} for i in range(1, 1001)])逻辑说明离线任务按商品维度全量算结果落 JSON 或 Redis。在线只做一次查表。参数topn取 20 到 50留出重排空间。注意去重要在打分前做否则同一商品被两路召回会重复计分。5. 商品搭配与问答系统从兼容关系到自然语言查询推荐解决“买什么”搭配解决“一起买什么”问答解决“用自然语言问图谱”。三者共用同一张图只是查询模式不同。5.1 搭配关系抽取兼容与互补两类搭配关系分两种兼容手机和壳必须匹配和互补咖啡机和滤纸常一起买。兼容关系靠属性规则推互补关系靠共购统计。def build_compatibility(graph): # 同品类下属性值匹配的商品互为兼容如手机壳适配机型 query MATCH (a:Entity)-[:REL {type:具有属性_适配机型}]-(v)-[:REL {type:具有属性_适配机型}]-(b:Entity) WHERE a.id b.id MERGE (a)-[:REL {type:兼容}]-(b) graph.run(query) def build_complement(graph, min_freq5): # 共购频次超过阈值视为互补搭配 query MATCH (a:Entity)-[:REL {type:购买过}]-(u)-[:REL {type:购买过}]-(b:Entity) WHERE a.id b.id WITH a, b, count(u) AS freq WHERE freq $min MERGE (a)-[:REL {type:搭配}]-(b) graph.run(query, minmin_freq)逻辑说明兼容用属性值相等做匹配a.id b.id保证只建一条无向边。互补用共购频次min_freq是核心参数设太低会引入大量偶然共购设太高覆盖不够一般从 5 起调看数据量。搭配关系建好后商品详情页的“搭配购”直接查一跳即可。5.2 问答系统意图识别加模板查询问答不需要上大模型电商问句高度模板化意图分类加 Cypher 模板就能覆盖大部分。先定义意图到查询的映射。INTENT_TEMPLATES { query_attr: MATCH (p:Entity {id:$pid})-[:REL]-(t) WHERE t.id STARTS WITH value: RETURN t.id AS attr , query_compatible: MATCH (p:Entity {id:$pid})-[:REL {type:兼容}]-(c) RETURN c.id AS item , query_pair: MATCH (p:Entity {id:$pid})-[:REL {type:搭配}]-(c) RETURN c.id AS item , } def answer(graph, intent, pid): if intent not in INTENT_TEMPLATES: return 暂不支持该问题 rows graph.run(INTENT_TEMPLATES[intent], pidpid).data() return rows逻辑说明意图识别可以用关键词规则起步“兼容”映射query_compatible“搭配”映射query_pair“什么材质”映射query_attr跑通后再换小分类模型。参数pid由上游实体链接模块从问句里抽出来实体链接是问答准确率的瓶颈商品名归一化没做好后面全白搭。5.3 实体链接把“那个保温杯”对上商品 ID用户不会说商品 ID会说“上次看的那个保温杯”。实体链接要做的是把口语指代映射到图谱实体。from rapidfuzz import process def link_entity(mention, candidates): # candidates 是 [(pid, title), ...] titles [c[1] for c in candidates] match, score, idx process.extractOne(mention, titles) if score 60: # 相似度阈值低于则判为未识别 return None return candidates[idx][0]逻辑说明用模糊匹配做兜底score阈值 60 是经验值低于它宁可返回未识别也别乱链链错比不链更伤体验。生产环境一般再加一层向量召回把商品标题和属性编码后做语义匹配模糊匹配只处理拼写差异。6. 避坑与排查图谱项目最容易翻车的 5 个点知识图谱项目翻车往往不在算法而在数据和工程细节。下面 5 条是我踩过的按“现象 → 原因 → 解决”写。实体重复同一个商品两个 ID。现象是推荐结果里出现两个几乎一样的商品问答查属性只返回一半。原因是多数据源合并时没做实体对齐商品表 ID 和详情页 ID 各建各的。解决是入库前先跑一轮实体对齐用标题加品牌加关键属性做去重键命中则合并到主 ID并保留映射表。MERGE 越写越慢最后卡死。现象是批量写入从每秒几千条掉到几十条。原因是没建唯一约束MERGE 每次全表扫描。解决是先建Entity(id)唯一约束再写且批量大小控制在 1000 左右别一次提交十万条。多跳查询返回空但数据明明在。现象是两跳共购查询查不出结果。原因是关系方向建反了购买过是用户指向商品查询时写成商品指向用户。解决是固定方向约定并写进文档查询前先用一跳验证方向。属性关系爆炸图谱里几万个关系类型。现象是 Neo4j 浏览器里关系类型列表拉不到底。原因是把每个属性键都拼进关系名。解决是属性超过 30 个时改成统一具有属性关系加属性实体用属性名做实体 ID。问答答非所问实体链接链错。现象是问 A 商品返回 B 商品属性。原因是模糊匹配阈值太低把相似标题链错。解决是提高阈值到 60 以上加品牌和品类做二次校验链不上的走澄清反问而不是硬猜。7. 把图谱接进现有推荐链路的一个小技巧图谱推荐最尴尬的不是效果差而是和现有推荐系统抢位置。我的做法是不替换只做特征注入把图谱算出的分数当成一路特征喂给现有排序模型让模型自己决定权重。这样上线风险最小效果也能量化。具体操作是离线把每个(user, item)对的图谱特征算出来落成宽表和原有特征拼在一起训练。图谱特征至少放三个共购频次、品类路径相似度、搭配命中数。def graph_features(graph, uid, pid): feats {} # 共购频次 q1 MATCH (u:Entity {id:$uid})-[:REL {type:购买过}]-(x)-[:REL {type:购买过}]-(p:Entity {id:$pid}) RETURN count(x) AS n feats[co_buy] graph.run(q1, uiduid, pidpid).data()[0][n] # 品类路径相似度同品类为 1否则 0 q2 MATCH (u:Entity {id:$uid})-[:REL {type:购买过}]-(x)-[:REL {type:属于品类}]-(c) MATCH (p:Entity {id:$pid})-[:REL {type:属于品类}]-(c) RETURN count(c) AS n feats[same_cat] 1 if graph.run(q2, uiduid, pidpid).data()[0][n] 0 else 0 # 搭配命中数 q3 MATCH (u:Entity {id:$uid})-[:REL {type:购买过}]-(x)-[:REL {type:搭配}]-(p:Entity {id:$pid}) RETURN count(x) AS n feats[pair_hit] graph.run(q3, uiduid, pidpid).data()[0][n] return feats逻辑说明三个特征分别覆盖行为、内容、搭配三个维度都是计数型量纲接近不用额外归一化。same_cat是布尔特征模型能自己学权重。这套特征离线跑全量用户商品对成本高实际只对召回候选集算别全量铺开。验证方法很简单A/B 实验里把图谱特征组和基线组对比看 CTR 和人均 GMV。如果两周内没有显著提升先别急着扩特征回头查实体对齐和关系覆盖率八成是图谱本身质量不够不是特征没用。我自己的习惯是每加一类关系先跑一遍覆盖率统计看多少商品能连上这条关系覆盖率低于 30% 的关系先别进特征噪声大于信号。图谱这东西宁可少而准不要多而脏。希望帮到你。本文还有配套的精品资源点击获取