Neo4j + LLM:构建一个可查询的知识图谱

周一早上九点,老李的保温杯还没拧开,供应链总监老赵就冲进了会议室。

“钛合金供应商刚通知我们,下个月起断供。我现在必须知道——公司所有产品里,哪些用到了钛合金?这些产品占营收多少?有没有替代供应商?”

老李立刻打开数据库客户端,开始写 SQL。

“产品表 JOIN 物料清单表,JOIN 物料表,再 JOIN 供应商表……”他嘴里念叨着,手指在键盘上翻飞。五分钟后,SQL 写出来了,执行——超时。加索引,再跑——又是超时。

“老赵,这东西要 JOIN 七张表,数据量上千万行,一时半会儿出不来。”老李额头冒汗。

小王在旁边轻声说了句:“老李,我来吧。”

他打开笔记本,敲了一条 Cypher 查询,不到一秒,屏幕上弹出一个清晰的关系图——五个受影响的最终产品,每条路径都标注了替代供应商。

老李盯着那个像蜘蛛网一样的图,保温杯停在半空:“你这是用的什么?”

“Neo4j。图数据库。”小王把屏幕转过来,“你刚才用 SQL JOIN 十几张表的思路,在图数据库里就是顺着边往前走几步的事。”


为什么关系型数据库在这里“跪了”

“老李你想啊,SQL 里‘查找所有用钛合金的产品’,需要做多次 JOIN。表越大,JOIN 代价越高。因为关系型数据库的思路是‘把数据锁在表格里,查的时候临时拼关系’。每多一跳关系,就多一次 JOIN,性能指数级下降。”

小王在白板上画了两个图:“图数据库不一样。它存数据的时候,就直接把关系存成‘边’。查关系就是沿着边往前走,不需要 JOIN。一跳、两跳、三跳——代价几乎线性增长,而不是指数爆炸。”

图:关系型靠临时 JOIN,图数据库靠预先存储的关系边

“而且,供应链问题不是‘查一个点’就完了,它是‘沿着关系链扩散’。”小王点开 Neo4j 的浏览器界面,画了几个节点和箭头,“你看,查到产品 A 受影响后,你自然想知道——产品 A 的客户是谁?哪个客户最大?替代物料有没有库存?这每一步都是一次关系遍历。SQL 要重新写查询,图数据库只需在刚才结果的基础上继续匹配路径。”

老李沉默地看着那张不断扩展的关系网,轻轻叹了口气。


从零开始用 Neo4j 搭一个供应链图谱

“别光演示,这东西怎么搭起来的?”老李恢复了冷静。

小王打开终端,先启动了 Neo4j 容器,然后用 Python 连接,开始建节点和边。

python
from neo4j import GraphDatabase

# 连接本地 Neo4j
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))

def create_graph(tx):
    # 创建产品、物料、供应商节点
    tx.run("MERGE (p:Product {name: '航空涡轮叶片'})")
    tx.run("MERGE (m:Material {name: '钛合金'})")
    tx.run("MERGE (s:Supplier {name: '西部钛业'})")
    tx.run("MERGE (alt:Supplier {name: '宝钛集团'})")
    # 创建关系
    tx.run("""
        MATCH (p:Product {name:'航空涡轮叶片'}), (m:Material {name:'钛合金'})
        MERGE (p)-[:REQUIRES]->(m)
    """)
    tx.run("""
        MATCH (m:Material {name:'钛合金'}), (s:Supplier {name:'西部钛业'})
        MERGE (s)-[:SUPPLIES]->(m)
    """)
    # 替代供应商
    tx.run("""
        MATCH (m:Material {name:'钛合金'}), (alt:Supplier {name:'宝钛集团'})
        MERGE (alt)-[:CAN_SUPPLY {status:'待评估'}]->(m)
    """)

with driver.session() as session:
    session.execute_write(create_graph)

“现在,回答老赵的那个问题——钛合金断供影响哪些产品?”

python
def query_impact(driver, material_name):
    with driver.session() as session:
        result = session.run("""
            MATCH (m:Material {name: $name})<-[:REQUIRES]-p:Product
            OPTIONAL MATCH (m)<-[:CAN_SUPPLY]-alt:Supplier
            RETURN p.name AS product, collect(DISTINCT alt.name) AS alternatives
        """, name=material_name)
        return list(result)

print(query_impact(driver, "钛合金"))
# 输出:[{"product": "航空涡轮叶片", "alternatives": ["宝钛集团"]}]

“看到了吗?不需要 JOIN,不需要子查询,一条语句沿着边就把关系走完了。”

老李盯着代码,手指下意识在桌上画着那个箭头:“MERGE 是建关系,MATCH 是查关系……这不就是把业务里的‘谁属于谁’直接画出来吗?”


用大模型自动建图:从文本到三元组

“你刚才那十几个物料和产品,是自己一条条手工写进去的吧?那几千个物料怎么办?”老李突然警觉。

“当然不能手工。我们用 LLM 自动从文档里抽三元组,再灌进图里。”小王展示了一段抽取脚本。

