不能拍照的機密 SOP,怎麼手抄成 Neo4j 知識圖

重點摘要
  • 面對一本不能拆、不能拍、只能帶紙筆進出還要被稽核的 700 頁機密機械 SOP,正確目標不是「抄書」,是「萃取流程骨架」——你當那本書的領域專家萃取器。
  • 流程文件的價值在步驟順序、誰負責、要交什麼、什麼條件跳哪一步,是關係不是語義 → 天生適合知識圖 / GraphRAG,不是向量 RAG。
  • 進去前腦子帶一個固定四格模板(做什麼 / 動什麼 / 注意什麼 / 分支),出來每站變一列 CSV,一支 Python 用 MERGE 灌進 Neo4j,80 站的流程圖(含分支與返工迴圈)就成形。
  • 合法的讀書筆記天生又精簡、又明顯不是影本 → 方向對了,稽核就不是敵人

這篇要解一道很具體的題:手邊有一本 700 頁的機密機械流程 SOP,是公司的資產,規定嚴到——書不能拆、進工作間不能帶手機不能拍照、書不能帶出來、只能帶紙筆進出、出來時紙還要被稽核。你想把它變成能被 AI 查詢的流程文件 RAG,該怎麼做?這是把 GraphRAG 落到一個「輸入通道被限成一支筆」的真實極端情境。

🧭 本文導覽
Part 一 — 這道題:限制、轉念、路線
Part 二 — 進去怎麼抄
Part 三 — 出來怎麼建圖
Part 四 — 邊界
Part 五 — 附錄:程式碼
PART 一 — 這道題:限制、轉念、路線

一本不能拍照的書:限制長什麼樣

先把限制攤開,因為每一條都砍掉一種常見做法:

限制 砍掉的做法
書不能拆 🚫 破壞式掃描(裁書脊進饋紙機)
不能帶手機、不能拍照 🚫 翻拍 App、OCR 數位化
書不能帶出來 🚫 帶回家慢慢處理
只能帶紙筆、出來要稽核 ⚠️ 你能帶出的只有「手寫的東西」,而且會被檢查
申請就能進、時間不限 ✅ 可以多趟、慢慢來

最後一條是唯一的好消息:時間不是瓶頸,你可以分很多趟。真正的瓶頸是那條被限成一支筆的輸入通道——所以整個策略,就是最大化每一頁紙的資訊密度

第一個轉念:不是抄書,是萃取骨架

直覺會想「那我就一頁一頁手抄」。700 頁純手抄不值得做——那是好幾週的工、會抄錯,錯了 RAG 就跟著錯,而且逐字謄寫機密內容根本過不了稽核。

正確目標是:用人腦當感測器,把 700 頁壓縮成一疊能過稽核的手寫骨架,出來後重建成知識庫。你帶出來的每張紙,目的是在外面觸發你重建,不是取代那本書。這裡有一個關鍵技巧:

每趟出來當天立刻打字擴寫。
手寫的是壓縮骨架(箭頭、關鍵詞、數字),你的記憶補完剩下 70%。隔天記憶就掉了。所以「進去抄 → 出來當天擴寫」是一組動作,不能拆開。這就是為什麼手抄 700 頁可行:紙不必裝下全書,只要裝下能勾起記憶的錨點。

為什麼流程文件該進圖,不是向量 RAG

在動手前先判斷路線。一般向量 RAG 是「切塊 → 向量化 → 找最像的塊」,對「請假規定是什麼」這種知識問答很好。但流程文件的價值不在某段文字,而在關係——向量找不到「順序」、找不到「誰負責」、找不到「什麼條件跳哪站」。

一套 80 站的機械流 SOP,正是知識圖 / GraphRAG 的甜蜜點。用這張決策樹判斷就清楚:

① 一份文件、一段就能答嗎?
是 → 不用 RAG,長 context / CAG 就好
否 → ② 價值在「跨步驟、跨角色的關係」嗎?
否(純知識查詢)→ 向量 RAG
是(步驟 / 順序 / 誰負責 / 條件跳站)→ 知識圖 / GraphRAG

