Skip to content

Graph RAG

This content is not available in your language yet.

Sau bài này, bạn sẽ:

  • Hiểu vì sao câu hỏi cần nối nhiều sự kiện làm khó RAG vector, và đồ thị tri thức (knowledge graph) giúp ở chỗ nào.
  • Biểu diễn được tri thức bằng bộ ba (triple) chủ thể – quan hệ – đối tượng, và viết được prompt nhờ LLM trích bộ ba ra JSON có kiểm tra.
  • Lưu được bộ ba vào Neo4j và viết được truy vấn Cypher một bước, hai bước, và độ dài thay đổi {1,3}.
  • Nhận ra ý tưởng của GraphRAG (Microsoft): tóm tắt theo cộng đồng cho câu hỏi về cả kho, và biết khi nào RAG vector vẫn là lựa chọn tốt hơn.

Bạn làm chatbot cho một trang tin khởi nghiệp. Kho có vài nghìn bài báo ngắn. Ba đoạn trong đó:

  • “Chị Lan, cựu sinh viên ĐH Bách khoa, sáng lập Mây Xanh năm 2021.”
  • “Quỹ Sao Mai vừa rót vốn vào Mây Xanh, công ty có trụ sở tại Đà Nẵng.”
  • “Lúa Vàng ở Cần Thơ, do anh Minh sáng lập, công bố nhận vốn từ Quỹ Sao Mai.”

Câu hỏi “Ai sáng lập Mây Xanh?” thì dễ. Đoạn thứ nhất giống câu hỏi, RAG vector lấy nó ở top-1, xong.

Còn câu “Những công ty nào có chung nhà đầu tư với Mây Xanh?” thì khác. Câu trả lời là Lúa Vàng, nằm ở đoạn thứ ba. Nhưng đoạn thứ ba không nhắc tới Mây Xanh chữ nào. Muốn trả lời, phải đi hai bước: từ Mây Xanh tới Quỹ Sao Mai (đoạn hai), rồi từ Quỹ Sao Mai tới Lúa Vàng (đoạn ba). RAG vector chấm từng đoạn riêng lẻ theo độ giống câu hỏi, nên đoạn ba dễ bị xếp thấp, và mô hình chỉ thấy một nửa câu chuyện.

Hãy nghĩ tới bảng điều tra trong phim trinh thám. Thám tử ghim ảnh người, công ty, địa điểm lên bảng, rồi căng dây đỏ giữa chúng, mỗi sợi có một nhãn: “sáng lập”, “rót vốn”, “trụ sở ở”. Câu hỏi khó lúc này thành việc lần theo dây: từ ảnh Mây Xanh, theo dây “rót vốn” ngược về Quỹ Sao Mai, rồi theo một sợi “rót vốn” khác sang Lúa Vàng. Không cần đoạn văn nào nhắc cả hai công ty cùng lúc.

Graph RAG dựng chính tấm bảng đó từ văn bản, với LLM làm người đọc hồ sơ và căng dây giúp bạn. Bài toán của bài: biến kho văn bản thành một đồ thị gồm thực thể và quan hệ, rồi trả lời câu hỏi bằng cách đi theo các cạnh của đồ thị, nhiều bước khi cần, thay vì chỉ tìm đoạn văn giống câu hỏi.

Đồ thị tri thức: thực thể, quan hệ, bộ ba

Phần tiêu đề “Đồ thị tri thức: thực thể, quan hệ, bộ ba”

Chữ “đồ thị” ở đây là graph: một mạng gồm nút (node) và cạnh (edge), không phải biểu đồ (chart) vẽ số liệu.

  • Mỗi nút là một thực thể (entity): người, công ty, quỹ, thành phố.
  • Mỗi cạnh là một quan hệ (relation) giữa hai thực thể. Cạnh có hướng và có nhãn: “Lan sáng lập Mây Xanh” khác “Mây Xanh sáng lập Lan”.

Một sự kiện được ghi thành một bộ ba (s,r,o)(s, r, o): ss là chủ thể (subject), rr là quan hệ (predicate), oo là đối tượng (object). Ví dụ (Lan, FOUNDED, Mây Xanh) đọc là “Lan sáng lập Mây Xanh”, cạnh đi từ Lan tới Mây Xanh. Cả đồ thị tri thức chỉ là một tập bộ ba. Từ ba đoạn văn ở phần Trực giác, cộng thêm vài câu về anh Minh, ta được 8 bộ ba dùng suốt bài:

Chủ thể ssQuan hệ rrĐối tượng ooĐọc là
LanFOUNDEDMây XanhLan sáng lập Mây Xanh
MinhFOUNDEDLúa VàngMinh sáng lập Lúa Vàng
Quỹ Sao MaiINVESTED_INMây XanhQuỹ Sao Mai đầu tư vào Mây Xanh
Quỹ Sao MaiINVESTED_INLúa VàngQuỹ Sao Mai đầu tư vào Lúa Vàng
LanSTUDIED_ATĐH Bách khoaLan học ở ĐH Bách khoa
MinhSTUDIED_ATĐH Bách khoaMinh học ở ĐH Bách khoa
Mây XanhBASED_INĐà NẵngMây Xanh có trụ sở ở Đà Nẵng
Lúa VàngBASED_INCần ThơLúa Vàng có trụ sở ở Cần Thơ

