跳到主要內容

NLP Day 41 專案定義與知識庫整備

NLP Day 41 專案定義與知識庫整備

執行需求:CPU 可跑。今天是專案五篇(Day 41–45)的第一篇,要把「企業知識庫問答」這個貫穿專案的問題框架、語料規格、共用設定一次定下來,並把 Day 31–32 已經搭好的檢索層正式收斂成可被後續四篇共用的 `project_config.py`。我們沿用「個人資料保護法 + 施行細則」這份公開法規(來源:法務部全國法規資料庫 `https://law.moj.gov.tw/`,授權:政府資料開放授權條款—第 1 版)、用 `intfloat/multilingual-e5-small` 做嵌入、用 Chroma 0.6.x 做向量索引;後續 Day 42(檢索最佳化)、Day 43(評估)、Day 44(部署展示)、Day 45(系列總結)會完整沿用本篇的設定檔,不會在中途改參數。整個專案五篇都在 CPU 跑得動;Colab T4 留給 Day 42、43 的檢索實驗。

引言

Day 31–32 我們用兩天把企業知識庫問答的「檢索層」與「生成 + 評估層」寫完,那兩篇用的是「個人資料保護法」(後簡稱個資法)這份真實的公開法規。今天開始的專案五篇,要把那套原型收斂成 production-ready 的最小可行產品:定義清楚的問題、設定固定的共用參數、寫好可重現的檢索介面、準備好評估資料集。本篇不寫新的演算法,只把既有的東西「鎖定」,讓後續四篇可以無痛接力。

貫穿專案的共用設定(Day 41–45 全文沿用,不要在後續篇改這裡):語料為「個人資料保護法」加「個人資料保護法施行細則」,來源為法務部全國法規資料庫 `https://law.moj.gov.tw/LawClass/LawAll.aspx?pcode=I0050021` 與 `https://law.moj.gov.tw/LawClass/LawAll.aspx?pcode=I0050022`,授權為政府資料開放授權條款—第 1 版;影像(其實是文本)切分 chunk_size=500、chunk_overlap=60;嵌入模型 `intfloat/multilingual-e5-small`(CC BY-NC,384 維);向量資料庫為 Chroma 0.6.x;LLM 為 GPT-4o-mini 或 Ollama qwen2.5:7b;檢索 top_k=4、score_threshold=0.30;評估指標 recall@4、MRR、忠實度、引用正確性;SEED=42。這些設定集中寫在 `project_config.py`,後續四篇直接 `from project_config import *`。

專案目標與範圍

這個專案的目標是「讓企業內部同仁可以自然語言查詢個資法條文」。具體情境:法務、人資、客服等部門遇到「個資法第 X 條規定是什麼」、「我們能不能這樣處理客戶資料」這類問題時,可以打開問答系統輸入問題,系統回傳「依個資法第 5 條與第 19 條規定...」,並標出引用來源條文。系統不需要回答「一般法律常識」、不需要查詢其他法規、不需要串接企業內部客戶資料;只要能根據個資法(與施行細則)的條文回答使用者的問題,就算完成目標。

範圍的設定是工業界常被低估的環節。明確定義「系統做什麼、不做什麼」可以避免後續開發時的範圍蔓延(scope creep)。我們的範圍界定如下:

  • 包含:個資法與施行細則全文、條文檢索、自然語言問答、引用條號標註、忠實度與引用正確性評估、FastAPI 服務化、Streamlit 展示頁。
  • 不包含:其他法律(個資法以外的法規)、企業內部資料(員工個資、客戶資料)、多輪對話(每輪獨立、不延續前次脈絡)、即時更新(每月重新抓一次即可,不做 streaming ingest)、權限控管(單一租戶、所有人都能查所有條文)。

這個範圍設定對應 Day 31 的「單一語料 + Chroma + 4 個段落 + LLM 生成」架構;超出範圍的功能(例如多輪對話)會在延伸學習資源中提示,不會在本專案篇中實作。把範圍寫死的好處是:後續四篇不會因為「這個應不應該做」而猶豫,每個功能都有明確的「做 vs 不做」答案。

共用設定檔 project_config.py

把所有會跨篇章共用的參數集中到 `project_config.py`,是工業界最常見也最有效的做法。這個檔案包含語料路徑、嵌入模型名稱、向量資料庫位置、LLM 介面、檢索參數、評估指標——任何篇章要改參數都只動這個檔。後續 Day 42 會用 `from project_config import *` 載入;Day 44 部署時把這個檔變成環境變數或 CLI 引數,可在不改程式碼的情況下切換不同環境(dev / staging / prod)。

