简介:这是一套面向计算机相关专业学生与开发者的电商行业知识图谱实战项目,围绕实体关系构建,落地商品推荐、商品搭配与问答系统三类应用场景,适合作为毕业设计、课程设计或知识图谱入门进阶的学习素材。压缩包共59个文件、约15.49MB,以21个Python源码为核心,配合10个CSV与5个JSON数据文件、10个TXT词典、3个Markdown说明及XMind思维导图、产品文档等,覆盖数据处理、模型、控制器、服务端与前端视图等模块,结构清晰便于按层阅读。目前已有130人学习。项目代码经测试运行成功,答辩评审平均分达96分,读者可据此理解图谱搭建流程、实体关系抽取与问答逻辑,并在此基础上修改扩展功能,用于毕设、课设或项目立项演示。
1. 电商知识图谱到底解决什么问题:从「猜你喜欢」到「搭配购」的底层逻辑
做电商推荐的同学大概率遇到过这种场景:用户刚买了一个手机壳,首页立刻推同款手机壳,转化率惨不忍睹;运营想上「搭配购」,让买咖啡机的人顺手带走滤纸和咖啡豆,结果推荐系统给出的却是另一台咖啡机。这类问题的根子不在模型,而在数据层——商品、品类、属性、场景之间的关系没有被显式建模,模型只能靠协同过滤的共现矩阵硬猜。电商行业知识图谱要干的事,就是把这些关系用「实体—关系—实体」的三元组固定下来,让推荐、搭配、问答三个下游任务共享同一套语义底座。这篇笔记我会按「本体怎么设计 → 数据怎么抽 → Neo4j 怎么存 → 推荐/搭配/问答怎么接」的顺序,把一套能跑起来的 Python 方案讲透,适合已经会写 Python、想把手头电商数据用起来的工程师,也适合刚接触知识图谱构建、想找一个完整落地案例的入门者。
2. 电商本体建模:先想清楚实体和关系再动手写代码
知识图谱构建翻车最常见的原因,不是代码写错,而是本体没设计好就开始灌数据。本体(Ontology)说白了就是「这个领域里有哪些类型的实体、它们之间允许有哪些类型的关系」的约定。电商场景看着简单,实际上实体类型和关系类型一旦定歪,后面抽取、存储、查询全要返工。
2.1 电商场景的实体类型与关系类型清单
我一般会把电商知识图谱的实体分成六类,这个划分覆盖了绝大多数推荐和问答需求:
| 实体类型 | 典型属性 | 举例 |
|---|---|---|
| 商品 Product | 商品ID、标题、价格、销量 | iPhone 15 128G 黑色 |
| 品类 Category | 品类ID、层级、名称 | 手机 > 智能手机 |
| 品牌 Brand | 品牌ID、名称、国别 | 某国产品牌 |
| 属性 Attribute | 属性名、属性值 | 颜色=黑色、内存=128G |
| 场景 Scene | 场景名 | 通勤、露营、办公 |
| 用户 User | 用户ID、偏好标签 | 某用户 |
关系类型同样要克制,不要一上来搞几十种。核心的就这几条:BELONGS_TO(商品属于品类)、PRODUCED_BY(商品由品牌生产)、HAS_ATTRIBUTE(商品具有属性)、SUITABLE_FOR(商品适合场景)、COMPATIBLE_WITH(商品可与商品搭配)、SIMILAR_TO(商品相似)、PURCHASED(用户购买过)。搭配购靠COMPATIBLE_WITH,推荐靠SIMILAR_TO和PURCHASED,问答靠前面几条做路径查询。
提示:本体设计阶段一定要拉上运营或品类同学过一遍,他们嘴里的「这个和那个能一起卖」就是
COMPATIBLE_WITH的原始来源,比算法猜的准得多。
2.2 用 Python 定义本体并落成配置文件
本体不要硬编码在代码里,写成 YAML 或 JSON,后面抽取规则、Neo4j 约束都能复用。下面是一个最小可用的本体定义:
# ontology.py # 电商知识图谱本体定义:实体类型 + 关系类型 + 约束 ONTOLOGY = { "entities": { "Product": {"key": "product_id", "props": ["title", "price", "sales"]}, "Category": {"key": "category_id", "props": ["name", "level"]}, "Brand": {"key": "brand_id", "props": ["name", "country"]}, "Attribute": {"key": "attr_id", "props": ["name", "value"]}, "Scene": {"key": "scene_id", "props": ["name"]}, "User": {"key": "user_id", "props": ["tags"]}, }, "relations": { "BELONGS_TO": ("Product", "Category"), "PRODUCED_BY": ("Product", "Brand"), "HAS_ATTRIBUTE": ("Product", "Attribute"), "SUITABLE_FOR": ("Product", "Scene"), "COMPATIBLE_WITH": ("Product", "Product"), "SIMILAR_TO": ("Product", "Product"), "PURCHASED": ("User", "Product"), }, } def validate_triple(head_type, rel, tail_type): """校验一条三元组是否符合本体约束,抽取阶段用它挡脏数据""" if rel not in ONTOLOGY["relations"]: return False expect_head, expect_tail = ONTOLOGY["relations"][rel] return head_type == expect_head and tail_type == expect_tail这段代码的关键在validate_triple:抽取流水线每产出一条三元组,先过一遍本体校验,类型对不上直接丢弃。参数上key字段是实体的唯一标识,后面 Neo4j 建唯一约束要用它;props是允许挂载的属性,多出来的字段在入库时会被忽略,避免 schema 漂移。
2.3 本体粒度怎么定:三个判断标准
粒度太细,图谱稀疏,查询走不通;粒度太粗,推荐区分度不够。我的判断标准是:第一,看下游任务,如果只做搭配购,Attribute可以只保留颜色、尺寸这类影响搭配的属性;第二,看数据可得性,抽不出来的关系别硬设计;第三,看查询路径长度,从商品到场景超过三跳的路径,实际查询性能会明显下降,该合并的实体就合并。这三条定下来,本体基本不会大改。
3. 从原始商品数据到三元组:抽取流水线怎么写
本体定完,接下来是把电商平台导出的商品表、订单表、评价文本变成三元组。这一步是整个项目最脏最累的环节,也是决定图谱质量的地方。我一般把流水线拆成结构化抽取、文本抽取、关系补全三段,每段独立可测。
3.1 结构化数据抽取:商品表和订单表怎么转三元组
商品表里category_id、brand_id这些外键天然就是关系,直接映射即可。订单表则用来生成PURCHASED和挖掘COMPATIBLE_WITH。
# extract_structured.py import pandas as pd from ontology import validate_triple def extract_from_products(df: pd.DataFrame): """商品表 -> 三元组列表""" triples = [] for _, row in df.iterrows(): pid = f"P{row['product_id']}" # 商品 -> 品类 triples.append((pid, "Product", "BELONGS_TO", f"C{row['category_id']}", "Category")) # 商品 -> 品牌 triples.append((pid, "Product", "PRODUCED_BY", f"B{row['brand_id']}", "Brand")) # 商品 -> 属性(颜色、内存等拆成独立属性实体) for attr in ["color", "memory", "size"]: if pd.notna(row.get(attr)): aid = f"A{row['product_id']}_{attr}" triples.append((pid, "Product", "HAS_ATTRIBUTE", aid, "Attribute")) # 本体校验,挡掉类型不匹配的脏三元组 return [t for t in triples if validate_triple(t[1], t[2], t[4])] def mine_compatible(order_df: pd.DataFrame, min_support=50, min_conf=0.3): """从订单共现挖掘搭配关系,support 和 confidence 双阈值过滤""" from itertools import combinations from collections import defaultdict pair_cnt, item_cnt = defaultdict(int), defaultdict(int) for _, grp in order_df.groupby("order_id"): items = list(set(grp["product_id"])) for it in items: item_cnt[it] += 1 for a, b in combinations(sorted(items), 2): pair_cnt[(a, b)] += 1 triples = [] for (a, b), c in pair_cnt.items(): support = c confidence = c / item_cnt[a] if support >= min_support and confidence >= min_conf: triples.append((f"P{a}", "Product", "COMPATIBLE_WITH", f"P{b}", "Product")) return triplesmine_compatible里的两个参数是搭配购质量的关键:min_support控制最少共现次数,太小会挖出「牙膏+螺丝刀」这种噪声;min_conf控制条件概率,0.3 意味着买 A 的人里至少 30% 也买了 B。实际调参时我一般先跑一遍看分布,support 取分位数 90% 左右,confidence 从 0.2 起步往上试。
3.2 文本抽取:从标题和评价里抽属性与场景
商品标题里藏着大量属性,比如「iPhone 15 128G 黑色 5G手机」,用规则加词典就能抽得不错,不必上大模型。评价文本则用来抽场景,比如「带去露营很方便」对应SUITABLE_FOR露营场景。
# extract_text.py import re from collections import defaultdict # 属性词典:实际项目从品类运营那拿,这里给最小示例 ATTR_DICT = { "color": ["黑色", "白色", "蓝色", "红色"], "memory": ["64G", "128G", "256G", "512G"], } SCENE_DICT = { "露营": ["露营", "户外", "野营"], "通勤": ["通勤", "上班", "地铁"], "办公": ["办公", "办公室", "工位"], } def extract_attrs_from_title(title: str, pid: str): triples = [] for attr_name, words in ATTR_DICT.items(): for w in words: if w in title: aid = f"A{pid}_{attr_name}_{w}" triples.append((f"P{pid}", "Product", "HAS_ATTRIBUTE", aid, "Attribute")) break return triples def extract_scene_from_reviews(reviews: list, pid: str, min_hit=3): """评价里场景词命中次数超过阈值才建边,避免单条噪声""" hit = defaultdict(int) for r in reviews: for scene, words in SCENE_DICT.items(): if any(w in r for w in words): hit[scene] += 1 return [(f"P{pid}", "Product", "SUITABLE_FOR", f"S{scene}", "Scene") for scene, c in hit.items() if c >= min_hit]min_hit这个阈值是血泪经验:评价里偶尔出现一次「露营」不代表商品适合露营,可能是用户吐槽「本来想露营用结果不行」。设成 3 以上能过滤掉大部分误抽,代价是长尾商品场景边会少,可以按品类单独调。
3.3 关系补全:用商品向量补 SIMILAR_TO
SIMILAR_TO靠共现挖不全,长尾商品几乎没有共现。常见做法是用商品标题和属性的 embedding 算相似度,超过阈值就连边。
# build_similar.py import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def build_similar_edges(products: list, threshold=0.75, topk=10): """products: [{'pid':..., 'text':...}]""" corpus = [p["text"] for p in products] vec = TfidfVectorizer(max_features=5000, ngram_range=(1, 2)) X = vec.fit_transform(corpus) sim = cosine_similarity(X) edges = [] for i in range(len(products)): # 取 topk 且超过阈值的邻居 idx = np.argsort(-sim[i])[1:topk + 1] for j in idx: if sim[i][j] >= threshold: edges.append((f"P{products[i]['pid']}", "Product", "SIMILAR_TO", f"P{products[j]['pid']}", "Product")) return edgesngram_range=(1,2)是为了同时捕捉单词和双词短语,threshold设 0.75 是经验值,太低会把同品类不同档次的商品连起来,推荐时容易「降级」。如果商品量大,TF-IDF 换成句向量模型效果更好,但要注意算力成本。
4. 用 Neo4j 存图谱:约束、批量导入与查询模板
三元组抽完,得有个地方存。Neo4j 是知识图谱落地最常用的图数据库,Cypher 查询写起来直观,Python 通过官方驱动就能操作。这一章讲建库、导入和三个下游任务要用的查询模板。
4.1 建唯一约束和索引:导入前必做
导入前不建约束,重复导入会产生大量重复节点,后面查询结果翻倍,排查起来非常痛苦。
# neo4j_setup.py from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "your_password")) CONSTRAINTS = [ "CREATE CONSTRAINT product_id IF NOT EXISTS FOR (p:Product) REQUIRE p.pid IS UNIQUE", "CREATE CONSTRAINT category_id IF NOT EXISTS FOR (c:Category) REQUIRE c.cid IS UNIQUE", "CREATE CONSTRAINT brand_id IF NOT EXISTS FOR (b:Brand) REQUIRE b.bid IS UNIQUE", "CREATE CONSTRAINT attr_id IF NOT EXISTS FOR (a:Attribute) REQUIRE a.aid IS UNIQUE", "CREATE CONSTRAINT scene_id IF NOT EXISTS FOR (s:Scene) REQUIRE s.sid IS UNIQUE", ] def init_db(): with driver.session() as s: for c in CONSTRAINTS: s.run(c) if __name__ == "__main__": init_db()每个实体类型的唯一键都要建约束,Neo4j 会自动为唯一约束建索引,MATCH时按 ID 查能走索引,速度差一个数量级。密码别写死在代码里,用环境变量读。
4.2 批量导入三元组:UNWIND 比逐条 MERGE 快几十倍
逐条MERGE导入十万条三元组能跑到天荒地老,正确姿势是用UNWIND批量提交。
# load_triples.py from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "your_password")) # 关系类型 -> Cypher 模板,节点标签按本体映射 REL_TEMPLATE = { "BELONGS_TO": ("Product", "pid", "Category", "cid"), "PRODUCED_BY": ("Product", "pid", "Brand", "bid"), "HAS_ATTRIBUTE": ("Product", "pid", "Attribute", "aid"), "SUITABLE_FOR": ("Product", "pid", "Scene", "sid"), "COMPATIBLE_WITH": ("Product", "pid", "Product", "pid"), "SIMILAR_TO": ("Product", "pid", "Product", "pid"), } def load_batch(triples, batch_size=5000): with driver.session() as s: for i in range(0, len(triples), batch_size): batch = triples[i:i + batch_size] # 按关系类型分组,每组一条 UNWIND 语句 grouped = {} for h, ht, rel, t, tt in batch: grouped.setdefault(rel, []).append({"h": h, "t": t}) for rel, rows in grouped.items(): hl, hk, tl, tk = REL_TEMPLATE[rel] cypher = f""" UNWIND $rows AS row MERGE (a:{hl} {{{hk}: row.h}}) MERGE (b:{tl} {{{tk}: row.t}}) MERGE (a)-[:{rel}]->(b) """ s.run(cypher, rows=rows)batch_size设 5000 是吞吐和内存的折中,太大单条事务内存吃紧,太小网络往返开销高。MERGE保证幂等,重复跑不会产生重复边,这点在增量更新时很重要。
4.3 三个下游任务的 Cypher 查询模板
图谱建好,下游任务就是写查询。推荐、搭配、问答各有一个核心模板:
// 模板1:相似商品推荐——给定商品,找 SIMILAR_TO 邻居 MATCH (p:Product {pid: $pid})-[:SIMILAR_TO]->(rec:Product) RETURN rec.pid AS pid, rec.title AS title ORDER BY rec.sales DESC LIMIT 10; // 模板2:搭配购——找 COMPATIBLE_WITH 且同场景的商品 MATCH (p:Product {pid: $pid})-[:COMPATIBLE_WITH]->(c:Product) OPTIONAL MATCH (p)-[:SUITABLE_FOR]->(s:Scene)<-[:SUITABLE_FOR]-(c) RETURN c.pid AS pid, c.title AS title, collect(s.name) AS shared_scenes ORDER BY size(shared_scenes) DESC LIMIT 10; // 模板3:问答——查某品类下某品牌的所有商品 MATCH (c:Category {name: $category})<-[:BELONGS_TO]-(p:Product) -[:PRODUCED_BY]->(b:Brand {name: $brand}) RETURN p.pid AS pid, p.title AS title, p.price AS price;模板 2 里的OPTIONAL MATCH是关键,共享场景作为排序信号,没有共享场景的搭配也不会被过滤掉。模板 3 是问答系统的基础,用户问「某品牌有哪些手机」,解析出品类和品牌两个槽位,直接查这条路径。
5. 避坑与排查:知识图谱落地最容易翻车的五个地方
这一章是我踩过的坑合集,每条按「现象 → 原因 → 解决」写,照着排查能省不少时间。
现象一:导入后查询结果重复。原因是没有建唯一约束,或者MERGE时用的键和约束键不一致。解决:先跑init_db()建约束,再检查REL_TEMPLATE里的键名和约束里的属性名是否完全一致,大小写都算。
现象二:搭配购推荐出完全不相关的商品。原因是COMPATIBLE_WITH挖掘阈值太低,或者订单数据里混入了凑单商品。解决:提高min_support和min_conf,同时把价格低于某阈值的凑单品从订单里剔除再挖掘。
现象三:问答系统答非所问。原因是实体链接没做好,用户说的「苹果」可能指品牌也可能指水果。解决:在实体链接层加品类上下文,问句里出现「手机」时优先链接到品牌实体,同时给品牌实体加别名属性。
现象四:图谱越建越大,查询越来越慢。原因是SIMILAR_TO边太多,每个商品连了几十个邻居。解决:topk从 10 降到 5,threshold提到 0.8,或者对SIMILAR_TO边加时间衰减,只保留最近计算的。
现象五:增量更新后旧关系还在。原因是只做了MERGE没做删除,商品下架后关系还挂在图上。解决:给边加updated_at属性,定期跑清理任务删除超过 N 天未更新的边,或者用商品状态字段做软删除。
注意:排查图数据库问题时,先用
EXPLAIN看查询计划,确认有没有走索引。全表扫描在十万节点级别就会明显卡顿。
6. 把图谱接进推荐和问答:一个可验证的进阶技巧
图谱建完不是终点,得验证它真的有用。我一般用一个简单的 A/B 对比来验证:同一批用户,对照组用协同过滤推荐,实验组用图谱查询结果和协同过滤结果做融合,看点击率变化。融合策略上,图谱结果负责「可解释的推荐」,比如「因为你买了咖啡机,推荐搭配滤纸」,协同过滤负责「猜你喜欢」,两者按权重相加,图谱权重从 0.3 起步调。
具体验证代码可以这样写:
# ab_test.py def hybrid_recommend(pid, user_id, alpha=0.3, topn=10): """alpha 控制图谱结果权重,0 为纯协同过滤,1 为纯图谱""" graph_recs = query_graph_similar(pid, topn) # 走 Cypher 模板1 cf_recs = query_cf(user_id, topn) # 原有协同过滤 scores = {} for i, r in enumerate(graph_recs): scores[r] = scores.get(r, 0) + alpha * (1 / (i + 1)) for i, r in enumerate(cf_recs): scores[r] = scores.get(r, 0) + (1 - alpha) * (1 / (i + 1)) return sorted(scores, key=scores.get, reverse=True)[:topn]alpha是唯一需要调的参数,建议做网格搜索,0.1 到 0.5 之间步长 0.1。验证指标除了点击率,还要看推荐结果的品类多样性,图谱融合后多样性通常会提升,这是它相对协同过滤的核心优势。
问答系统这边,进阶技巧是把 Cypher 查询模板和意图识别解耦:意图识别只负责把问句映射到模板 ID 和槽位,模板负责查询。这样新增一种问法只需要加模板,不用动模型。我一般会维护一个模板注册表,每个模板带槽位定义和示例问句,意图识别用少量标注数据微调即可。
最后说个我自己的习惯:图谱项目一定要先跑通「一个品类」的闭环,比如只做手机品类,从抽取到推荐全链路验证,再横向扩品类。一上来全品类铺开,本体和抽取规则的问题会被数据量掩盖,等到发现时返工成本极高。希望帮到你。
本文还有配套的精品资源,点击获取