Điều quan trọng nhất: hai bộ ba chung một thực thể thì tự nối với nhau. Bộ ba thứ ba và thứ tư lấy từ hai bài báo khác nhau, nhưng cùng có nút Quỹ Sao Mai, nên trong đồ thị chúng thành một con đường Mây Xanh – Quỹ Sao Mai – Lúa Vàng. Tri thức rải rác ở nhiều đoạn được ghép lại qua nút chung.

Tên thực thể giữ tiếng Việt. Tên quan hệ viết tiếng Anh, chữ hoa, nối bằng gạch dưới, theo thói quen của Cypher (ngôn ngữ truy vấn ở phần sau). Tên chỉ gồm chữ, số và gạch dưới (không có khoảng trắng) thì không phải bọc trong dấu nháy ngược; bài dùng tiếng Anh chữ hoa vì đó là quy ước quen thuộc của Cypher. Danh sách các loại quan hệ (và loại thực thể) được phép gọi là lược đồ (schema). Có lược đồ thì cùng một ý luôn được ghi bằng cùng một nhãn, thay vì lúc FOUNDED, lúc CO_FOUNDER_OF.

Ai viết ra 8 bộ ba kia? Với vài nghìn bài báo, ta nhờ LLM. Quy trình giống bước lập chỉ mục của RAG: chia văn bản thành chunk, rồi với mỗi chunk gọi mô hình chat một lần. Prompt dùng lại ba ý của bài Prompt Engineering:

  1. Chỉ dẫn rõ, kèm lược đồ: chỉ được dùng các quan hệ FOUNDED, INVESTED_IN, STUDIED_AT, BASED_IN; chỉ ghi điều văn bản nói rõ, không suy đoán.
  2. Một ví dụ mẫu (few-shot): một câu ngắn và đúng JSON mong muốn cho câu đó.
  3. Định dạng đầu ra: chỉ một đối tượng JSON, có thể bật thêm JSON mode như bài Sử dụng API văn bản.

Với đoạn “Quỹ Sao Mai vừa rót vốn vào Mây Xanh, công ty có trụ sở tại Đà Nẵng.”, đầu ra mong muốn là:

{
"triples": [
{"subject": "Quỹ Sao Mai", "relation": "INVESTED_IN", "object": "Mây Xanh"},
{"subject": "Mây Xanh", "relation": "BASED_IN", "object": "Đà Nẵng"}
]
}

Đầu ra của mô hình vẫn chỉ là chữ, nên chương trình phải kiểm trước khi lưu: JSON có đọc được không, quan hệ có nằm trong lược đồ không, tên có rỗng không. Bộ ba sai lược đồ thì bỏ, hoặc gửi lại kèm lời nhắc. Nên lưu kèm mỗi bộ ba mã chunk nguồn của nó: khi nghi ngờ, bạn mở lại đoạn gốc, và chatbot trích được nguồn như ở bài RAG.

Gộp thực thể (entity resolution) là bước khó nhất khi làm thật. “Mây Xanh”, “công ty Mây Xanh”, “Cty Mây Xanh” là một công ty, nhưng nếu lưu thành ba nút thì đồ thị đứt: đi từ Lan không bao giờ tới Đà Nẵng. Cách làm thường gặp, từ rẻ tới đắt: chuẩn hóa chuỗi (bỏ khoảng trắng thừa, bỏ chức danh như “chị”, “ông”), một bảng tên gọi khác do người viết, nhờ LLM hay embedding gợi ý các cặp tên có thể trùng rồi người duyệt lại.

Cái giá của bước này: RAG vector lập chỉ mục chỉ bằng các lần gọi embedding, còn Graph RAG gọi mô hình chat cho từng chunk. Kho lớn thì lập chỉ mục chậm và tốn hơn hẳn, và tài liệu đổi thì phải trích lại phần đổi.

Bạn lưu bộ ba vào đâu cũng được: một dict Python, một bảng SQL. Nhưng khi đồ thị lớn và cần hỏi nhiều kiểu, người ta dùng cơ sở dữ liệu đồ thị (graph database). Neo4j là một hệ phổ biến, có bản Community miễn phí chạy được trong Docker. Ngôn ngữ truy vấn của nó là Cypher, viết mẫu cần tìm như vẽ bằng ký tự:

  • Nút: (c:Entity {name: 'Mây Xanh'}). c là tên biến, Entity là nhãn của nút, phần trong ngoặc nhọn là thuộc tính.
  • Cạnh: -[:FOUNDED]->, mũi tên chỉ hướng. Bỏ mũi tên (-[:FOUNDED]-) là chấp nhận cả hai chiều.

