Graph 4 — Cypher nâng cao
Graph 4 — Cypher nâng cao
Cypher căn bản dựng được các truy vấn một cấp: khách nào có tài khoản gì, số dư bao nhiêu. Nhưng giá trị thật của đồ thị nằm ở câu hỏi nhiều bước — dòng tiền chảy qua bao nhiêu tài khoản trước khi tới đích, hai khách có nối với nhau qua chuỗi giao dịch nào không, tiền có chạy thành một vòng để rửa dấu vết không. Đây là chỗ SQL đuối sức còn Cypher toả sáng. Bài này đi qua đường đi độ sâu thay đổi, đường ngắn nhất, các mệnh đề gộp/lọc nâng cao, nạp dữ liệu lô và đọc kế hoạch thực thi.
Lưu ý sandbox: các block
cypherdưới đây là ngôn ngữ Neo4j, không chạy trên sandbox Knowledge Base. Bài có đúng một block SQL đánh dấu-- ▶ Chạy được(PostgreSQL, read-only) — cố tình dựng bằngWITH RECURSIVEđể bạn thấy tận mắt RDBMS phải vật lộn thế nào với đường độ sâu thay đổi, làm điểm đối chiếu với Cypher.
Variable-length path — trái tim của truy vấn đồ thị
Trong Cypher căn bản, mỗi relationship trong pattern là đúng một bước: (a)-[:SENT]->(b). Nhưng nhiều câu hỏi ngân hàng không biết trước số bước: "tiền từ tài khoản này đi tới đâu qua 1 đến 4 lần chuyển?". Cypher diễn đạt bằng cú pháp variable-length relationship — thêm khoảng *min..max vào type quan hệ:
MATCH path = (src:Account {accountNo: $tk})-[:SENT*1..4]->(dst:Account)
RETURN dst.accountNo, length(path) AS so_buoc
-[:SENT*1..4]-> nghĩa là "đi theo cạnh SENT từ 1 đến 4 bước". Engine tự bung mẫu thành mọi độ dài trong khoảng và trả về mọi đích tới được. Các biến thể:
*— độ sâu bất kỳ (cẩn thận: có thể bùng nổ trên đồ thị lớn).*2— đúng 2 bước.*..3— từ 1 đến 3 bước (min ngầm là 1).*3..— từ 3 bước trở lên.
Gán mẫu vào biến path (path = ...) mở ra loạt hàm đường đi: length(path) (số quan hệ), nodes(path) (danh sách node dọc đường), relationships(path) (danh sách cạnh). Đây là thứ SQL thuần không có: một truy vấn duy nhất, đọc như tiếng Anh, cho ra đường đi độ sâu thay đổi — trong khi RDBMS phải viết WITH RECURSIVE dài dòng (xem phần đối chiếu bên dưới).
Trong bối cảnh chống gian lận, đây chính là "follow the money" (truy vết dòng tiền): cho một tài khoản nghi vấn, tìm mọi tài khoản mà tiền của nó tới được trong tối đa 4 chặng.
shortestPath và allShortestPaths — điều tra liên hệ
Câu hỏi kinh điển của điều tra: "khách A và khách B có liên hệ với nhau không, và gần tới mức nào?". Cypher trả lời bằng hàm đường đi ngắn nhất:
MATCH (a:Customer {id: $id_a}), (b:Customer {id: $id_b})
MATCH path = shortestPath( (a)-[:OWNS|SENT*..8]-(b) )
RETURN path, length(path) AS khoang_cach
shortestPath(...) tìm một đường ngắn nhất giữa hai node; allShortestPaths(...) trả về mọi đường cùng độ dài ngắn nhất (khi có nhiều lối bằng nhau). Ba điểm cần nhớ:
- Type nhiều loại:
[:OWNS|SENT*..8]cho phép đi qua cả cạnhOWNSlẫnSENT— thường cần vì liên hệ đi qua tài khoản trung gian:Customer -OWNS-> Account -SENT-> Account <-OWNS- Customer. - Không hướng: dùng
-...-(không mũi tên) khi liên hệ có thể theo bất kỳ chiều nào — điều tra thường quan tâm có nối hay không, không quan tâm chiều tiền. - Chặn trần độ sâu (
*..8): luôn đặt giới hạn để engine không quét vô tận trên đồ thị hàng triệu node.
So với SQL, đây là khác biệt một trời một vực: tìm đường ngắn nhất trong RDBMS đòi hỏi đệ quy thủ công cộng logic chọn min độ dài — dễ sai, chậm, khó đọc; với Cypher chỉ là một lời gọi hàm.
Sơ đồ: variable-length path và đường ngắn nhất
Bên trái: từ tài khoản nghi vấn A1, một pattern *1..4 bung ra mọi đích (A2, A3, A4, A5) qua 1–4 chặng. Bên phải: shortestPath tìm chuỗi ngắn nhất nối hai khách qua tài khoản trung gian.
Đối chiếu chạy được: RDBMS vật lộn với đường độ sâu thay đổi
Để thấy cụ thể vì sao đồ thị thắng, đây là cùng bài toán "truy vết dòng tiền nhiều chặng" viết bằng SQL thuần trên sandbox. Vì bảng transactions không có sẵn cạnh tài khoản→tài khoản (mỗi giao dịch chỉ có một account_id với kind là credit/debit), ta phải tự dựng lại cạnh chuyển tiền rồi mới đệ quy — đúng cái gánh nặng mà mô hình đồ thị xoá bỏ. Câu dưới đây chạy được trên sandbox (Postgres, read-only):
-- ▶ Chạy được
WITH RECURSIVE
transfers AS (
-- Dựng lại cạnh chuyển tiền: ghép một 'debit' với một 'credit'
-- xảy ra sau đó trên tài khoản khác (heuristic theo thời gian).
SELECT d.account_id AS from_acc, c.account_id AS to_acc
FROM transactions d
JOIN transactions c
ON c.kind = 'credit'
AND c.account_id <> d.account_id
AND c.created_at > d.created_at
WHERE d.kind = 'debit'
),
flow(from_acc, current_acc, hops, path) AS (
-- Neo: mỗi cạnh chuyển tiền là một đường dài 1 chặng.
SELECT t.from_acc, t.to_acc, 1, ARRAY[t.from_acc, t.to_acc]
FROM transfers t
UNION ALL
-- Đệ quy: nối thêm một chặng, tránh lặp lại tài khoản đã đi (chống vòng vô tận).
SELECT f.from_acc, t.to_acc, f.hops + 1, f.path || t.to_acc
FROM flow f
JOIN transfers t ON t.from_acc = f.current_acc
WHERE f.hops < 4
AND NOT t.to_acc = ANY(f.path)
)
SELECT from_acc, current_acc AS to_acc, hops, path
FROM flow
ORDER BY hops DESC, from_acc, to_acc
LIMIT 20;
Chạy trên dữ liệu mẫu, câu này ra đường 2 chặng {1,3,4} — tiền từ tài khoản 1 tới tài khoản 4 qua tài khoản 3. Hãy để ý chi phí: một CTE riêng chỉ để tái tạo cạnh (đồ thị đã có sẵn [:SENT]), một CTE đệ quy neo + đệ quy, một mảng path tự bảo trì và điều kiện NOT ... = ANY(path) viết tay để chống vòng vô tận (Neo4j chống vòng tự động). Đọc, gỡ lỗi và tối ưu khối này khó hơn hẳn dòng Cypher -[:SENT*1..4]-> tương đương — đúng luận điểm của cả series: truy vấn nhiều bước là bản chất của đồ thị, là ngoại lệ khổ sở của RDBMS.
Aggregation, WITH và collect/UNWIND
Cypher gộp dữ liệu như bài căn bản đã nêu: trộn cột thường với hàm gộp (count, sum, avg, collect) trong RETURN/WITH là tự gộp theo cột không-gộp — không có GROUP BY. WITH đóng vai pipeline và WHERE sau WITH đóng vai HAVING. Ví dụ "tài khoản nhận tiền từ ít nhất 5 nguồn khác nhau" — dấu hiệu tài khoản trung chuyển (mule):
MATCH (src:Account)-[s:SENT]->(dst:Account)
WITH dst, count(DISTINCT src) AS so_nguon, sum(s.amount) AS tong_nhan
WHERE so_nguon >= 5
RETURN dst.accountNo, so_nguon, tong_nhan
ORDER BY so_nguon DESC
collect() gom nhiều giá trị thành danh sách (list); UNWIND làm ngược lại — bung một danh sách thành nhiều dòng. Cặp collect/UNWIND là công cụ chủ lực để tái cấu trúc dữ liệu:
MATCH (c:Customer)-[:OWNS]->(acc:Account)
WITH c, collect(acc.accountNo) AS ds_tk // gom: mỗi khách một danh sách
WHERE size(ds_tk) > 1
UNWIND ds_tk AS tk // bung: mỗi tài khoản một dòng lại
RETURN c.fullName, tk
UNWIND còn là cách chuẩn để nạp dữ liệu lô (xem phần sau) và để duyệt danh sách tham số truyền vào từ driver.
Pattern comprehension và list function
Cypher mượn ý tưởng list comprehension của Python. Pattern comprehension duyệt một pattern ngay trong biểu thức và trả về danh sách, không cần MATCH riêng:
MATCH (c:Customer)-[:OWNS]->(acc:Account)
RETURN c.fullName,
acc.accountNo,
[ (acc)-[s:SENT]->(x:Account) WHERE s.amount > 100000000 | x.accountNo ]
AS chuyen_lon_toi
Biểu thức [ (pattern) WHERE ... | biểu_thức ] đọc là: với mỗi khớp của pattern thoả WHERE, lấy biểu_thức, gom thành danh sách. Ở trên: với mỗi tài khoản, liệt kê các tài khoản mà nó chuyển khoản lớn (>100tr) tới — trên cùng một dòng, không làm nở kết quả. Kèm theo là kho list function: size(), head(), last(), range(), hàm bậc cao reduce() (fold/aggregate trên danh sách), [x IN list WHERE ...] (lọc), [x IN list | expr] (map).
OPTIONAL MATCH — LEFT JOIN của Cypher
MATCH thường loại bỏ hàng nếu pattern không khớp — như INNER JOIN. OPTIONAL MATCH giữ lại hàng và điền null khi pattern vắng — đúng như LEFT JOIN:
MATCH (c:Customer)
OPTIONAL MATCH (c)-[:OWNS]->(acc:Account)
RETURN c.fullName, count(acc) AS so_tk // khách chưa có tài khoản: 0
Không có OPTIONAL MATCH, khách chưa mở tài khoản sẽ biến mất khỏi kết quả. Dùng nó bất cứ khi nào quan hệ có thể vắng mặt mà bạn vẫn muốn giữ node gốc — báo cáo đầy đủ, phát hiện node cô lập, đếm-kể-cả-không.
Subquery CALL {} — truy vấn lồng nhau
CALL { ... } cho phép chạy một subquery trong lòng truy vấn chính, mỗi dòng đầu vào một lần (như LATERAL join trong SQL). Cực hữu ích cho "với mỗi node, lấy top-N liên quan" — thứ khó viết bằng gộp phẳng:
MATCH (c:Customer)
CALL {
WITH c
MATCH (c)-[:OWNS]->(acc:Account)-[s:SENT]->(dst:Account)
RETURN dst.accountNo AS dich, s.amount AS tien
ORDER BY s.amount DESC
LIMIT 3 // top 3 giao dịch lớn nhất của MỖI khách
}
RETURN c.fullName, dich, tien
WITH c ở đầu subquery là cách nhập biến từ truy vấn ngoài vào. Từ Neo4j 5, CALL {} còn dùng cho giao dịch theo lô khi ghi lượng lớn (CALL { ... } IN TRANSACTIONS OF 1000 ROWS), tránh dồn cả triệu thao tác vào một transaction đơn.
Kiểm tra tồn tại pattern trong WHERE
Một sức mạnh riêng của Cypher: lọc theo sự có mặt (hoặc vắng mặt) của cả một đường đi, không chỉ giá trị property. Đặt pattern thẳng trong WHERE:
MATCH (c:Customer)-[:OWNS]->(acc:Account)
WHERE (acc)-[:SENT]->(:Account {flagged: true}) // tài khoản này từng gửi tới TK bị gắn cờ
AND NOT (c)-[:VERIFIED_BY]->(:Kyc) // và khách chưa hoàn tất KYC
RETURN c.fullName, acc.accountNo
WHERE (a)-[:REL]->(b) là predicate tồn tại pattern — trả true nếu có ít nhất một khớp. Trong SQL đây là EXISTS (SELECT ... ), và phủ định là NOT EXISTS. Riêng với đường nhiều bước (WHERE (a)-[:SENT*1..3]->(b)), Cypher gọn gàng còn SQL lại rơi vào đệ quy — lợi thế lặp lại của mô hình đồ thị.
Nạp và đồng bộ dữ liệu số lượng lớn
LOAD CSV đọc trực tiếp file CSV để dựng đồ thị — cách khởi tạo phổ biến khi migrate từ core banking:
LOAD CSV WITH HEADERS FROM 'file:///accounts.csv' AS row
CALL {
WITH row
MERGE (a:Account {accountNo: row.account_no})
ON CREATE SET a.balance = toFloat(row.balance), a.currency = row.currency
} IN TRANSACTIONS OF 5000 ROWS
UNWIND + MERGE là mẫu chuẩn khi nạp từ driver: truyền một mảng bản ghi vào tham số $rows, UNWIND bung thành từng dòng, MERGE get-or-create theo khoá để không tạo trùng khi job chạy lại:
UNWIND $rows AS row
MERGE (a:Account {accountNo: row.from})
MERGE (b:Account {accountNo: row.to})
MERGE (a)-[s:SENT {txnId: row.txnId}]->(b)
ON CREATE SET s.amount = row.amount, s.txnAt = datetime(row.at)
Khi cần thao tác phức tạp hơn, thư viện APOC (Awesome Procedures on Cypher) cung cấp hàng trăm procedure gọi qua CALL apoc.... — ví dụ apoc.periodic.iterate để nạp lô lớn có kiểm soát bộ nhớ. APOC là add-on ngoài chuẩn, cần cài riêng.
INDEX, CONSTRAINT và đọc kế hoạch thực thi
Hiệu năng đồ thị phụ thuộc vào chuyện engine tìm node bắt đầu (anchor) nhanh cỡ nào. Không có index, MATCH (a:Account {accountNo: $tk}) phải quét toàn bộ node label Account — chậm khủng khiếp trên hàng triệu node. Hai công cụ:
// CONSTRAINT: bảo đảm duy nhất + TỰ ĐỘNG tạo index đi kèm
CREATE CONSTRAINT acc_no_unique IF NOT EXISTS
FOR (a:Account) REQUIRE a.accountNo IS UNIQUE;
// INDEX: tăng tốc tra cứu trên property không cần duy nhất
CREATE INDEX cust_city_idx IF NOT EXISTS
FOR (c:Customer) ON (c.city);
- Unique constraint trên khoá định danh (
accountNo,Customer.id) là bắt buộc: nó vừa chặn dữ liệu trùng, vừa tạo index nền cho mọiMATCHvà đặc biệt choMERGE— thiếu nó, mỗiMERGEphải quét toàn label để kiểm tra tồn tại, biến job nạp thành thảm hoạ. - Index thường cho property hay lọc (
city,txnAt) giúp anchor nhanh.
Muốn biết engine làm gì, đặt EXPLAIN (xem kế hoạch, không chạy) hoặc PROFILE (chạy thật + đếm số row chạm ở mỗi bước) trước truy vấn:
PROFILE
MATCH (a:Account {accountNo: $tk})-[:SENT*1..4]->(dst:Account)
RETURN dst.accountNo
Dấu hiệu cần đọc trong kế hoạch: bước NodeByLabelScan (quét toàn label — dấu hiệu thiếu index, nên thành NodeIndexSeek), và cột db hits cao bất thường (engine chạm quá nhiều bản ghi). Với variable-length path, luôn kiểm tra trần độ sâu và anchor có index chưa — hai nguyên nhân phổ biến nhất khiến truy vấn "treo".
Use case thực tế
Bối cảnh. Đội AML của NCB nhận cảnh báo: tài khoản A-NGHIVAN nhận một khoản 950 triệu VND bất thường. Nghi vấn: tiền được phân tán rồi gộp lại qua mạng tài khoản trung chuyển (structuring/layering) và có thể chạy thành vòng để rửa nguồn gốc. Đồ thị (:Account)-[:SENT {amount, txnAt}]->(:Account) đã có sẵn trong Neo4j.
Bước 1 — Truy vết dòng tiền độ sâu thay đổi. Điều tra viên tìm mọi tài khoản mà tiền từ A-NGHIVAN tới được trong tối đa 4 chặng, trong cửa sổ 7 ngày:
MATCH path = (src:Account {accountNo: $tk})-[:SENT*1..4]->(dst:Account)
WHERE all(r IN relationships(path)
WHERE r.txnAt >= datetime($tu) AND r.txnAt <= datetime($den))
RETURN dst.accountNo,
length(path) AS so_chang,
[n IN nodes(path) | n.accountNo] AS duong_di,
reduce(s = 0.0, r IN relationships(path) | s + r.amount) AS tong_luong
ORDER BY so_chang DESC
LIMIT 50
all(r IN relationships(path) WHERE ...) bảo đảm mọi chặng nằm trong cửa sổ thời gian; reduce(...) cộng dồn số tiền dọc đường. Ra 12 đường, trong đó một đường 4 chặng đi qua 3 tài khoản mở cùng ngày, cùng chi nhánh — mẫu điển hình của mule.
Bước 2 — Phát hiện vòng. Vòng là đường quay về node xuất phát. Cypher bắt gọn bằng cách buộc node đầu và cuối trùng nhau:
MATCH cycle = (a:Account {accountNo: $tk})-[:SENT*2..6]->(a)
RETURN [n IN nodes(cycle) | n.accountNo] AS vong,
length(cycle) AS do_dai
ORDER BY do_dai
Pattern (a)-[:SENT*2..6]->(a) — cùng biến a ở hai đầu — nghĩa "đường 2 đến 6 chặng khởi từ a rồi quay lại a". Phát hiện một vòng 5 chặng: tiền rời A-NGHIVAN, qua 4 tài khoản trung gian, quay về A-NGHIVAN sau 6 giờ — bằng chứng mạnh cho layering. Cùng câu hỏi này trên RDBMS phải viết WITH RECURSIVE với mảng path tự chống vòng như khối SQL ở trên, dài gấp nhiều lần và chậm hơn.
Bước 3 — Chuẩn bị hồ sơ. Kết quả (đường đi + vòng) được xuất kèm danh sách chủ tài khoản (JOIN qua OWNS tới Customer) để gửi bộ phận điều tra. Truy vấn dựa trên unique constraint Account.accountNo nên anchor MATCH (a:Account {accountNo: $tk}) chạy bằng NodeIndexSeek, cả hai câu trả về dưới ~80ms trên đồ thị ~2 triệu cạnh (đo trên staging). Không có constraint đó, PROFILE cho thấy NodeByLabelScan và thời gian nhảy lên vài giây.
Kết quả. Điều tra viên có bức tranh dòng tiền và vòng chỉ với hai truy vấn Cypher ngắn — thay cho hàng loạt CTE đệ quy khó bảo trì trong hệ báo cáo SQL cũ. Việc tìm mẫu cấu trúc (fan-out, gộp, vòng) tự động hoá được bằng thuật toán đồ thị và ráp vào quy trình ở bài fraud & AML; về giám sát giao dịch nói chung, xem thêm Giám sát giao dịch AML.
Ghi nhớ
- Variable-length path
-[:SENT*1..4]->là trái tim của truy vấn đồ thị: đường đi độ sâu thay đổi trong một mẫu. Biến path (path = ...) mở ralength(),nodes(),relationships(). Luôn đặt trần độ sâu. shortestPath/allShortestPathstìm đường ngắn nhất giữa hai node — điều tra liên hệ khách hàng. SQL cần đệ quy thủ công cho cùng việc.- Aggregation không có
GROUP BY: trộn cột thường + hàm gộp là tự gộp;WITH+WHERE=HAVING.collect()gom danh sách,UNWINDbung danh sách thành dòng. - Pattern comprehension
[ (pattern) WHERE ... | expr ]gom danh sách ngay trong biểu thức; kèm list functionsize/head/reduce/range. OPTIONAL MATCH=LEFT JOIN(giữ node gốc, điền null).CALL {}= subquery/LATERAL— "với mỗi node lấy top-N".WHERE (a)-[:REL]->(b)=EXISTStrên cả một pattern.- Nạp lô:
LOAD CSVcho file,UNWIND $rows+MERGEcho driver (get-or-create, không trùng khi chạy lại), APOC cho tác vụ nâng cao.CALL {} IN TRANSACTIONS OF N ROWSđể chia lô ghi. - Unique CONSTRAINT trên khoá định danh là bắt buộc — nó tạo index nền cho
MATCHvà đặc biệt choMERGE.EXPLAIN/PROFILEđể đọc kế hoạch; cảnh giácNodeByLabelScanvà db hits cao. - Đường độ sâu thay đổi và phát hiện vòng là chỗ đồ thị thắng đậm RDBMS: khối
WITH RECURSIVEchạy được ở trên cho thấy Postgres phải tự dựng cạnh, tự giữ mảngpathvà tự chống vòng — thứ Cypher gói trong-[:SENT*1..4]->và(a)-[:SENT*2..6]->(a).
Bài viết liên quan
Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.
Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.
Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.
Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.