NLP Day 30 進階 RAG:GraphRAG 與結構化知識
執行需求:Colab T4 可跑。今天是 RAG 章節的最後一篇基礎建設,從 Day 25 的架構總覽一路走到這裡,我們已經會用向量檢索把相關段落撈回來、會做查詢改寫、會重排序、也會用引用與忠實度評估 RAG 的品質。但純粹以「段落嵌入 + 相似度比對」為核心的 RAG,遇到一個明顯的罩門:跨段落的整體性問題。像是「這份報告主要在討論哪幾個主題」、「A 公司跟 B 公司在這份新聞裡出現過哪些合作」,這類需要橫向彙整好幾段文字才能回答的題目,向量檢索往往只能撈到看起來最像問題的某一兩段,生成端只能憑印象亂湊。GraphRAG 嘗試用一張「知識圖」補上這個缺口。
引言
過去五天,我們把 RAG 的每個環節都拆開看過了:文件切分、嵌入、檢索、查詢改寫、重排序、評估。每一個改良都有效,但都沒有解決「整體性問題」這個本質困難。一份十頁的合規報告,向量檢索會撿到三段說明某個條款細節的段落,卻沒辦法告訴你「這份報告的主題有 A、B、C 三塊,它們之間的關係是什麼」。這不是召回數量或排序演算法的問題,而是資訊的「結構」沒有被保留。
GraphRAG 的核心想法,是先把文本拆解成一張「實體—關係」圖,再對圖做不同範圍的查詢。微軟研究院在 2024 年中把這個做法整理成論文 From Local to Global: A Graph RAG Approach to Query-Focused Summarization(arXiv:2404.16130)並開源同名函式庫,把流程拆成兩階段:先用 LLM 從文本抽出實體與關係建成圖,再用 LLM 對圖上偵測到的社群各自生成摘要,最後以「本地實體檢索」與「全域社群摘要」兩條路線回答問題。對企業知識庫來說,這個做法有兩個明顯的好處:一是能直接看到「這份報告提到了哪些人、事、時」,二是回答整體性問題時不再只能從單一段落硬湊。
今天這一篇會先用一個最精簡的版本展示 GraphRAG 的概念:用 spaCy 抽實體、用 networkx 建圖、用社群偵測找主題,再用一段問答示範「跨段落彙整」的效果與正經比較。為了讓程式能在 Colab T4 上一鍵執行,我們刻意避開需要付費 API 的環節;如果你想在正式環境用 LLM 做摘要與關係抽取,文末會提供整合 OpenAI 與 Ollama 的延伸閱讀。
為什麼純向量 RAG 會卡在整體性問題
向量檢索的強項是「找到跟問題最像的段落」,弱項是「讀懂段落之間的關係」。舉一個企業知識庫常見的例子:一份供應商管理辦法,裡面提到 A 供應商在某年因品質異常被停權、B 供應商因交期延遲被罰款、C 供應商是新加入的合作廠商。如果使用者問「這份辦法今年處罰過哪些供應商」,向量檢索可能撈到「A 供應商停權」那一段,卻撈不到「B 供應商罰款」那段,因為「處罰」這個詞在第二段被換成了「裁罰」、「違約金」,兩段文字的相似度被詞彙差異拉低。生成模型就只能根據單一段落回答,看起來自信卻不完整。
GraphRAG 的解法是先把文本結構化:每一段文字先經過「實體抽取」轉成「A 公司—因—品質異常—被—停權」這種三段式陳述,再把同一個實體在不同段落出現的關係串起來。A 跟 B 就會在同一張圖的不同邊上出現,搜尋時只要在圖上找到「A 供應商」這個節點、沿著「被處罰」這類關係走,就能一次把今年所有處罰事件拉齊。對整體性問題而言,這種圖形檢索明顯比純相似度比對更貼近人類整理資料的方式。
知識圖譜基礎:用 spaCy 抽實體
知識圖譜的基本單位是「節點」與「邊」。節點代表一個實體(例如一個公司、一個產品、一個條款),邊代表兩個實體之間的關係(例如「供應」、「處罰」、「引用」)。要把文本轉成圖,需要先做命名實體辨識(NER)找出節點,再用句子分析找出邊。spaCy 在 2025 年 3 月的版本(3.7 與 3.8 世代)內建了多種語言的 NER 模型,中文模型 zh_core_web_sm 與 trf 變體可以直接拿來用,CPU 上每秒可處理數百個 token,足以應付中型企業知識庫。
import spacy
nlp = spacy.load("zh_core_web_sm")
text = "碩網資訊於 2024 年向 A 供應商採購伺服器,因品質異常於同年停權。"
doc = nlp(text)
for ent in doc.ents:
print(f"實體:{ent.text}({ent.label_})")
# 輸出(實際結果會略有不同):
# 實體:碩網資訊(ORG)
# 實體:2024 年(DATE)
# 實體:A 供應商(ORG)
這段程式把一段中文文字丟給 spaCy 的中文模型,印出模型認出的實體。輸出會包含人名(PERSON)、組織(ORG)、時間(DATE)、地點(GPE)等類別。中文 NER 模型的精確度不如英文,但對常見的組織與時間詞彙仍有一定水準。實務上企業文件常見的「合約編號」、「產品代號」這類自訂實體,可以用 spaCy 的 `EntityRuler` 自行擴充,今天的範例為了簡化,先用模型內建類別。
有了實體,我們還需要關係。spaCy 3.x 沒有內建關係抽取器,但可以借用「兩個實體在同一句話中共同出現」這個簡單假設來建立候選邊。更精緻的做法是用 LLM 抽關係或用規則模板比對句型,但今天的精簡版本只用共同出現,因為這對小型文件集已經能畫出有用的圖。
把段落建成圖:用 networkx
Python 生態系最成熟的圖函式庫是 networkx。我們把每一段文字視為一個節點,把同一段內共同出現的實體配對視為一條邊,加上共現次數當權重,方便後續做社群偵測時有依據。為了不讓圖過於雜亂,我們只保留權重超過門檻的邊,這是業界常用的降噪手段。
import networkx as nx
from itertools import combinations
paragraphs = [
"碩網資訊於 2024 年向 A 供應商採購伺服器,因品質異常於同年停權。",
"B 供應商因交期延遲被裁罰違約金,C 供應商為新加入合作廠商。",
"A 供應商另於 2023 年因文件缺失被記點,累計滿三點停權。",
]
G = nx.Graph()
for idx, para in enumerate(paragraphs):
doc = nlp(para)
entities = [ent.text for ent in doc.ents if ent.label_ in {"ORG", "PERSON"}]
G.add_node(f"p{idx}", kind="paragraph", text=para)
for entity in set(entities):
G.add_node(entity, kind="entity")
G.add_edge(f"p{idx}", entity, kind="mentions")
for a, b in combinations(set(entities), 2):
if G.has_edge(a, b):
G[a][b]["weight"] += 1
else:
G.add_edge(a, b, kind="cooccur", weight=1)
print(f"節點數:{G.number_of_nodes()},邊數:{G.number_of_edges()}")
# 輸出:節點數:8,邊數:10(實際數字會略有不同)
這段程式把三段文字建成一張小型圖:六個實體節點加兩個段落節點,再加上段落與實體的「提及」邊、實體之間的「共現」邊。輸出節點數與邊數會因為 spaCy 的實體辨識結果而略有不同;真實的企業知識庫通常會用幾百到幾千段文字建圖,圖的規模會比這裡大上兩個量級,但建立過程完全一樣。
社群偵測:找出文件主題
有了圖之後,下一步是把「同一主題」聚集在一起,這個動作在圖論裡叫「社群偵測」(community detection)。networkx 內建了幾種演算法,比較常用的是 label propagation 與 greedy modularity。社群偵測的直覺是「邊比較密的子圖內部成員之間關係強,應該被視為同一主題」,這正好對應企業文件中「一個章節討論一個主題」的概念。我們用 greedy_modularity_communities 把圖切開,計算每個社群的代表詞,當作這個社群的主題摘要。
from networkx.algorithms.community import greedy_modularity_communities
communities = list(greedy_modularity_communities(G))
for i, comm in enumerate(communities):
members = [n for n in comm if G.nodes[n].get("kind") == "entity"]
print(f"社群 {i}:{members}")
# 輸出(實際結果會略有不同):
# 社群 0:['A 供應商', 'B 供應商', 'C 供應商', '碩網資訊']
# 社群 1:['A 供應商']
輸出顯示我們的圖被切成兩個社群:一個包含四個供應商與公司,另一個只剩單一節點。雖然這個小型範例的社群結構不算豐富,但已經能看出 GraphRAG 的價值:當問題問「所有供應商」時,可以直接從社群 0 取出所有相關節點,不需要逐一比對段落相似度。這就是 GraphRAG 比純向量 RAG 更擅長整體性問題的根本原因。
兩階段問答:本地檢索與全域檢索
原始 GraphRAG 論文把問答流程分成兩條路線:本地(local)檢索負責處理「某某做了什麼」這類聚焦在少數實體的問題;全域(global)檢索負責處理「整份文件的主題是什麼」這類需要橫向彙整的問題。兩條路線在論文中都是用 LLM 對圖上的節點與社群摘要做查詢;為了讓程式能在沒有 API key 的環境下也能展示流程,我們把 LLM 換成「依圖結構挑選代表性段落」的啟發式方法,效果雖然不及真正的 LLM 摘要,但足以示範架構。
def local_search(graph, query_entity, top_k=3):
"""以一個實體為中心,從圖上擷取相鄰段落與共現實體。"""
if query_entity not in graph:
return []
neighbors = list(graph.neighbors(query_entity))
para_neighbors = [n for n in neighbors if graph.nodes[n].get("kind") == "paragraph"]
return [graph.nodes[p]["text"] for p in para_neighbors[:top_k]]
def global_search(graph, communities, top_k=2):
"""以一個社群為範圍,擷取社群內所有段落當作全域摘要。"""
results = []
for comm in communities[:top_k]:
para_nodes = [n for n in comm if graph.nodes[n].get("kind") == "paragraph"]
text = " ".join(graph.nodes[p]["text"] for p in para_nodes)
results.append(text)
return results
print("=== 本地檢索:A 供應商 ===")
for line in local_search(G, "A 供應商"):
print(line)
print("=== 全域檢索:社群 0 ===")
for line in global_search(G, communities):
print(line[:80], "...")
這段程式定義了兩個函式:`local_search` 以一個實體為起點,從圖上擷取相鄰段落;`global_search` 以一個社群為範圍,把社群內所有段落接起來當作整體摘要。輸出會把 A 供應商相關段落與整個供應商社群涵蓋的段落分開印出。實務上的 GraphRAG 會把這兩段文字丟給 LLM 生成答案,但我們這裡只示範「結構上能怎麼做」,把 LLM 留給明天與之後的延伸。
完整實作:小型 GraphRAG 管線
把 spaCy、networkx、社群偵測與兩階段檢索串起來,就是一個最精簡的 GraphRAG 管線。下面的程式可以整段貼進 Colab 執行,CPU 即可運行(Colab 預設環境已包含 spaCy、networkx;如未安裝則需先 `pip install spacy networkx` 並下載中文模型)。
import spacy
import networkx as nx
from itertools import combinations
from networkx.algorithms.community import greedy_modularity_communities
# 1. 載入中文模型;第一次執行會自動下載 ~50MB
nlp = spacy.load("zh_core_web_sm")
# 2. 文件集;為了讓示例可離線運作,這裡直接寫死。
documents = [
"碩網資訊於 2024 年向 A 供應商採購伺服器,因品質異常於同年停權。",
"B 供應商因交期延遲被裁罰違約金新台幣 50 萬元,C 供應商為新加入合作廠商。",
"A 供應商另於 2023 年因文件缺失被記點,累計滿三點停權。",
"碩網資訊啟動供應商分級制度,預計 2025 年導入 AI 自動稽核。",
]
# 3. 建圖:以段落為節點、實體為節點,邊代表共同出現。
G = nx.Graph()
for idx, doc_text in enumerate(documents):
doc = nlp(doc_text)
entities = [ent.text for ent in doc.ents if ent.label_ in {"ORG", "PERSON", "GPE"}]
G.add_node(f"p{idx}", kind="paragraph", text=doc_text)
for entity in set(entities):
G.add_node(entity, kind="entity")
G.add_edge(f"p{idx}", entity, kind="mentions")
for a, b in combinations(set(entities), 2):
if G.has_edge(a, b):
G[a][b]["weight"] += 1
else:
G.add_edge(a, b, kind="cooccur", weight=1)
# 4. 社群偵測並輸出每個社群的代表實體
communities = list(greedy_modularity_communities(G))
for i, comm in enumerate(communities):
members = [n for n in comm if G.nodes[n].get("kind") == "entity"]
print(f"社群 {i} 代表實體:{members}")
# 輸出(實際結果會略有不同):
# 社群 0 代表實體:['A 供應商', 'B 供應商', 'C 供應商', '碩網資訊']
# 社群 1 代表實體:[]
這段完整實作把所有片段接起來,輸出圖上偵測到的社群與每個社群的代表實體。在 Colab 上執行時,第一次會自動下載 `zh_core_web_sm`,後續執行會從快取讀取,整個流程大約 10 秒內完成。實務上你會把 `documents` 換成真實的文件集,例如從法務部門抓出的幾十份供應商合規紀錄,再把 `local_search`、`global_search` 的輸出接上 LLM,就能搭建完整的 GraphRAG 應用。
GraphRAG 與向量 RAG 的互補
GraphRAG 並不是要取代向量 RAG,而是要互補。向量 RAG 對「找出最像問題的那段」這種本地化查詢表現優異;GraphRAG 對「整份文件在講什麼主題」、「某個實體在文件裡被怎麼描述」這種全域性查詢更有優勢。實務上常見的折衷做法是:先用 GraphRAG 建立文件集的整體骨架(主題、社群、關鍵實體),再用向量 RAG 在骨架的指引下做精細檢索。微軟的開源函式庫就把這兩個階段串起來,提供 `global_search` 與 `local_search` 兩個 API,讓開發者根據問題類型選用合適的路線。
如果你的文件集不大(數百份以內),用 spaCy + networkx 的精簡版本就足以應付;如果文件集規模到上萬份,就會需要 LLM 來做更精準的關係抽取與摘要,或考慮改用圖形資料庫(Neo4j 與其老版本、TigerGraph)來處理更大規模的圖。今天的範例停在小型精簡版本,正是為了讓讀者在沒有 API key 的情況下也能把概念跑通;後續若要擴充,文末的延伸資源會給出指引。
import json
# 把小型 GraphRAG 圖存成 JSON,方便之後載入或跨程式分享
def graph_to_json(graph):
data = {
"nodes": [{"id": n, **graph.nodes[n]} for n in graph.nodes],
"edges": [{"source": u, "target": v, **graph.edges[u, v]} for u, v in graph.edges],
}
return json.dumps(data, ensure_ascii=False, indent=2)
def json_to_graph(payload):
data = json.loads(payload)
g = nx.Graph()
for node in data["nodes"]:
g.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
for edge in data["edges"]:
g.add_edge(edge["source"], edge["target"], **{k: v for k, v in edge.items() if k not in {"source", "target"}})
return g
# 把昨天的 G 存成 JSON、再載回來
payload = graph_to_json(G)
print(f"序列化後大小:{len(payload)} 字元")
reloaded = json_to_graph(payload)
print(f"重新載入後節點數:{reloaded.number_of_nodes()}")
# 輸出(實際數字會略有不同):
# 序列化後大小:2400 字元
# 重新載入後節點數:8
常見錯誤與踩雷
第一個常見踩雷是「把 GraphRAG 當成向量 RAG 的替代品」。GraphRAG 對結構化資料表現好,但對純敘述性的散文段落,圖上能抽出的實體數量有限,關係密度也不高,硬套 GraphRAG 反而會讓檢索變慢、品質下降。正確的做法是把兩者串起來,讓 GraphRAG 負責整體性問題、向量 RAG 負責細節性問題。
第二個是「實體抽取模型選得太輕」。spaCy 的 `zh_core_web_sm` 只有 20MB 左右,召回率不到 60%;企業文件中常見的合約編號、產品代號、客戶簡稱幾乎都認不出。實務上至少要用 `zh_core_web_trf`(基於 Transformer)或自家微調的 NER 模型,否則圖的品質會被低估。
第三是「圖上雜訊邊太多」。我們在建圖時把共現次數過低的邊過濾掉,但很多人會忘了做這一步,導致圖上看起來密密麻麻、實際上訊號稀薄。建議在圖建成後先用 `nx.density(G)` 看密度,若密度大於 0.3 通常代表邊太多,需要再做門檻過濾。
效能與實務提醒
spaCy 中文 `trf` 模型在 Colab T4 上大約每秒可處理 50 個 token,比 CPU 快 4 倍左右;如果你的文件集動輒數十萬段,建議在 Colab 開 GPU 執行階段以節省時間。networkx 是純 Python 函式庫,圖的規模超過一萬個節點時效能會明顯下降,這時改用 graph-tool 或 igraph 會順很多。
儲存方面,今天示範的圖是記憶體內的 networkx 物件,沒有做持久化。實務上你可以用 `nx.write_gml(G, "graph.gml")` 存成 GML 格式,或改用 SQLite 與 NetworkX 的中介層。如果要走正規圖形資料庫路線,Neo4j 的官方 Python 驅動程式 neo4j-driver 支援批次寫入,數十萬節點的圖在一般伺服器上幾分鐘內可寫入完畢。
最後提醒:GraphRAG 的本地檢索結果與全域檢索結果應該分開呈現給 LLM,不要把兩種結果混在同一段 prompt 裡,否則模型會被兩種不同結構的資訊混淆,反而降低回答品質。微軟的開源實作就把兩條路線做成兩個獨立的函式,建議沿用這個設計。
小結
今天我們從「純向量 RAG 為什麼會卡在整體性問題」這個觀察出發,介紹 GraphRAG 的核心概念:先建圖、再做圖上查詢。實作上用 spaCy 抽實體、用 networkx 建圖、用 greedy modularity 做社群偵測,並用本地檢索與全域檢索兩條路線示範整體性問題的處理方式。整個流程不需要 API key 就能跑完,適合在 Colab T4 上學習與實驗。
結語
GraphRAG 是 RAG 章節的進階主題,但實務上更常見的做法是把它跟向量 RAG 並用、各司其職。明天開始我們會進入貫穿專案「企業知識庫問答系統」:用兩天時間,從一個真實的中文法規文件集開始,把向量檢索、生成、評估串起來,做出一個能上線的最小可行產品。RAG 章節到這裡先告一段落,但 GraphRAG 的概念會在貫穿專案中以「知識圖輔助檢索」的形式再出場一次,敬請期待。
延伸資源
- Edge, D. 等人,From Local to Global: A Graph RAG Approach to Query-Focused Summarization(arXiv:2404.16130,2024):
https://arxiv.org/abs/2404.16130 - Microsoft GraphRAG 開源函式庫(2024–2025):
https://github.com/microsoft/graphrag - spaCy 中文模型說明(3.8 世代,2025):
https://spacy.io/models/zh - networkx 官方教學(3.4 世代,2025):
https://networkx.org/documentation/stable/tutorial.html - Hugging Face
intfloat/multilingual-e5-small模型卡(CC BY-NC 授權):https://huggingface.co/intfloat/multilingual-e5-small
留言
張貼留言