SOP 幾乎必然落在最後一格。要注意的反面是:對「一本散文知識書」GraphRAG 常常 overkill(建圖貴、簡單查詢反而更差)——但這裡不是散文書,是高度結構化的站點流程,所以圖法穩贏。

PART 二 — 進去怎麼抄

帶一個固定模板:四格站點卡

SOP 的結構高度規則:80 站 × 每站同樣幾個欄位。這代表你不是在「抄書」,是在「填 80 次同一張表」。表格式的東西,手抄又快又不會漏。進去前,腦子裡帶好這個固定模板,每個站填這幾格:

格子 抄什麼 建圖後變成
站號 / 站名 / 上下站 流向:上站 → 本站 → 下站 節點 + ST_NEXT 邊
做什麼 Action 這站的動作 節點屬性
動什麼 Operate 部件 → 動作 → 目標值 節點屬性
⚠ 注意什麼 Caution 警告、門檻、連鎖條件 節點屬性(最重要)
分支 / 條件 若 ___ → 跳站 ___ ST_BRANCH 邊

「注意什麼」是全書的皇冠珠寶——「做什麼 / 動什麼」常常是標準動作,「注意什麼」才是老師傅用事故換來的 tribal knowledge(扭力多少、超過幾度材質劣化、爐門沒關禁止啟動)。這格抄最細、最原話。

分趟策略:先畫地圖,再逐站深挖

因為時間不限、可以多趟,就別想一趟啃完。分兩種趟:

  1. 第一趟 = 只畫流程骨架。走完 80 站,每站只抄四樣:站號 / 站名 / 上站→下站 / 一句話這站幹嘛。目的是抓出整條流的形狀,尤其揪出分支、迴圈、返工、並行——這些是散文 SOP 最愛藏、卻是圖法唯一贏向量 RAG 的地方。出來當天打成 80 節點的流程圖,你就有全局地圖。
  2. 第二趟起 = 逐站填滿卡片。拿著地圖,一站一站填完整四格。優先順序:①分支點 ②安全 / 品質關鍵站 ③你工作真的會碰的站。

為什麼地圖先行?80 站如果從第 1 站就深挖,可能還沒挖完就沒力氣、也看不到全貌。先拿到骨架,才能安排優先序、並且提早發現結構驚喜(第 60 站原來會跳回第 34 站,這會改變你讀前面 59 站的方式)。

照抄 vs 簡寫:事實與判斷

手寫速度有限,要分清楚哪些照抄、哪些簡寫。判準是「事後重建得出來嗎」:

類型 怎麼處理 例子
事實(記不回來) 照抄,漏一個永遠補不回 扭力、溫度、壓力、料號、門檻、簽核級數、注意事項原話
判斷(記得回來) 簡寫,關鍵詞 + 箭頭,回家補 標準動作的描述、流程大意

這正是正道腦的核心切法:事實是地基不會背叛,判斷會朽。概念你回家 Google 得到,但「這步驟門檻 30 天、簽核三級」這種公司特有數字,漏抄就永遠補不回。

PART 三 — 出來怎麼建圖

一列 CSV 怎麼變成圖上一個站

出來之後回到熟悉的地盤。整條鏈四段:CSV(一列一站)→ Python 切成參數 → Cypher 用 MERGE 寫進 Neo4j → 圖成形。用一個有分支的站(檢驗分流)走一遍。

① 你準備的資料:CSV 的一列

no,name,next,action,operate,caution,branch_cond,branch_to,output,note
03,檢驗分流,04,量測外徑,量規 → 測 → Ø50.0±0.1mm,量規未歸零讀值失準,外徑超差,05,尺寸紀錄表,分支站

你要準備的就這樣:一列一站、逗號分欄;action / operate / caution 一格塞多項時用 | 隔開。人只做這一步——把手寫卡片打成 CSV。

② 透過程式:Python 讀成參數

import csv
SEP = "|"

def split_list(v):
    return [x.strip() for x in (v or "").split(SEP) if x.strip()]