# project_config.py(後續四篇直接 from project_config import *)
import os
import random
from pathlib import Path

random.seed(42)                                    # 固定隨機種子,可重現

# ---- 語料來源 ----
LAW_URL_MAIN = "https://law.moj.gov.tw/LawClass/LawAll.aspx?pcode=I0050021"     # 個資法
LAW_URL_SUB  = "https://law.moj.gov.tw/LawClass/LawAll.aspx?pcode=I0050022"     # 施行細則
LAW_LICENSE  = "政府資料開放授權條款—第 1 版"

# ---- 本地檔案路徑 ----
DATA_DIR       = Path("./data")
RAW_TEXT_PATH  = DATA_DIR / "pdpa_full.txt"
CHROMA_DIR     = Path("./chroma_db")
COLLECTION_NAME = "pdpa_main"
EVAL_SET_PATH   = DATA_DIR / "eval_set.json"
LOG_PATH        = DATA_DIR / "inference_log.csv"
DATA_DIR.mkdir(parents=True, exist_ok=True)

# ---- 文本切分(與 Day 31 一致)----
CHUNK_SIZE = 500
CHUNK_OVERLAP = 60
SPLIT_SEPARATORS = ["。", ";", ",", " ", ""]

# ---- 嵌入模型 ----
EMBEDDING_MODEL = "intfloat/multilingual-e5-small"   # 384 維,CC BY-NC
EMBEDDING_DEVICE = "cpu"

# ---- 檢索參數 ----
TOP_K = 4
SCORE_THRESHOLD = 0.30                # cosine 距離,> 0.30 才納入回傳

# ---- LLM 介面 ----
LLM_BACKEND = "openai"                # 或 "ollama"
LLM_MODEL_NAME = "gpt-4o-mini"        # 或 "qwen2.5:7b"
LLM_TEMPERATURE = 0

# ---- 評估指標 ----
EVAL_METRICS = ["recall@4", "mrr", "faithfulness", "citation_precision"]
SEED = 42

print("project_config 已載入")
print(f"  語料來源:個資法 + 施行細則({LAW_LICENSE})")
print(f"  嵌入模型:{EMBEDDING_MODEL}")
print(f"  向量資料庫:Chroma 0.6.x @ {CHROMA_DIR}")
print(f"  檢索 top_k={TOP_K},score_threshold={SCORE_THRESHOLD}")
# 輸出:
# project_config 已載入
#   語料來源:個資法 + 施行細則(政府資料開放授權條款—第 1 版)
#   嵌入模型:intfloat/multilingual-e5-small
#   向量資料庫:Chroma 0.6.x @ ./chroma_db
#   檢索 top_k=4,score_threshold=0.30

這段定義了後續四篇會用到的所有常數與路徑。`DATA_DIR` 用 `Path` 而不是字串,方便跨平台;`LAW_URL_MAIN` 與 `LAW_URL_SUB` 是真實可存取的法務部網址(這兩支 URL 在 2025 年 3–5 月都還有效);`CHUNK_SIZE` 與 `CHUNK_OVERLAP` 沿用 Day 31 的設定(500/60),不要在本專案篇中途改這個值。`SCORE_THRESHOLD = 0.30` 是 Day 42 會用到的「距離閾值」,大於這個距離的段落會被過濾掉,避免把不相關的段落也丟給 LLM。`LLM_BACKEND` 與 `LLM_MODEL_NAME` 是切換 OpenAI 與 Ollama 的介面,後續 Day 44 部署時會用到。

沿用 Day 31 的檢索層

Day 31 我們已經寫好完整的檢索層(讀檔 → 條號切分 → 長度切分 → Chroma 索引 → retrieve 函式)。本篇把那段程式碼原封不動搬過來,並把它與 `project_config.py` 整合,讓後續篇章可以直接 import。

# 沿用 Day 31 的檢索層(檔案:retrieval.py)
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
import chromadb
from chromadb.utils import embedding_functions

from project_config import (RAW_TEXT_PATH, CHROMA_DIR, COLLECTION_NAME,
                            CHUNK_SIZE, CHUNK_OVERLAP, SPLIT_SEPARATORS,
                            EMBEDDING_MODEL, TOP_K, SCORE_THRESHOLD)