python
def extract_triples_from_doc(doc_text):
    prompt = f"""
    从以下文本中提取实体和关系,返回 JSON 数组,每个元素格式:
    {{"head": "实体1", "relation": "关系", "tail": "实体2"}}
    
    文本:{doc_text}
    
    例如:
    "航空涡轮叶片使用钛合金作为主要材料,西部钛业是主要供应商,宝钛集团可替代。"
    输出:
    [
      {{"head":"航空涡轮叶片","relation":"REQUIRES","tail":"钛合金"}},
      {{"head":"西部钛业","relation":"SUPPLIES","tail":"钛合金"}},
      {{"head":"宝钛集团","relation":"CAN_SUPPLY","tail":"钛合金"}}
    ]
    """
    response = llm.generate(prompt)
    triples = json.loads(response)
    # 批量写入 Neo4j
    for t in triples:
        write_triple(t["head"], t["relation"], t["tail"])
    return triples

“只要把物料清单、采购合同、供应商评估报告丢进去,模型自动识别实体,抽关系。人工校对一下类型,就入库了。”

老李若有所思:“那查询的时候呢?总不能每次都写 Cypher 吧?我想让供应链部门自己查。”


Text2Cypher:用自然语言查询知识图谱

小王笑了:“这就是 Text2Cypher——用户说人话,LLM 翻译成 Cypher 去查图。”

他打开一个测试页面,输入:“钛合金断供,影响哪些产品?谁可以替代?”

几秒后,系统返回了产品和替代供应商列表。控制台打印了 LLM 生成的 Cypher:

python
# Text2Cypher 示意
def text_to_cypher(question: str) -> str:
    prompt = f"""
    将以下自然语言问题转换成 Neo4j Cypher 查询。
    图谱中的节点类型:Product, Material, Supplier
    关系类型:REQUIRES, SUPPLIES, CAN_SUPPLY
    
    问题:{question}
    
    只需返回 Cypher 语句,不要解释。
    """
    return llm.generate(prompt)

question = "钛合金断供影响哪些产品?"
cypher = text_to_cypher(question)
print(cypher)
# 输出: MATCH (m:Material {name:'钛合金'})<-[:REQUIRES]-p:Product
#        OPTIONAL MATCH (m)<-[:CAN_SUPPLY]-alt:Supplier
#        RETURN p.name AS product, collect(alt.name) AS alternatives

“只要图模型定义清楚,LLM 翻译 Cypher 的准确率相当高。而且你可以限制它只能执行只读查询,避免删库跑路。”

老李靠回椅背,拧开保温杯,终于喝了一口。枸杞已经凉了,但他似乎没察觉。

“那这张图还能干什么?除了查影响,能不能自己推理出我不知道的事?”


知识推理:让图告诉你不知道的事

“当然能。比如你想知道——哪个供应商的断供风险最大?”小王又跑了一条查询。

“我们可以在图里加权重。比如一个物料被越多产品依赖,它的断供风险就越大。一个供应商如果同时供应多种高风险物料,那它就是个‘关键供应商’。”

python
# 找出关键供应商:供应物料种类>=2 且其中至少1种被3个以上产品依赖
with driver.session() as session:
    result = session.run("""
        MATCH (s:Supplier)-[:SUPPLIES]->(m:Material)<-[:REQUIRES]-p:Product
        WITH s, m, COUNT(p) AS product_count
        WHERE product_count >= 3
        WITH s, COUNT(m) AS critical_material_count
        WHERE critical_material_count >= 2
        RETURN s.name AS critical_supplier, critical_material_count
    """)
    print(list(result))

“这种多跳推理,SQL 写起来能绕死人,而图查询就像顺着线索走路——不仅快,而且天然直观。”

老李沉默了。他看着屏幕上那张不断延伸的关系网,像看一张自己公司的 X 光片。半晌,他说:“如果我要加一个‘替代物料库存’的信息,是不是再加个节点,连上就行?”

“对。图数据库最灵活的地方——它不需要预定义 schema。新的节点和关系随时加,不影响已有结构。”


图查询也会慢:性能陷阱和优化

“等会儿,”老李突然问,“图遍历是快,但如果关系特别密,会不会也慢?就像你顺着认识的人找人,结果每个人都认识几百个人,一层层扩散下去,最后满世界都是候选。”

小王点头:“会。这叫‘超级节点’问题。比如一个物料被几百种产品依赖,你从它出发查关联,瞬间就要遍历几百条边。优化方法也简单——限制遍历深度,或者用关系属性过滤。还可以建立索引,加速节点查找。”

“另外,图查询不能无限递归。你可以加 [*1..3] 限制最多跳数。实际业务里,超过三跳的关系基本跟问题没太大关联了。”

老李在小本本上写下几个字:“超级节点,限制深度。”


散会后,老李把小王留下来。

“这东西,维护成本高吗?”

“Neo4j Community Edition 对中小规模基本够用。日常维护主要是定期清理无用节点、更新索引、备份。跟关系型库差不多。”

老李点点头,站起来走了两步,又回头:“那什么,你说的 Text2Cypher,LLM 翻译错的概率高不高?如果用户问的是模糊问题,比如‘哪些产品可能有风险’,它怎么翻译?你给我看看那个 prompt 是怎么限定语法的。”

小王笑了,打开一个调试日志,把 prompt 模板展示出来。老李凑近屏幕,保温杯搁在桌上,枸杞晃晃悠悠,像图谱里那些被箭头连接起来的节点,终于不再孤立。

多模态知识库:文字、图片、表格、代码,一个都不能少
成本骤降90%!Agent从“奢侈品”变身“日用品”,AI普及时代加速到来