Tạo bộ ba bằng MERGE: tìm mẫu, chưa có thì tạo. Chú ý: MERGE xét cả mẫu một lượt, không khớp cả mẫu thì tạo cả mẫu, kể cả những nút đã có, vì thế ô code MERGE từng nút trước rồi mới MERGE cạnh. CREATE thì luôn tạo mới, chạy hai lần là có hai nút “Lan”.

MERGE (lan:Entity {name: 'Lan'})
MERGE (mx:Entity {name: 'Mây Xanh'})
MERGE (lan)-[:FOUNDED]->(mx)

Một bước: ai sáng lập Mây Xanh? Mẫu đi từ một nút bất kỳ p theo cạnh FOUNDED tới Mây Xanh.

MATCH (p)-[:FOUNDED]->(c:Entity {name: 'Mây Xanh'})
RETURN p.name

Kết quả: "Lan".

Hai bước: công ty nào có chung nhà đầu tư với Mây Xanh? Mẫu vẽ đúng con đường ở phần Trực giác: từ Mây Xanh, theo cạnh INVESTED_IN ngược về quỹ f, rồi theo một cạnh INVESTED_IN khác đi xuôi tới công ty other.

MATCH (c:Entity {name: 'Mây Xanh'})<-[:INVESTED_IN]-(f)-[:INVESTED_IN]->(other)
RETURN f.name AS quy, other.name AS cong_ty

Kết quả: "Quỹ Sao Mai", "Lúa Vàng". Mây Xanh không tự xuất hiện ở cột cong_ty, vì trong một mẫu, Cypher không cho đi lại cùng một cạnh hai lần.

Độ dài thay đổi: mọi thực thể cách Mây Xanh từ 1 tới 3 bước, theo cạnh loại nào và chiều nào cũng được.

MATCH (c:Entity {name: 'Mây Xanh'})-[]-{1,3}(n)
RETURN DISTINCT n.name AS ten

{1,3} đặt sau một cạnh nghĩa là “lặp cạnh này từ 1 tới 3 lần”. Đây là cú pháp mới, theo chuẩn GQL. Tài liệu và ví dụ cũ trên mạng viết -[*1..3]-. Neo4j Community 2026.10 vẫn chạy cả hai và cho cùng kết quả (7 thực thể), nhưng phiên bản Neo4j cũ hơn có thể không hiểu {1,3}: ví dụ nào báo lỗi cú pháp thì kiểm phiên bản trước.

Giá trị từ người dùng đi qua tham số. Trong code, viết {name: $name} và truyền name="Mây Xanh" riêng, đừng nối chuỗi. Nối chuỗi thì một cái tên chứa dấu nháy và vài lệnh Cypher có thể biến thành lệnh xóa dữ liệu, giống prompt injection nhưng ở tầng cơ sở dữ liệu. Có một chỗ không viết $r được như tên: kiểu quan hệ trong mẫu -[:FOUNDED]->. Ô code Neo4j của bài ghép kiểu quan hệ vào chuỗi, nhưng chỉ sau khi kiểm nó nằm trong lược đồ. (Neo4j bản mới còn có cú pháp kiểu động $(...) cho chỗ này, được giới thiệu chính để giảm rủi ro tiêm Cypher, nhưng tài liệu Neo4j lưu ý truy vấn dùng giá trị động có thể chạy chậm hơn truy vấn tĩnh.)

Truy vấn nhiều bước là duyệt đồ thị

Phần tiêu đề “Truy vấn nhiều bước là duyệt đồ thị”

Bên dưới {1,3} là một phép duyệt đồ thị. Cách tự nhiên nhất là BFS (breadth-first search, tìm kiếm theo chiều rộng): đi từ nút xuất phát ra mọi láng giềng (bước 1), từ các nút đó ra láng giềng chưa gặp (bước 2), cứ thế từng lớp một, và dừng ở hh bước. Vì đi theo lớp, lần đầu gặp một nút cũng là lúc có đường đi ngắn nhất tới nó. Ô code chạy trên trang cài đúng thuật toán này bằng một dict và một hàng đợi.

Số nút tìm được tăng rất nhanh theo hh. Giả sử mỗi nút có khoảng dd láng giềng. Bước 1 gặp tối đa dd nút; mỗi nút đó lại mở ra dd nút nữa, nên bước 2 gặp tối đa d2d^2; cứ thế, sau hh bước có tối đa

d+d2+⋯+dhd + d^2 + \cdots + d^h

nút. Với d=10d = 10 và h=3h = 3, đó là 10+100+1000=111010 + 100 + 1000 = 1110 nút: dán hết vào prompt thì không vừa, và phần lớn chẳng liên quan. Vì vậy Graph RAG nên giới hạn số bước (thường 1–2), giới hạn số bộ ba đưa vào prompt, và lọc theo loại quan hệ như truy vấn hai bước ở trên, thay vì đi theo mọi cạnh.