# csv.DictReader 讀一列 → 多值欄位切成 list
action  = split_list("量測外徑")            # ["量測外徑"]
operate = split_list("量規 → 測 → Ø50.0±0.1mm")
caution = split_list("量規未歸零讀值失準")   # ["量規未歸零讀值失準"]

到這裡,一列文字變成了一包乾淨參數,程式還沒碰資料庫。

③ 怎麼寫入 Neo4j:參數 + Cypher

分兩趟——先建所有節點,再建邊,這樣邊的另一端保證存在。第一趟把參數代進 upsert:

MERGE (s:Station {no: "03"})              // 沒有就建、有就沿用(不重複)
SET s.name    = "檢驗分流",
    s.action  = ["量測外徑"],
    s.operate = ["量規 → 測 → Ø50.0±0.1mm"],
    s.caution = ["量規未歸零讀值失準"]       // list 直接掛節點上

第二趟用 nextbranch_to 兩欄建邊:

// 順序邊:03 → 04
MATCH (a:Station {no:"03"})
MERGE (b:Station {no:"04"})               // 04 還沒建也沒關係,先補空殼
MERGE (a)-[:ST_NEXT]->(b)

// 分支邊:03 → 05,條件寫在邊上
MATCH (a:Station {no:"03"})
MERGE (b:Station {no:"05"})
MERGE (a)-[r:ST_BRANCH]->(b)
SET r.condition = "外徑超差"
為什麼是 MERGE 不是 MATCH?
下一站可能你下一趟才抄到。用 MATCH 找不到 04 → 那句靜默跳過、邊永遠建不出來,而且不報錯MERGE 會先補一個只有 no 的空殼站,等你真的抄到 04 時再 SET 補上名字。這是「壞了卻不報錯」最陰的一種坑。

完成的圖 + 自動驗證缺哪些卡

範例 6 站跑完,流程圖長這樣——藍色主鏈是順序,站 03 岔出分支到 05,站 05 又接回 03 形成返工迴圈:

01 上料 02 預熱 03 檢驗 04 精加工 06 終檢(末站)
03 ┈ ST_BRANCH「外徑超差」┈▸ 05 返工 ┈ ST_NEXT ┈▸ 回 03(返工迴圈)

這條返工迴圈,就是散文 SOP 最會藏、向量 RAG 抓不到、而圖一眼就看到的東西。跑完程式還會印出驗證,順便當作跨趟的 TODO 清單:

Station nodes : 6
ST_NEXT edges : 6
ST_BRANCH     : 1
dead-ends (no next out): ['06']     <- 只有末站,對
isolated: none                       <- 沒有孤兒站,對
!! CARDS STILL MISSING: ['47','48']  <- 被指到但還沒抄 = 下一趟 TODO

那個 MISSING 清單特別實用:被 next / branch 指到、但你還沒抄的站(名字是空的),就是下一趟該進去補哪幾張卡。跨趟迭代直接看它收斂到空為止。

圖建好之後,包成 MCP 服務,agent 問「站 47 要注意什麼」就是對這張圖一句 MATCH 秒回、還帶出處(那條查詢本身就是 provenance)。檢索邏輯寫一次,所有 agent 共用同一張圖。

PART 四 — 邊界

稽核紅線:別夾帶,合法筆記自然過關

有一條紅線一定要釘死:別想把原文逐字帶出,也別用密碼 / 微縮字夾帶。稽核就是防這個,會被抓;而且逐字內容對建知識庫根本不是最好的原料

你要帶出的是「你消化後的結構」——節點、箭頭圖、關鍵數字。這種東西天生又精簡、又明顯是讀書筆記不是影本,自然過得了稽核。方向對了,稽核就不是敵人:你在合法範圍內做的事(領域專家為工作學習、做筆記),剛好就是產出最好 RAG 原料的方式。用箭頭 / 節點的心智圖取代整段句子,密度高 10 倍,而且一看就是筆記。

⚠ 重建出來的圖本身就是公司機密 IP。放公司內網 / on-prem Neo4j,存取控管,別進個人雲。萃取合法,不代表產物可以外流。

什麼時候別這樣做