# 條號正則(與 Day 31 一致)
ARTICLE_RE = re.compile(r"(第\s*[一二三四五六七八九十百零]+\s*條(?:之[一二三四五六七八九十]+)?)")

def split_by_article(text: str) -> list[dict]:
    """以條為單位切分;每塊保留條號。"""
    parts = ARTICLE_RE.split(text)
    chunks = []
    for i in range(1, len(parts), 2):
        article = parts[i].strip()
        body = parts[i + 1].strip() if i + 1 < len(parts) else ""
        chunks.append({"article": article, "text": f"{article}{body}"})
    return [c for c in chunks if c["text"]]

def build_index() -> chromadb.api.models.Collection:
    """建立 Chroma 索引;若已存在則直接載入"""
    emb_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
        model_name=EMBEDDING_MODEL,
    )
    client = chromadb.PersistentClient(path=str(CHROMA_DIR))
    collection = client.get_or_create_collection(
        name=COLLECTION_NAME,
        embedding_function=emb_fn,
        metadata={"hnsw:space": "cosine"},
    )
    if collection.count() > 0:
        return collection                                # 已建立,直接回傳

    raw_text = RAW_TEXT_PATH.read_text(encoding="utf-8")
    articles = split_by_article(raw_text)
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP, separators=SPLIT_SEPARATORS,
    )
    chunks = []
    for art in articles:
        for piece in splitter.split_text(art["text"]):
            chunks.append({"article": art["article"], "text": piece})
    collection.add(
        ids=[f"chunk-{i}" for i in range(len(chunks))],
        documents=[c["text"] for c in chunks],
        metadatas=[{"article": c["article"]} for c in chunks],
    )
    return collection

def retrieve(query: str, collection, k: int = TOP_K) -> list[dict]:
    """以問題字串查詢,回傳前 k 個最相關的條文(已過濾 score_threshold)"""
    res = collection.query(query_texts=[query], n_results=k)
    hits = []
    for i in range(len(res["ids"][0])):
        distance = res["distances"][0][i]
        if distance > SCORE_THRESHOLD:
            continue                          # 太不相關就過濾
        hits.append({
            "chunk_id": res["ids"][0][i],
            "article": res["metadatas"][0][i]["article"],
            "text": res["documents"][0][i],
            "distance": distance,
        })
    return hits

print("retrieval.py 已沿用 Day 31 的檢索層")
# 輸出:retrieval.py 已沿用 Day 31 的檢索層

這段把 Day 31 的 `split_by_article`、`build_index`、`retrieve` 三個函式原封不動搬過來,但改成從 `project_config` 讀參數。`build_index` 是冪等的(idempotent):第一次跑會建立索引、後續跑會直接讀磁碟上的 `chroma_db` 目錄,這樣重啟 Colab 不需要重新嵌入。`retrieve` 在 Day 31 的基礎上加入 `SCORE_THRESHOLD` 過濾:距離大於 0.30 的段落視為太不相關、直接丟棄,這個設計在 Day 42 評估時會驗證有效性。

Day 31 的 `retrieve` 是裸 Chroma 介面(回傳 dict 串列),這個設計避免了把 Chroma 物件傳到處處的耦合問題。本專案篇的 `retrieve` 介面與 Day 31 完全一致,後續 Day 42、43、44 會沿用同一個函式。

評估資料集:10 個常見問題

Day 32 我們用過 10 個問題做 A/B 測試,得到的引用正確率從 0.58 提升到 0.94。本篇把這個評估資料集正式化、寫成 `eval_set.json`,後續 Day 43 會完整跑這 10 個問題的評估。每個問題包含問題字串、預期相關的條號清單(用於算 recall@k)、以及(Day 43 會用到的)參考答案。

# eval_set.json 的內容(Day 41 寫出、Day 43 評估用)
import json
from project_config import EVAL_SET_PATH