Ghép lại, một hệ Graph RAG ở cấp ứng dụng thường chạy như sau:

  1. Lập chỉ mục (một lần): chia chunk, nhờ LLM trích bộ ba, kiểm, gộp thực thể, lưu vào đồ thị kèm chunk nguồn.
  2. Nhận ra thực thể trong câu hỏi: nhờ LLM liệt kê tên riêng trong câu hỏi, rồi khớp với tên nút (so chuỗi, hoặc embedding tên nút).
  3. Duyệt đồ thị quanh các thực thể đó, 1–2 bước, lọc theo loại quan hệ khi biết.
  4. Biến bộ ba thành chữ: mỗi bộ ba một dòng, ví dụ Quỹ Sao Mai INVESTED_IN Lúa Vàng, kèm chunk nguồn nếu cần.
  5. Ghép vào prompt và gọi mô hình chat, đúng như RAG thường: chỉ dẫn, ngữ cảnh đánh số, câu hỏi.

So với pipeline ở bài RAG và chatbot hiện đại, chỉ bước truy xuất đổi: thay “tìm top-kk theo cosine” bằng “duyệt đồ thị”. Hai cách không loại trừ nhau. Nhiều hệ dùng cả hai: vector tìm đoạn văn liên quan, đồ thị bổ sung các mối nối mà đoạn văn không nói cùng một chỗ.

Hình dưới nhắc lại pipeline RAG vector của bài trước. Ở Graph RAG, hãy đọc khối “Kho vectơ” thành đồ thị, và khối “Tìm top-k (cosine)” thành bước duyệt đồ thị quanh các thực thể của câu hỏi; phần ghép prompt và gọi mô hình chat giữ nguyên.

Indexingdone onceQueryevery questionDocumentsChunkingEmbeddingVector storesamemodelQuestionEmbeddingTop-k search(cosine)Build promptinstructions, chunks[1] [2], questionChat modelAnswer+ sources
The question and the chunks are embedded with the same model; chunk ii gets the score s(i)=cos⁡(q,c(i))s^{(i)} = \cos(\mathbf{q}, \mathbf{c}^{(i)}), and the kk chunks with the highest scores are kept.

Có một cách khác cho bước 3: nhờ LLM viết luôn truy vấn Cypher từ câu hỏi. Cách này linh hoạt, nhưng chạy thẳng một truy vấn do mô hình viết là mạo hiểm. Nếu làm, hãy chạy truy vấn ở chế độ chỉ đọc (driver Python: routing_=RoutingControl.READ, máy chủ sẽ từ chối lệnh ghi), đặt giới hạn thời gian chạy, và trên Neo4j Enterprise thì dùng tài khoản chỉ có quyền đọc.

GraphRAG của Microsoft: câu hỏi về cả kho

Phần tiêu đề “GraphRAG của Microsoft: câu hỏi về cả kho”

Có một loại câu hỏi mà cả RAG vector lẫn duyệt quanh một thực thể đều bó tay: “Các chủ đề chính trong kho tài liệu này là gì?”. Không đoạn nào chứa sẵn câu trả lời, và câu hỏi chẳng nêu thực thể nào để bắt đầu. Lấy top-kk đoạn thì giống hỏi ý kiến cả lớp mà chỉ nghe ba bạn bàn đầu.

Edge và cộng sự (2024) đề xuất GraphRAG cho loại câu hỏi toàn cục (global) này. Tên giống “Graph RAG”, nhưng đây là tên một cách làm cụ thể và một dự án mã nguồn mở của Microsoft. Ý chính:

  • Lúc lập chỉ mục: LLM trích thực thể và quan hệ từ văn bản, dựng đồ thị. Đồ thị được chia thành các cộng đồng (community): những cụm thực thể nối với nhau chặt hơn với phần còn lại. Tài liệu của dự án ghi là dùng thuật toán Leiden, chia theo nhiều cấp. LLM viết tóm tắt cho từng cộng đồng, làm sẵn một lần.
  • Lúc hỏi: mỗi bản tóm tắt cộng đồng cho ra một câu trả lời từng phần, rồi các câu trả lời từng phần được gộp thành câu trả lời cuối. Trở lại ví dụ lớp học: mỗi nhóm thảo luận riêng rồi cử người phát biểu, sau đó mới tổng hợp.