這套是為「結構化流程 + 你有合法存取權 + 值得長期反覆查」設計的。反過來,遇到這些情況先別建圖:

  • 只查幾次:與其花好幾趟建圖,不如把地圖那層抄出來、問幾個具體問題就好。知識庫是「結果」不是「起點」——先用出效益,再收整成庫
  • 內容是散文知識、不是流程:那是向量 RAG 的活,GraphRAG 會 overkill。
  • 沒有合法存取權:這整套的前提是「你能申請進去、可以合法做筆記」。沒有授權,不要碰。

但只要命中那三個條件,一套 80 站的 SOP 就是最理想的對象:進去把它壓成骨架,出來重建成一張會被 agent 反覆查詢的流程圖。萃取通道被限成一支筆,方法卻一點不含糊。

PART 五 — 附錄:完整程式碼

建圖程式:load_stations.py

把打好的 stations.csv 灌進 Neo4j。兩趟:先建全部節點、再建邊(MERGE 不用 MATCH,下一站可能後面才抄到);跑完印出還缺哪些卡。

#!/usr/bin/env python3
"""Load hand-captured SOP station cards (stations.csv) into Neo4j as a process graph.

One CSV row = one :Station node. Multi-value fields (action/operate/caution) are
split on " | " and stored as list properties (denormalized on purpose: keeps
"what to watch at station 47" a single MATCH, no hop -- small models write 1-2 hop
Cypher reliably, 3-hop they don't).

Edges built:
  (:Station)-[:ST_NEXT]->(:Station)                 flow order
  (:Station)-[:ST_BRANCH {condition}]->(:Station)   conditional jump / rework / loop

After loading it verifies flow integrity and prints which cards are still MISSING
(referenced by a next/branch but never captured) -- doubles as a cross-trip TODO list.

Env:  NEO4J_URI (default bolt://localhost:7687) | NEO4J_USER (default neo4j) | NEO4J_PASSWORD (required)
Run:  pip install neo4j ;  NEO4J_PASSWORD=... python load_stations.py [stations.csv]
"""
import csv
import os
import sys
import atexit
from neo4j import GraphDatabase

SEP = "|"                 # multi-value separator inside a single field
CSV_DEFAULT = "stations.csv"

_driver = None
def driver():
    """Module-level lazy singleton -- reuse one connection pool, close on exit."""
    global _driver
    if _driver is None:
        uri = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
        user = os.environ.get("NEO4J_USER", "neo4j")
        pw = os.environ.get("NEO4J_PASSWORD")
        if not pw:
            sys.exit("NEO4J_PASSWORD not set (export it; do not hardcode in this file)")
        _driver = GraphDatabase.driver(uri, auth=(user, pw), connection_timeout=15)
        atexit.register(_driver.close)
    return _driver


def split_list(v):
    return [x.strip() for x in (v or "").split(SEP) if x.strip()]


def load_rows(path):
    with open(path, encoding="utf-8-sig", newline="") as f:      # utf-8-sig strips Excel BOM
        rows = []
        for r in csv.DictReader(f):
            no = (r.get("no") or "").strip()
            if not no or no.startswith("#"):                     # skip comment / blank lines
                continue
            rows.append(r)
    return rows


UPSERT = """
MERGE (s:Station {no: $no})
SET s.name = $name, s.action = $action, s.operate = $operate,
    s.caution = $caution, s.output = $output, s.note = $note
"""
# MERGE (not MATCH) the target: the next/branch station may be captured in a later
# row or a later trip. MATCH would silently no-op and leave the edge unbuilt.
NEXT = """
MATCH (a:Station {no: $a})
MERGE (b:Station {no: $b})
MERGE (a)-[:ST_NEXT]->(b)
"""
BRANCH = """
MATCH (a:Station {no: $a})
MERGE (b:Station {no: $b})
MERGE (a)-[r:ST_BRANCH]->(b)
SET r.condition = $cond
"""