EVAL_SET = [
    {
        "qid": "q01",
        "question": "雇主可以查看員工的 email 嗎?",
        "relevant_articles": ["第 5 條", "第 19 條"],
        "reference_answer": "依個資法第 5 條與第 19 條,雇主非公務機關蒐集、處理或利用員工個人資料,應有特定目的並符合法定情形;單純查看員工 email 屬個人資料的利用行為,應依個資法規定處理。",
    },
    {
        "qid": "q02",
        "question": "個資法對告知義務有什麼規定?",
        "relevant_articles": ["第 8 條"],
        "reference_answer": "個資法第 8 條規定,公務機關或非公務機關於蒐集個人資料時,應明確告知當事人蒐集目的、資料類別、利用期間等事項。",
    },
    {
        "qid": "q03",
        "question": "當事人可以要求刪除個資嗎?",
        "relevant_articles": ["第 11 條"],
        "reference_answer": "依個資法第 11 條,當事人有權請求刪除、停止蒐集、處理或利用其個人資料;公務機關或非公務機關於特定情形下應主動或依請求刪除。",
    },
    {
        "qid": "q04",
        "question": "個人資料的定義是什麼?",
        "relevant_articles": ["第 2 條"],
        "reference_answer": "個資法第 2 條定義個人資料為自然人之姓名、出生年月日、國民身分證統一編號、護照號碼、特徵、指紋、婚姻、家庭、教育、職業、病歷、醫療、基因、性生活、健康檢查、聯絡方式、財務情況、社會活動及其他得以直接或間接方式識別該個人之資料。",
    },
    {
        "qid": "q05",
        "question": "公務機關與非公務機關的差別?",
        "relevant_articles": ["第 2 條", "第 7 條"],
        "reference_answer": "個資法第 2 條定義公務機關為依法行使公權力之中央或地方機關;非公務機關則指公務機關以外之自然人、法人或其他團體。第 7 條規範非公務機關的登記與安全維護義務。",
    },
    {
        "qid": "q06",
        "question": "違反個資法會被罰多少?",
        "relevant_articles": ["第 47 條", "第 48 條", "第 50 條"],
        "reference_answer": "個資法第 47 條至第 50 條規範罰則,包含罰鍰金額(5 萬至 50 萬、50 萬至 5,000 萬不等)、刑事責任與民事損害賠償。",
    },
    {
        "qid": "q07",
        "question": "敏感性個人資料包含哪些?",
        "relevant_articles": ["第 6 條"],
        "reference_answer": "個資法第 6 條定義敏感性個人資料包括病歷、醫療、基因、性生活、健康檢查及犯罪前科等。",
    },
    {
        "qid": "q08",
        "question": "委託他人處理個資要注意什麼?",
        "relevant_articles": ["第 4 條", "第 8 條", "第 9 條"],
        "reference_answer": "個資法第 4 條與第 8 條規定,受託人處理個人資料視同委託機關蒐集處理;委託機關應對受託人為適當之監督,並應明確告知當事人受託情形。",
    },
    {
        "qid": "q09",
        "question": "個人資料保護委員會的職權?",
        "relevant_articles": ["第 22 條", "第 40 條"],
        "reference_answer": "個資法第 22 條與第 40 條規範個人資料保護委員會的組成、職權與審議制度。",
    },
    {
        "qid": "q10",
        "question": "個資法施行細則的重點?",
        "relevant_articles": ["施行細則第 1 條"],
        "reference_answer": "個資法施行細則補充說明個資法各條的細節執行方式,包含告知義務的具體方式、安全維護的具體措施等。",
    },
]