Paper báo cáo GraphRAG trả lời đầy đủ hơn và đa dạng hơn so với một RAG vector nền, trên các câu hỏi toàn cục với kho cỡ một triệu token. Đó là kết quả cho đúng loại câu hỏi ấy, không phải “GraphRAG tốt hơn RAG mọi lúc”. Tài liệu của dự án tách các chế độ hỏi: global search dùng tóm tắt cộng đồng cho câu hỏi về cả kho; local search đi từ một thực thể ra láng giềng của nó, gần với cách làm ở mục trước; ngoài ra còn DRIFT search (local search kèm thông tin cộng đồng) và chế độ tìm vector cơ bản cho câu hỏi một chỗ.

  • Chất lượng đồ thị bằng chất lượng bước trích. LLM có thể bịa quan hệ, đặt ngược hướng, hoặc tách một thực thể thành nhiều nút. Hộp thứ hai ở mục Lỗi hay gặp nói cách kiểm.
  • Lập chỉ mục tốn kém, như đã nói: mỗi chunk một lần gọi mô hình chat, và với GraphRAG còn thêm các lần viết tóm tắt cộng đồng.
  • Lược đồ phải bảo trì. Thêm loại câu hỏi mới có thể cần loại quan hệ mới, tức là trích lại.
  • Học trên đồ thị là một hướng khác. Mạng tích chập đồ thị (Graph Convolutional Network, GCN) của Kipf và Welling (2017) cập nhật vectơ của mỗi nút từ vectơ của các láng giềng, tạo embedding cho nút. GCN cần huấn luyện; Graph RAG trong bài thì không. Bài chỉ nhắc tên để bạn khỏi nhầm hai thứ.

Bảng dưới đặt bốn câu hỏi cạnh con đường trả lời chúng trong đồ thị 8 bộ ba. Hãy nhìn cột “Số bước”: càng nhiều bước, các bộ ba trên đường đi càng dễ nằm rải ở nhiều đoạn văn (hàng 3 cần hai bài báo khác nhau), và tìm đoạn theo độ giống câu hỏi càng dễ hụt.

Câu hỏiĐường đi trong đồ thịSố bước
Ai sáng lập Mây Xanh?Mây Xanh <-[FOUNDED]- Lan1
Người sáng lập Mây Xanh học trường nào?Mây Xanh <-[FOUNDED]- Lan -[STUDIED_AT]-> ĐH Bách khoa2
Công ty nào có chung nhà đầu tư với Mây Xanh?Mây Xanh <-[INVESTED_IN]- Quỹ Sao Mai -[INVESTED_IN]-> Lúa Vàng2
Ai học cùng trường với người sáng lập Mây Xanh?Mây Xanh <-[FOUNDED]- Lan -[STUDIED_AT]-> ĐH Bách khoa <-[STUDIED_AT]- Minh3

Bảng thứ hai so sánh ba cách làm. Đọc theo hàng “Mạnh ở” trước: mỗi cách hợp với một loại câu hỏi.

Tiêu chíRAG vectorGraph RAG (duyệt đồ thị)GraphRAG (tóm tắt cộng đồng)
Lập chỉ mụcChia chunk, gọi embeddingThêm: LLM trích bộ ba cho từng chunk, gộp thực thểThêm nữa: chia cộng đồng, LLM viết tóm tắt cho từng cộng đồng
Truy xuấtTop-kk chunk theo cosineDuyệt 1–2 bước quanh thực thể trong câu hỏiMỗi tóm tắt cho câu trả lời từng phần, rồi gộp
Mạnh ởCâu hỏi một chỗ: “X quy định gì?”Quan hệ, nhiều bước: “Ai có chung nhà đầu tư với X?”Câu hỏi về cả kho: “Chủ đề chính là gì?”
Yếu ởCâu trả lời rải ở nhiều đoạnPhụ thuộc bước trích và lược đồLập chỉ mục tốn nhiều lần gọi LLM nhất
Khi tài liệu đổiEmbed lại chunk đổiTrích lại, gộp lại thực thểTrích lại, có thể phải tóm tắt lại cộng đồng

Code trong bài chạy thử lần cuối tháng 10/2026, với Python 3.14, openai 3.37 và driver neo4j 6.4 trên Neo4j Community 2026.10.1; ô chạy trên trang chỉ dùng Python thuần. Ô thứ nhất chạy ngay trên trang. Ô thứ hai gọi API, ô thứ ba cần Neo4j, nên hai ô này chạy trên máy bạn.

Ô này dựng đồ thị từ 8 bộ ba của bài, không cần thư viện nào. Mỗi bộ ba được ghi vào danh sách kề ở cả hai đầu: ở chủ thể là cạnh đi ra -[R]->, ở đối tượng là cạnh đi vào <-[R]-. Nhờ vậy BFS đi được cả hai chiều, mà vẫn nhớ chiều thật của từng cạnh. Hàm bfs trả về đường đi ngắn nhất tới mọi thực thể trong tối đa max_hops bước.

Duyệt đồ thị tri thức nhiều bước bằng dict và BFS

# Duyệt đồ thị tri thức nhiều bước bằng dict và BFS, không cần thư viện.
from collections import deque
TRIPLES = [ # (chủ thể, quan hệ, đối tượng)
("Lan", "FOUNDED", "Mây Xanh"),
("Minh", "FOUNDED", "Lúa Vàng"),
("Quỹ Sao Mai", "INVESTED_IN", "Mây Xanh"),
("Quỹ Sao Mai", "INVESTED_IN", "Lúa Vàng"),
("Lan", "STUDIED_AT", "ĐH Bách khoa"),
("Minh", "STUDIED_AT", "ĐH Bách khoa"),
("Mây Xanh", "BASED_IN", "Đà Nẵng"),
("Lúa Vàng", "BASED_IN", "Cần Thơ"),
]
# Danh sách kề: mỗi cạnh được ghi ở cả hai đầu, kèm chiều của nó
graph = {}
for s, r, o in TRIPLES:
graph.setdefault(s, []).append((f"-[{r}]->", o))
graph.setdefault(o, []).append((f"<-[{r}]-", s))
def bfs(start, max_hops):
"""Trả về {thực thể: đường đi ngắn nhất từ start}, tối đa max_hops."""
paths = {start: start}
hops = {start: 0}
queue = deque([start])
while queue:
node = queue.popleft()
if hops[node] == max_hops:
continue # đủ số bước, không đi tiếp từ nút này
for edge, nxt in graph[node]:
if nxt not in hops: # nút chưa gặp: lần đầu gặp là ngắn nhất
hops[nxt] = hops[node] + 1
paths[nxt] = f"{paths[node]} {edge} {nxt}"
queue.append(nxt)
return paths
for h in (1, 2, 3):
print(f"hops <= {h}: {len(bfs('Mây Xanh', h)) - 1} entities")
for path in list(bfs("Mây Xanh", 2).values())[1:]:
print(path)

The first run downloads Python, about 10 MB.

  • Ba dòng đầu đếm số thực thể tới được từ Mây Xanh: 3 thực thể trong 1 bước, 5 trong 2 bước, 7 trong 3 bước. Đồ thị chỉ có 8 nút, nên 3 bước đã chạm mọi nút khác. Ô Neo4j ở dưới cũng đếm được 7 với -[]-{1,3}.
  • Ba đường đi một bước (Lan, Quỹ Sao Mai, Đà Nẵng) trả lời được câu hỏi một bước như “Ai sáng lập Mây Xanh?”.
  • Hai đường đi hai bước chính là hàng 2 và hàng 3 của bảng ở mục Minh họa: Lan -[STUDIED_AT]-> ĐH Bách khoa, và Quỹ Sao Mai -[INVESTED_IN]-> Lúa Vàng.

Thử đổi bfs("Mây Xanh", 2) ở vòng for cuối thành bfs("Mây Xanh", 3): hai đường mới tới Minh và Cần Thơ hiện ra. Đường tới Minh là hàng cuối của bảng Minh họa, vì BFS gặp ĐH Bách khoa trước Lúa Vàng.

Ô này gửi cho mô hình prompt ở mục Lý thuyết: chỉ dẫn kèm lược đồ, một ví dụ mẫu (lượt user và assistant viết sẵn), rồi đoạn văn cần trích. Nó cần ba biến môi trường như ở bài Sử dụng API văn bản. Nhà cung cấp báo lỗi 400 với response_format thì xóa dòng đó: prompt vẫn đòi JSON, và json.loads vẫn kiểm.

Run on your machine

Trích bộ ba từ một đoạn văn, có kiểm lược đồ

Install
pip install openai
Environment variables
OPENAI_BASE_URL (optional), OPENAI_API_KEY, LLM_MODEL
# Nhờ LLM đọc một đoạn văn và trả về các bộ ba dưới dạng JSON.
import json
import os
from openai import OpenAI
client = OpenAI(
base_url=os.environ.get("OPENAI_BASE_URL"), # bỏ trống: dùng OpenAI
api_key=os.environ["OPENAI_API_KEY"],
)
MODEL = os.environ["LLM_MODEL"]
RELATIONS = ["FOUNDED", "INVESTED_IN", "STUDIED_AT", "BASED_IN"]
SYSTEM = (
"Bạn trích bộ ba tri thức từ văn bản. Chỉ trả về một đối tượng JSON "
'dạng {"triples": [{"subject": "...", "relation": "...", '
'"object": "..."}]}. relation chỉ được là một trong: '
+ ", ".join(RELATIONS)
+ ". Chỉ ghi điều văn bản nói rõ, không suy đoán. Tên công ty, "
"tổ chức ghi đúng như văn bản, bỏ chức danh như ông, bà, chị."
)
EXAMPLE_IN = "Ông Tùng sáng lập Gió Nam, một công ty ở Huế."
EXAMPLE_OUT = json.dumps({"triples": [
{"subject": "Tùng", "relation": "FOUNDED", "object": "Gió Nam"},
{"subject": "Gió Nam", "relation": "BASED_IN", "object": "Huế"},
]}, ensure_ascii=False)
TEXT = (
"Chị Lan tốt nghiệp ĐH Bách khoa rồi sáng lập Mây Xanh năm 2021. "
"Công ty đặt trụ sở ở Đà Nẵng và vừa nhận vốn từ Quỹ Sao Mai."
)
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": EXAMPLE_IN}, # một ví dụ mẫu
{"role": "assistant", "content": EXAMPLE_OUT},
{"role": "user", "content": TEXT},
],
temperature=0, # trích xuất: cần ổn định
response_format={"type": "json_object"}, # JSON mode; lỗi 400 thì xóa
)
text = response.choices[0].message.content
try:
items = json.loads(text)["triples"]
except (json.JSONDecodeError, KeyError, TypeError):
items = None
if not isinstance(items, list):
print("Không đọc được JSON:", text)
items = []
for item in items: # không tin đầu ra: kiểm từng bộ ba
if not isinstance(item, dict) or item.get("relation") not in RELATIONS:
print("Bỏ qua (sai lược đồ):", item)
continue
s = " ".join(str(item.get("subject", "")).split()) # bỏ khoảng trắng thừa
o = " ".join(str(item.get("object", "")).split())
if s and o:
print((s, item["relation"], o))
else:
print("Bỏ qua (tên rỗng):", item)

Chương trình in mỗi bộ ba hợp lệ thành một tuple, in “Bỏ qua (sai lược đồ)” cho bộ ba có quan hệ ngoài danh sách, và “Bỏ qua (tên rỗng)” cho bộ ba thiếu chủ thể hoặc đối tượng. Nếu mô hình không trả JSON, hoặc triples không phải một danh sách, nó in “Không đọc được JSON” kèm chuỗi nhận được. Đoạn văn được chọn để có hai chỗ đáng xem:

  • “tốt nghiệp ĐH Bách khoa” không có chữ “học”, nên mô hình phải tự ánh xạ sang STUDIED_AT, hoặc bỏ qua. Cả hai đều có thể xảy ra.
  • “nhận vốn từ Quỹ Sao Mai” viết theo góc nhìn của Mây Xanh. Bộ ba đúng hướng là (Quỹ Sao Mai, INVESTED_IN, Mây Xanh). Nếu bạn thấy (Mây Xanh, INVESTED_IN, Quỹ Sao Mai), mô hình đã đặt ngược hướng, và bước kiểm lược đồ không bắt được lỗi này.

Chạy Neo4j Community trong Docker (xem bài Docker và Cloud nếu bạn mới dùng Docker). Mật khẩu đặt bằng biến môi trường, dùng chung cho lệnh docker run và cho code Python; Neo4j đòi mật khẩu ít nhất 8 ký tự. 127.0.0.1: trước mỗi cổng giữ Neo4j chỉ mở cho máy bạn.

Cửa sổ terminal
export NEO4J_PASSWORD='<mật khẩu, ít nhất 8 ký tự>'
docker run --rm --name neo4j-atlas \
-p 127.0.0.1:7474:7474 -p 127.0.0.1:7687:7687 \
-e NEO4J_AUTH="neo4j/$NEO4J_PASSWORD" \
neo4j:community

Khi log hiện dòng Started., mở cửa sổ dòng lệnh thứ hai, đặt lại NEO4J_PASSWORD ở đó, rồi chạy ô dưới. Cổng 7687 dành cho driver; cổng 7474 mở Neo4j Browser tại http://localhost:7474, nơi bạn dán được các khối Cypher ở mục Lý thuyết và xem đồ thị dưới dạng hình. Có --rm và không gắn ổ đĩa (volume), nên dừng container là mất dữ liệu: đủ cho việc học, không dùng cho dữ liệu thật.

Run on your machine

Ghi 8 bộ ba vào Neo4j, hỏi hai bước và nhiều bước

Install
pip install neo4j
Environment variables
NEO4J_URI (optional), NEO4J_PASSWORD
# Ghi bộ ba vào Neo4j, rồi hỏi hai bước và nhiều bước bằng Cypher.
import os
from neo4j import GraphDatabase
URI = os.environ.get("NEO4J_URI", "neo4j://127.0.0.1:7687")
AUTH = ("neo4j", os.environ["NEO4J_PASSWORD"])
TRIPLES = [ # cùng 8 bộ ba với ô chạy trên trang
("Lan", "FOUNDED", "Mây Xanh"),
("Minh", "FOUNDED", "Lúa Vàng"),
("Quỹ Sao Mai", "INVESTED_IN", "Mây Xanh"),
("Quỹ Sao Mai", "INVESTED_IN", "Lúa Vàng"),
("Lan", "STUDIED_AT", "ĐH Bách khoa"),
("Minh", "STUDIED_AT", "ĐH Bách khoa"),
("Mây Xanh", "BASED_IN", "Đà Nẵng"),
("Lúa Vàng", "BASED_IN", "Cần Thơ"),
]
RELATIONS = {"FOUNDED", "INVESTED_IN", "STUDIED_AT", "BASED_IN"}
SHARED_INVESTOR = """
MATCH (c:Entity {name: $name})<-[:INVESTED_IN]-(f)-[:INVESTED_IN]->(o)
RETURN f.name AS fund, o.name AS company
"""
WITHIN_3 = """
MATCH (c:Entity {name: $name})-[]-{1,3}(n)
RETURN count(DISTINCT n) AS n
"""
with GraphDatabase.driver(URI, auth=AUTH) as driver:
driver.verify_connectivity()
# Xóa đồ thị cũ của bài để chạy lại nhiều lần vẫn ra cùng kết quả
driver.execute_query("MATCH (n:Entity) DETACH DELETE n")
for s, r, o in TRIPLES:
# Tên đi qua tham số $s, $o. Kiểu quan hệ được ghép vào chuỗi,
# nên phải kiểm trước rằng nó nằm trong lược đồ.
if r not in RELATIONS:
raise ValueError(f"Quan hệ ngoài lược đồ: {r}")
driver.execute_query(
"MERGE (a:Entity {name: $s}) MERGE (b:Entity {name: $o}) "
f"MERGE (a)-[:{r}]->(b)",
s=s, o=o,
)
for query in (SHARED_INVESTOR, WITHIN_3):
records, _, _ = driver.execute_query(query, name="Mây Xanh")
for record in records:
print(record.data())