def load(path):
    rows = load_rows(path)
    if not rows:
        sys.exit(f"No station rows in {path} (did you delete the # comment lines?)")
    with driver().session() as s:
        for r in rows:                       # pass 1: all nodes first
            no = r["no"].strip()
            s.run(UPSERT, no=no,
                  name=(r.get("name") or "").strip(),
                  action=split_list(r.get("action")),
                  operate=split_list(r.get("operate")),
                  caution=split_list(r.get("caution")),
                  output=(r.get("output") or "").strip(),
                  note=(r.get("note") or "").strip())
        for r in rows:                       # pass 2: edges (targets now guaranteed present)
            no = r["no"].strip()
            nxt = (r.get("next") or "").strip()
            if nxt and nxt != "-":
                s.run(NEXT, a=no, b=nxt)
            bt = (r.get("branch_to") or "").strip()
            if bt:
                s.run(BRANCH, a=no, b=bt, cond=(r.get("branch_cond") or "").strip())
    return len(rows)


def verify():
    q = {
        "stations":  "MATCH (s:Station) RETURN count(s) AS n",
        "next_edges":"MATCH ()-[r:ST_NEXT]->() RETURN count(r) AS n",
        "branches":  "MATCH ()-[r:ST_BRANCH]->() RETURN count(r) AS n",
    }
    with driver().session() as s:
        counts = {k: s.run(v).single()["n"] for k, v in q.items()}
        # referenced by an edge but never captured -> a card you still owe (name is null)
        missing = [r["no"] for r in s.run(
            "MATCH (s:Station) WHERE s.name IS NULL OR s.name = '' RETURN s.no AS no ORDER BY no")]
        # no outgoing ST_NEXT -> should be exactly the final station; more = flow gaps
        deadends = [r["no"] for r in s.run(
            "MATCH (s:Station) WHERE NOT (s)-[:ST_NEXT]->() AND (s.name IS NOT NULL AND s.name<>'') "
            "RETURN s.no AS no ORDER BY no")]
        # fully isolated (no next in or out) -> almost always a mistake
        isolated = [r["no"] for r in s.run(
            "MATCH (s:Station) WHERE NOT (s)-[:ST_NEXT]->() AND NOT (s)<-[:ST_NEXT]-() "
            "RETURN s.no AS no ORDER BY no")]

    print(f"\n  Station nodes : {counts['stations']}")
    print(f"  ST_NEXT edges : {counts['next_edges']}")
    print(f"  ST_BRANCH     : {counts['branches']}")
    print(f"  dead-ends (no next out): {deadends or 'none'}   <- expect exactly the last station")
    print(f"  isolated (no next at all): {isolated or 'none'}   <- should be empty")
    if missing:
        print(f"\n  !! CARDS STILL MISSING (referenced but not captured): {missing}")
        print(f"     -> these are your next-trip TODO list.")
    else:
        print("\n  OK: every referenced station has a captured card.")
    assert counts["stations"] > 0, "no stations loaded"
    assert counts["next_edges"] > 0, "no ST_NEXT edges built -- check the 'next' column"


if __name__ == "__main__":
    path = sys.argv[1] if len(sys.argv) > 1 else CSV_DEFAULT
    n = load(path)
    print(f"Loaded {n} station rows from {path}")
    verify()

向量 RAG 程式:rag_min.py(完整循環)

手冊那半的最小可跑版 —— 向量庫就是一個 list、cosine 自己算,把「原文→embedding→存」「問題→embedding→找最近→拉出」「拉出→餵 LLM→生成」整條看得一清二楚。正式版換成 pgvector 見 HR 站程式頁

#!/usr/bin/env python3
"""rag_min.py — 向量 RAG 最小可跑範例,對照你講的那個循環。

  【建庫】  原文一段一段 → embedding → 存(向量 + 原文)
  【R 檢索】問題 → embedding → 找最接近的 top-k → 拉出原文
  【A+G】   拉出來 → 塞進 prompt → LLM 依據它生成答案 + 出處

零基礎設施:向量庫就是一個 list、cosine 自己算(看得見數學)。全本地。
前置: ollama pull bge-m3 ; ollama pull qwen2.5:7b ; pip install ollama
跑:   python rag_min.py "軸承過熱怎麼查?"
"""
import sys, math, ollama