EVAL_SET_PATH.write_text(json.dumps(EVAL_SET, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"評估資料集已寫出:{EVAL_SET_PATH}({len(EVAL_SET)} 題)")
# 輸出:評估資料集已寫出:./data/eval_set.json(10 題)

這 10 個問題覆蓋了個資法的核心場景:員工資料、告知義務、刪除權、定義、公務 vs 非公務、罰則、敏感性資料、委託處理、委員會、施行細則。每個問題的 `relevant_articles` 是預期應該被檢索到的條號清單(人工標註,與 Day 32 一致),用於 Day 43 計算 recall@4 與 MRR;`reference_answer` 是參考答案,Day 43 評估忠實度時會用 LLM 比對。

注意這份評估資料集只用 10 題,是工業界「快速迭代」的甜蜜點:太多會讓評估時間過長、太少又無法反映真實分布。10 題足以看趨勢、不夠看機率顯著性。如果未來要做更嚴格的評估,可以擴充到 50 題並切出 train/test split。

專案目錄結構

把設定檔、檢索層、評估集、測試程式與部署檔放在標準化的目錄結構,方便後續四篇接力。建議的結構如下:

nlp_project/
├── project_config.py       # 共用設定檔(Day 41 寫出)
├── retrieval.py            # 檢索層(沿用 Day 31)
├── generation.py           # 生成層(沿用 Day 32)
├── evaluation.py           # 評估指標(Day 43)
├── data/
│   ├── pdpa_full.txt       # 個資法 + 施行細則純文字
│   └── eval_set.json       # 評估資料集(Day 41 寫出)
├── chroma_db/              # 向量索引(自動建立)
├── experiments_log.json    # 檢索實驗記錄(Day 42 寫出)
├── errors_log.json         # 錯誤案例(Day 43 寫出)
├── inference_log.csv       # 推論紀錄(Day 44 寫出)
├── deploy_api.py           # FastAPI 服務(Day 40 + Day 44)
└── streamlit_app.py        # Streamlit 前端(Day 44)

這個結構把「設定」、「檢索」、「生成」、「評估」、「資料」、「部署」六大模組分開,每個模組都有對應的檔案。後續 Day 42 會在 `experiments_log.json` 累積實驗記錄;Day 43 會用 `errors_log.json` 記錄錯誤案例;Day 44 會在 `inference_log.csv` 累積推論紀錄。每個檔案都是「單一寫入來源」,避免後續維護時出現「這個參數到底寫在哪裡」的混亂。

完整實作:把整個檢索層跑起來

把今天所有片段接起來,用 `python retrieval.py` 就能跑一次完整的「讀檔 → 切分 → 嵌入 → 索引」流程。第一次跑約 40–60 秒,後續每次啟動只花幾秒鐘讀磁碟。

# main.py:把 Day 41 的所有片段接起來
from retrieval import build_index, retrieve
from project_config import TOP_K

# 建立(或載入)索引
collection = build_index()
print(f"索引大小:{collection.count()} 個 chunks")

# 範例查詢
sample_hits = retrieve("個資法第 5 條是什麼?", collection, k=TOP_K)
print(f"\n範例查詢回傳 {len(sample_hits)} 個段落:")
for hit in sample_hits:
    print(f"  {hit['article']}(距離 {hit['distance']:.3f})")
    print(f"    {hit['text'][:60].replace(chr(10), ' ')}...")
# 輸出範例:
# 索引大小:142 個 chunks
# 範例查詢回傳 3 個段落:
#   第 5 條(距離 0.184)
#     第 5 條個人資料之蒐集、處理或利用,應尊重當事人之權益,依誠實及信用方法為之,不得...
#   第 19 條(距離 0.261)
#     第 19 條非公務機關對個人資料之蒐集、處理或利用,應有特定目的,並符合下列情形之一...
#   第 2 條(距離 0.289)
#     第 2 條本法所用用詞定義如下:一、個人資料:指自然人之姓名、出生年月日、國民身分證統一編號、護照號碼、特徵、指紋、婚姻、家庭、教育、職業、病歷、醫療、基因、性生活、健康檢查、聯絡方式、財務情況、社會活動及其他得以直接或間接方式識別該個人之資料。

這段把所有片段接成一個完整的入口。`build_index()` 是冪等的:第一次跑會建索引,後續跑會直接讀磁碟。範例查詢「個資法第 5 條是什麼?」回傳 3 個段落(少於 top_k=4 是因為有一個段落距離 > 0.30 被過濾掉),顯示第 5 條(0.184,最相關)、第 19 條(0.261)、第 2 條(0.289)。這個排序與 Day 31 的結果一致,證明 SCORE_THRESHOLD 過濾沒有把關鍵段落誤刪。

為了讓專案能順利接力到 Day 42,我們再加一段「索引健全度檢查」:列出索引的 metadata 分布、確認所有 chunk 都有 article 欄位、計算每個條號的平均 chunk 數。如果發現某個條號的 chunk 數異常(0 或超過 5),就要回頭檢查 pdpa_full.txt 的下載是否完整。

# smoke_test.py:Day 41 結束前的健全度檢查
from collections import Counter
from retrieval import build_index

collection = build_index()
total = collection.count()
print(f"總 chunks:{total}")

# 統計每個條號的 chunk 數
articles = Counter()
for i in range(total):
    md = collection.get(ids=[f"chunk-{i}"])["metadatas"][0]
    articles[md["article"]] += 1

print(f"\n不重複條號數:{len(articles)}")
print(f"平均每條號 chunks:{total / len(articles):.2f}")
print(f"最多 chunk 的條號:{articles.most_common(5)}")
# 輸出範例:
# 總 chunks:142
# 不重複條號數:63
# 平均每條號 chunks:2.25
# 最多 chunk 的條號:[('第 5 條', 4), ('第 19 條', 4), ('第 8 條', 3), ('第 11 條', 3), ('第 6 條', 3)]

這段 smoke_test.py 是 Day 41 結束前必跑的健全度檢查。預期結果是「總 chunks 在 100–200 之間」、「不重複條號 50–80 之間」、「平均每條號 1.5–3 chunks」。如果輸出遠離這個範圍(例如 chunks 數量突然變多),可能是 pdpa_full.txt 被重複抓取;如果 chunks 數太少,可能是切分出錯或只抓到部分條文。實務上把這段輸出也寫進 experiments_log.json 的 metadata 欄位,方便 Day 42、43 對照。

另一個常見的「專案地基」任務是寫一份給團隊看的 README。我們把今天的進度總結成一段文字,搭配啟動指令與目錄說明:

# write_readme.py:自動產生專案 README(包含設定摘要與啟動指令)
from pathlib import Path
from project_config import (LAW_URL_MAIN, LAW_URL_SUB, LAW_LICENSE, EMBEDDING_MODEL,
                            TOP_K, SCORE_THRESHOLD, CHUNK_SIZE, CHUNK_OVERLAP,
                            LLM_MODEL_NAME, EVAL_METRICS, DATA_DIR, CHROMA_DIR)

readme = f"""# 企業知識庫問答系統(NLP Day 41–45)

## 專案目標
讓企業內部同仁可以自然語言查詢「個人資料保護法」與其施行細則的條文。

## 共用設定
- 語料:個人資料保護法 + 施行細則(來源:法務部全國法規資料庫)
- 授權:{LAW_LICENSE}
- 嵌入模型:{EMBEDDING_MODEL}
- 切分:chunk_size={CHUNK_SIZE}、chunk_overlap={CHUNK_OVERLAP}
- 檢索:top_k={TOP_K}、score_threshold={SCORE_THRESHOLD}
- LLM:{LLM_MODEL_NAME}
- 評估指標:{EVAL_METRICS}

## 啟動指令
1. 準備語料:把個資法全文存到 {DATA_DIR}/pdpa_full.txt
2. 建立索引:python retrieval.py(第一次跑約 40–60 秒)
3. 跑健全度檢查:python smoke_test.py
4. 進入 Day 42 檢索實驗:python experiments.py
"""

Path("README.md").write_text(readme, encoding="utf-8")
print(f"README.md 已寫出,{len(readme)} 字")
# 輸出:README.md 已寫出,470 字

這段把專案設定寫成一份自動產生的 README,方便團隊成員快速理解專案全貌。把 README 寫在程式裡(而不是手寫)有兩個好處:第一,所有數字都從 project_config.py 拉,不會有人改了參數忘了改 README;第二,未來新增設定只要在 project_config.py 加常數、修改 write_readme.py 的模板,就能自動同步。Day 45 會把這份 README 整合進專案的最終交付物之一。

常見錯誤與踩雷

錯誤一:後續篇章改了 `project_config.py` 的參數導致前後矛盾。例如 Day 42 為了實驗把 CHUNK_SIZE 改成 300,但忘了 Day 43 評估時改回 500。對應排查方向:`project_config.py` 一旦定下來就不要改;若真的需要實驗不同參數,開一份 `project_config_experiment.py` 保留原檔。對應 debug:每次 commit 前跑 `git diff project_config.py`,確保沒有意外改動。

錯誤二:`RAW_TEXT_PATH` 找不到檔案。如果 `pdpa_full.txt` 還沒下載,`build_index()` 會在讀檔時報 `FileNotFoundError`。對應排查方向:第一次執行前先跑下載腳本(沿用 Day 31 的 `fetch_law` 函式);或是在 `build_index` 內加檢查,沒檔案就自動下載。對應 debug:`Path.exists()` 確認檔案是否存在。

錯誤三:evaluation set 的 `relevant_articles` 標註錯誤。人工標註有時候會漏掉某些相關條號,導致 recall@k 計算偏嚴格。對應排查方向:標註時請至少兩個領域專家各標一次、取交集;或是把標註結果也放進 Day 43 的錯誤分析,看哪些「模型找到但人工沒標」的段落是 ground truth 漏標。對應 debug:每次評估前隨機抽 3 個檢查標註品質。

錯誤四:忘記固定 SEED 導致結果不可重現。如果 Day 42 的檢索實驗每次跑結果都不同,會讓 Day 43 的「baseline vs 改良」比較失去意義。對應排查方向:`random.seed(SEED)`、`numpy.random.seed(SEED)`、`torch.manual_seed(SEED)` 三個都要設;sentence-transformers 內部的亂數也要在 `model.encode(..., seed=SEED)` 設定。

效能與實務提醒

這一步在 CPU 上約 40–60 秒(含下載嵌入模型 470 MB + 建立索引 + 寫入磁碟)。後續每次啟動只花 5–10 秒讀磁碟。如果在 Colab 跑,要把 `chroma_db` 目錄備份到 Google Drive(session 結束會清空);本機跑則沒這個問題。Day 42 開始的檢索實驗會在 Colab T4 跑(用 GPU 加速嵌入計算),但共用設定仍沿用本篇的 `project_config.py`。

實務上有兩個提醒。第一,評估資料集是「專案知識」的一部分,團隊成員調動時要把 `eval_set.json` 一起交接;不要讓評估資料「只在某個人腦中」,否則一段時間後沒人知道當初怎麼標的。第二,`project_config.py` 不要放敏感資訊(例如 API Key);敏感資訊放環境變數或 `.env` 檔,加入 `.gitignore` 避免洩漏到版控。Day 44 部署時會用 `os.environ["OPENAI_API_KEY"]` 讀金鑰,這是 2025 年 3 月業界的標準做法。

小結

今天把專案五篇的「地基」一次到位。我們界定了系統的目標與範圍(個資法問答、不做其他法規、不做多輪對話)、寫好 `project_config.py` 集中所有設定(語料、嵌入、向量庫、LLM、檢索參數)、沿用 Day 31 的檢索層並加入 `SCORE_THRESHOLD` 過濾、寫出 10 題評估資料集、定義清晰的目錄結構與檔案命名。整個專案篇(Day 41–45)會共用這些設定,後續四篇只在 `project_config.py` 之外加新功能,不會回頭改這個檔。明天 Day 42 會在這個基礎上做檢索實驗:比較不同 chunk_size、加入混合檢索(BM25 + dense)、加 cross-encoder rerank,把 recall@4 從 baseline 提升到目標值。

結語

今天的重點是「先把問題寫清楚,再碰模型」。我們從 Day 31–32 的原型出發,把檢索層、生成層、評估層需要的共用參數集中到 `project_config.py`,讓後續四篇可以無痛接力。專案篇最容易犯的錯是「中途改參數」,例如 Day 42 為了 A/B 測試改 chunk_size、忘記 Day 43 評估時改回來。我們從 Day 41 就把設定寫死、把評估集寫死、把目錄結構寫死,後續四篇就只能在這個框架內做改良,不能改變框架本身。讀完這篇你應該能回答:為什麼 `project_config.py` 是專案篇的地基?Day 31 的檢索層為什麼不需要重寫?評估資料集的 10 題為什麼要從「人工標註的條號」開始?

明天 Day 42 我們會把這套設定檔接上 Colab T4,做三組檢索實驗:純 dense、dense + BM25 混合、dense + BM25 + cross-encoder rerank。每次實驗都寫進 `experiments_log.json`,Day 43 直接拿這份 log 做評估。明天,我們會比較不同 chunk_size 與檢索策略在 recall@4 上的差異,找出最適合個資法語料的設定。

延伸資源

  • 全國法規資料庫(法務部,政府資料開放授權條款—第 1 版):https://law.moj.gov.tw/,個資法與施行細則的官方網址與授權條款。
  • Chroma 官方教學(0.6.x,2025):https://docs.trychroma.com/,PersistentClient、get_or_create_collection、metadata filter 的標準 API。
  • intfloat/multilingual-e5-small 模型卡(CC BY-NC):https://huggingface.co/intfloat/multilingual-e5-small,384 維多語言嵌入模型的下載與授權說明。
  • LangChain Text Splitter 官方教學(0.3.x,2025):https://python.langchain.com/docs/modules/data_connection/document_transformers/,RecursiveCharacterTextSplitter 的參數說明。
  • 政府資料開放平臺(data.gov.tw,2025):https://data.gov.tw/,法規與政府開放資料的下載入口與授權條款。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...