Sample output

From one real run.

{'fund': 'Quỹ Sao Mai', 'company': 'Lúa Vàng'}
{'n': 7}

Ô này xóa mọi nút Entity trong cơ sở dữ liệu, ghi lại 8 bộ ba bằng MERGE, rồi chạy hai truy vấn với tham số name="Mây Xanh". Dòng thứ nhất là truy vấn hai bước “chung nhà đầu tư”: đúng hàng 3 của bảng Minh họa. Dòng thứ hai đếm thực thể cách Mây Xanh 1 tới 3 bước, được 7, khớp với ô BFS ở trên: trên đồ thị này, {1,3} của Cypher và BFS của bạn tìm ra cùng một tập thực thể. (Hai bên không làm y hệt nhau: Cypher liệt kê mọi đường đi không lặp cạnh, còn BFS chỉ giữ đường ngắn nhất tới mỗi nút.) Kết quả giống nhau mọi lần chạy, vì không có mô hình nào trong ô này. Biến NEO4J_URI là tùy chọn, chỉ cần khi Neo4j không nằm ở 127.0.0.1:7687; đừng trỏ nó vào cơ sở dữ liệu đang có dữ liệu thật.

Question 1 of 4 Kho tin âm nhạc có hai đoạn: "Ca sĩ Hà ký hợp đồng với hãng đĩa Sóng Âm" và "Sóng Âm vừa phát hành album đầu tay của ban nhạc Gió Lộng". Người dùng hỏi: "Ai cùng hãng đĩa với ca sĩ Hà?". Vì sao RAG vector với top-kk nhỏ dễ trả lời thiếu?
Question 2 of 4 LLM trích được hai bộ ba từ hai đoạn khác nhau: (Lan, FOUNDED, Mây Xanh) và (Cty Mây Xanh, BASED_IN, Đà Nẵng). Truy vấn "người sáng lập công ty có trụ sở ở Đà Nẵng" không trả về ai. Nguyên nhân khả dĩ nhất?
Question 3 of 4 Đồ thị có cạnh (An)-[:WORKS_AT]->(Công ty Bình Minh). Truy vấn MATCH (c {name: 'Công ty Bình Minh'})-[:WORKS_AT]->(p) RETURN p.name trả về rỗng. Sửa thế nào?
Question 4 of 4 Bạn có 2 000 biên bản họp và hỏi: "Ba vấn đề được bàn nhiều nhất trong cả năm là gì?". Vì sao global search của GraphRAG (Microsoft) hợp với câu này hơn RAG vector top-kk?

Ý chính:

  • Đồ thị tri thức là một tập bộ ba (s,r,o)(s, r, o): thực thể là nút, quan hệ là cạnh có hướng, có nhãn. Hai bộ ba chung một thực thể thì tự nối, nên tri thức rải ở nhiều đoạn được ghép lại.
  • LLM trích bộ ba từ từng chunk theo một lược đồ, trả JSON. Chương trình phải kiểm JSON và lược đồ, lưu chunk nguồn, và gộp thực thể; bước trích là chỗ tốn kém và dễ sai nhất.
  • Neo4j lưu đồ thị; Cypher vẽ mẫu bằng ký tự, như (c)<-[:INVESTED_IN]-(f)-[:INVESTED_IN]->(o), và -[]-{1,3} cho 1 tới 3 bước (cú pháp cũ *1..3). Bên dưới là phép duyệt kiểu BFS; số nút tăng cỡ dhd^h theo số bước, nên phải giới hạn bước và lọc loại quan hệ.
  • Graph RAG chỉ thay bước truy xuất của RAG bằng duyệt đồ thị. GraphRAG của Microsoft thêm tóm tắt theo cộng đồng cho câu hỏi về cả kho. RAG vector vẫn là lựa chọn rẻ và đủ cho câu hỏi một chỗ.

Đọc thêm:

Content licensed under CC BY-NC-SA 4.0. Code under the MIT license.About