EMBED = "bge-m3"        # 多語 embedding,1024 維(中英混雜)
GEN   = "qwen2.5:7b"    # 生成答案(可換)

# 原文:一段一段(手冊的每個站台條目)
DOCS = [
    ("車床 B12",     "主軸 2000rpm,夾持氣壓 0.5MPa。軸承型號 6205。主軸過熱 → 查潤滑油位與冷卻。每 500 小時換油。"),
    ("銑床 B07",     "進給 0.1mm/rev,刀具 T03。軸承型號 6205。加工振動過大 → 查刀具夾持與軸承磨耗。每 300 小時檢查軸承。"),
    ("熱處理爐 C03", "設定溫度 850±10°C,爐門電子連鎖。溫度不均 → 校正熱電偶。超過 870°C 材質劣化。每季校正溫控。"),
]

def embed(text):
    return ollama.embeddings(model=EMBED, prompt=text)["embedding"]     # 文字 → 向量

def cosine(a, b):                                                       # 兩向量有多接近(越接近 1 越像)
    dot = sum(x * y for x, y in zip(a, b))
    na  = math.sqrt(sum(x * x for x in a))
    nb  = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb)

# ---------- 【建庫】原文 → embedding → 存(向量 + 原文 兩份一起存)----------
STORE = [{"name": n, "text": t, "vec": embed(t)} for n, t in DOCS]      # 向量庫 = 一個 list
print(f"建庫:{len(STORE)} 段,每段一個 {len(STORE[0]['vec'])} 維向量\n")

def rag(question, k=2):
    # ---------- 【R 檢索】問題也 embed → 算相似度 → 拉最接近 top-k ----------
    qv = embed(question)
    ranked = sorted(STORE, key=lambda d: cosine(qv, d["vec"]), reverse=True)
    print("相似度排名:")
    for d in ranked:
        print(f"  {cosine(qv, d['vec']):.3f}  {d['name']}")
    hits = ranked[:k]                                                   # 拉出來的原文(top-k)
    print(f"\n拉出前 {k} 段 → {', '.join(h['name'] for h in hits)}\n")

    # ---------- 【A 增強 + G 生成】拉出來塞進 prompt → LLM 只依據它作答 ----------
    context = "\n".join(f"【{h['name']}】{h['text']}" for h in hits)
    prompt  = ("只依據以下手冊內容回答,不要編造,並標明答案出自哪個站台。\n\n"
               f"{context}\n\n問題:{question}")
    resp = ollama.chat(model=GEN, messages=[{"role": "user", "content": prompt}])
    return resp["message"]["content"]

if __name__ == "__main__":
    q = " ".join(sys.argv[1:]) or "軸承過熱怎麼查?"
    print(f"問題:{q}\n")
    print("答案:\n" + rag(q))

實跑輸出 + 互動 demo

用真的 embedding(bge-m3 1024 維)+ 真的 LLM(qwen2.5:7b)跑出來:「過熱」在 B12 原文命中最高 0.718 = 語義而非關鍵字。第二題「哪些機台用 6205」這次對,是因為只有 3 段、k=2 剛好都撈到;真到 80 站有多台用 6205,top-k 會漏 → 這才是上 hybrid 關鍵字的時機。

問題:軸承過熱怎麼查?
相似度排名(找最接近的 vector):
  0.718  車床 B12      ← 最高
  0.602  銑床 B07
  0.573  熱處理爐 C03
拉出前 2 段 → 車床 B12, 銑床 B07
答案:主軸過熱應「查潤滑油位與冷卻」。出自【車床 B12】。

問題:哪些機台用 6205 軸承?
相似度排名:
  0.585  車床 B12
  0.523  銑床 B07
  0.314  熱處理爐 C03   ← 沒用 6205,明顯低
拉出前 2 段 → 車床 B12, 銑床 B07
答案:6205 軸承用於 車床 B12、銑床 B07。
▶ 互動走一遍(點書 → pipeline → 打問題看檢索):
hrs.tomting.com/static/rag-explainer.html · 完整 sample code

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *