AG Day 23 引用與出處:答案要能追溯
執行需求:CPU+API key。昨天我們把檢索品質拉到可以信任的水準:AG Day 22 檢索品質:查詢改寫與 rerank(原文連結)用多路召回再精排,讓真正相關的段落有機會排進前五名。今天要處理的是「檢索之後」的那一步:把撈回來的段落變成可追溯的答案。所謂可追溯,不是把來源網址貼在文末就好,而是要能回答三個追問——這句話的依據是哪一段?那一段出自哪一份來源文本、哪一天抓的?如果原網址明天改版了,我們還找不找得回來?這三個問題分別對應來源識別、支撐關係與可驗證性,也是本篇要逐一補齊的三層能力。沒有這三層,一份看起來很專業的研究報告,在實務上其實無法交付。
引言
先設想一個很常見的場景。你請研究助理整理「邊緣運算的功耗設計趨勢」,它回了一份結構完整、語氣專業的三千字報告,文末列了五個網址。你拿去給同事看,同事問:「報告第三段說功耗下降四成,這是哪來的?」你無法回答,因為報告沒有標示。你回頭去翻那五個網址,其中一個已經 404,另外四個頁面很長,你根本不知道哪一句出自哪裡。這份報告的價值瞬間歸零——不是因為內容錯,而是因為無法查核。研究報告的可信度來自可查核性,而可查核性來自引用機制。
更麻煩的是另一種情況:報告「有」引用,但引用是假的。語言模型很擅長模仿引用的形式,它會生成看起來很合理的 [3],而那個編號可能根本不存在,或存在但內容跟那句話無關。這種現象叫幻覺引用(hallucinated citation),是 RAG 系統常見的失敗模式。所以我們的流程不能只有「產生引用」,還必須有「查核引用」——先驗證編號存在,再驗證被引用的段落是否真的支撐那句話。
今天會全部在 src/research_agent/report.py 與 storage.py 上實作,沿用昨天回傳的候選結構。--dry-run 模式下不呼叫模型,改走規則式示範,所有輸出都會清楚標示為示範,讓沒有金鑰的讀者也能把整條流程跑完。
原理/觀念
可追溯性的三個層次
第一層是來源識別(provenance):這份答案用到的每一段文字,都要能指回一份具體來源,而且那份來源要有可辨識的身分——標題、網址、抓取時間、內容雜湊。網址會變、內容會改,單靠網址不足以還原當時看到什麼,所以我們額外留下抓取時間與內容雜湊,日後才能判斷「是資料變了」還是「模型變了」。
第二層是支撐關係(attribution):答案裡的每一句敘述,要能對應到具體的段落編號,而不只是「這份報告參考了五份來源」。從「來源層級」降到「段落層級」是關鍵,因為同一份來源裡可能同時存在支撐與反對的論述。
第三層是可驗證性(verifiability):系統要能自己檢查引用是否成立,而不是把責任丟給讀者。這包括機械檢查(編號是否存在、有沒有段落完全沒被引用)與語意檢查(被引用的段落是否真的支撐那句話)。前者便宜且必須,後者昂貴但有價值。
引用編號是怎麼產生的
最穩定的做法是「先編號、後生成」。流程是這樣:檢索完成後,我們手上有一組精排後的段落;在組裝成模型輸入時,替每一段加上固定編號 [1] 到 [n],並同時建立「編號 → 段落 → 來源」的對照表。模型被要求在回答時,凡是引用某段內容就標上對應編號。因為編號是我們給的,模型只能選擇用或不用的,沒有機會憑空創造一個新編號——除非它不守規則,那就是查核機制要抓的。
這裡有個容易忽略的細節:對照表的生命週期必須跟答案綁在一起。如果生成過程中重新排序或重新編號,對照表就會錯位。因此我們把對照表視為輸出的一部分,跟答案一起回傳、一起寫檔,而不是流程中的臨時變數。
兩種引用產生策略
第一種是生成時夾帶(inline citation):在 system prompt 要求模型「每個事實性語句後面加上 [n]」,模型一次產出答案與引用。優點是引用位置精準、實作單純;缺點是模型可能漏標或亂標,而且沒有引用支撐的句子會混在裡面。第二種是事後對齊(post-hoc attribution):先請模型寫出答案,再逐句送去與候選段落比對,找出每句最可能的來源。優點是分工清楚、可以只對事實句做昂貴的查核;缺點是對齊本身也是模型判斷,可能出錯,而且多一輪成本。
實務上我建議混合:以「生成時夾帶」為主,因為它讓模型有意識地在寫每個句子時就想到出處;再以「事後查核」為輔,專門用來抓幻覺引用與無引用句。這樣成本集中在查核,而不是全篇重做一次對齊。
查核要驗什麼
查核分成三種不同性質的檢查。第一種是結構檢查:答案中出現的每個 [n] 是否都落在有效範圍內;有沒有段落從頭到尾沒被引用(可能是召回雜訊);有沒有句子完全沒有引用(必須決定政策:是允許常識性敘述,還是要求全部標註)。第二種是支撐檢查:對每個「句子+其引用段落」配對,請模型判斷段落是否支撐該句,輸出相符、部分相符、不相符三種結果,並附理由。第三種是穩定性檢查:同一份來源在不同時間抓取是否內容一致,這靠內容雜湊比對即可。
結構檢查免費、決定性、應該每次執行;支撐檢查要花模型費用,適合在報告產出前執行一輪,或在評估流程中抽樣執行(AG Day 33、AG Day 34 會把這件事納入評估集)。今天兩種都會實作,但把支撐檢查設計成可開關的選項。
出處清單的呈現方式
最後一層是呈現。常見格式有兩種:編號式,例如正文寫「功耗較前一代下降約四成 [2]」,文末列「[2] 標題(網址,抓取於 2026-08-05)」;以及 Markdown 連結式,直接在段落中嵌入連結。編號式的優點是簡潔、可程式化檢查、適合大量引用;連結式的優點是閱讀器可以直接點。實務上我會兩者並用:正文用編號,文末同時列出編號、標題、可點連結、抓取時間與段落位置,讓讀者能一路回到原始文本。
完整實作
今天從資料層往上長。第一步,先把來源中介資料存進 SQLite;欄位比昨天多,但都是為了可追溯性而存在:
# research-agent/src/research_agent/storage.py(節錄)
import sqlite3
from pathlib import Path
from typing import Any
DB_PATH = Path("data/knowledge.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT,
fetched_at TEXT NOT NULL,
content_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS chunks (
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL REFERENCES documents(id),
ord INTEGER NOT NULL,
text TEXT NOT NULL
);
"""
def connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA)
return conn
def get_document(conn: sqlite3.Connection, doc_id: str) -> dict[str, Any] | None:
row = conn.execute("SELECT * FROM documents WHERE id = ?", (doc_id,)).fetchone()
return dict(row) if row else None
段落說明:content_hash 是內容快照的指紋,只要抓到的文本沒變,雜湊就不變;它讓我們能偵測「同一網址內容已改」。row_factory = sqlite3.Row 讓查詢結果可以用欄位名稱存取,比位置索引可讀得多。若你前幾篇的欄位命名不同,請以本節的欄位為準調整查詢,資料模型是全系列共用的。
第二步,建立「編號 → 段落 → 來源」的對照表。這是整篇最關鍵的資料結構:
# research-agent/src/research_agent/report.py
import sqlite3
from dataclasses import dataclass, field
from typing import Any
from . import storage
@dataclass
class Citation:
number: int
chunk_id: str
doc_id: str
title: str
url: str
fetched_at: str
text: str
@property
def label(self) -> str:
return f"[{self.number}] {self.title}({self.url},抓取於 {self.fetched_at})"
@dataclass
class CitationTable:
by_number: dict[int, Citation] = field(default_factory=dict)
def add(self, citation: Citation) -> None:
self.by_number[citation.number] = citation
def numbers(self) -> list[int]:
return sorted(self.by_number)
def build_context(hits: list[dict[str, Any]], conn: sqlite3.Connection,
max_chars: int = 600) -> tuple[str, CitationTable]:
"""把精排後的候選組成帶編號的 context,並回傳對照表。"""
table = CitationTable()
blocks: list[str] = []
for number, hit in enumerate(hits, start=1):
doc = storage.get_document(conn, hit["meta"]["doc_id"])
if doc is None:
continue
citation = Citation(
number=number,
chunk_id=hit["id"],
doc_id=doc["id"],
title=doc["title"] or "(未命名來源)",
url=doc["url"],
fetched_at=doc["fetched_at"],
text=hit["text"],
)
table.add(citation)
blocks.append(f"[{number}] {citation.text[:max_chars]}\n來源:{citation.title}")
return "\n\n".join(blocks), table
段落說明:max_chars 限制每段送進模型的長度,控制 prompt 預算。編號從 1 開始且與清單順序一致,讓「第幾段」與「第幾號引用」在視覺上對齊,減少模型錯位。若某筆候選查不到對應來源,我們選擇跳過並在紀錄中留下警告,而不是編一個空來源——這是資料完整性優先的取捨。
第三步,設計要求模型夾帶引用的 system prompt。這段的措辭直接決定引用品質,值得多花心思:
ANSWER_SYSTEM = (
"你是研究報告撰寫者。只能依據提供的來源段落作答,不要使用來源以外的知識。\n"
"規則:\n"
"1. 每個事實性敘述後面都要標注來源編號,格式為 [1]、[2]。\n"
"2. 一句話可以引用多個來源,例如 [1][3]。\n"
"3. 若來源不足以回答某個子問題,明確寫出『來源不足』,不要臆測。\n"
"4. 不要編造編號;只能使用上下文中出現過的編號。\n"
"5. 輸出使用繁體中文 Markdown,不要加開場問候語。"
)
def generate_answer(question: str, context: str, dry_run: bool | None = None) -> str:
if dry_run:
from .dryrun import mock_answer
return mock_answer(question, context)
from .llm import complete_text # AG Day 3 建立的通用對話介面
user = f"研究問題:{question}\n\n來源段落:\n{context}"
return complete_text(system=ANSWER_SYSTEM, user=user)
段落說明:「不要使用來源以外的知識」這句約束看似理所當然,但少了它,模型很容易混入訓練資料中的舊資訊而不自知,這也是開源 RAG 系統常見的問題。要求它「明確寫出來源不足」則是為了避免模型為了通順而硬答——一份誠實標示不確定性的報告,遠比一份流暢但無依據的報告有價值。
第四步,實作結構檢查。這是零成本、必須每次都跑的一關:
import re
CITE_PATTERN = re.compile(r"\[(\d+)\]")
def parse_citations(answer: str) -> set[int]:
return {int(m.group(1)) for m in CITE_PATTERN.finditer(answer)}
def check_structure(answer: str, table: CitationTable) -> dict[str, Any]:
"""回傳結構檢查結果,包含越界編號與未被引用的來源。"""
used = parse_citations(answer)
valid = set(table.numbers())
invalid = sorted(used - valid)
unused = sorted(valid - used)
return {
"cited": sorted(used),
"invalid": invalid,
"unused": unused,
"ok": not invalid,
}
段落說明:invalid 非空代表模型編造了編號,這是嚴重問題,必須在寫檔前處理(重新生成或標記);unused 通常代表召回進來了雜訊段落,屬於提示訊號而非錯誤,可以留給人工檢視。把兩者分開,才不會讓小問題淹沒大問題。
第五步,實作支撐檢查。對每個「句子+引用」配對,請模型判斷是否支撐:
VERIFY_SYSTEM = (
"你是引用查核員。判斷『來源段落』是否支撐『敘述句』的內容。\n"
"判定標準:相符(可直接推出)、部分相符(需補充或弱化措辭)、不相符。\n"
'只回傳 JSON,格式為 {"verdict": "相符|部分相符|不相符", "reason": "..."}。'
)
SENTENCE_SPLIT = re.compile(r"(?<=[。!?])")
def split_sentences(answer: str) -> list[str]:
return [s.strip() for s in SENTENCE_SPLIT.split(answer) if s.strip()]
def verify_support(sentence: str, sources: list[str], dry_run: bool | None = None) -> dict[str, Any]:
from .retrieval import call_model_json # AG Day 22 建立的薄封裝
body = "\n\n".join(sources)
user = f"敘述句:{sentence}\n\n來源段落:\n{body}"
return call_model_json(VERIFY_SYSTEM, user, dry_run=dry_run)
def verify_all(answer: str, table: CitationTable, dry_run: bool | None = None) -> list[dict[str, Any]]:
reports: list[dict[str, Any]] = []
for sentence in split_sentences(answer):
numbers = [int(m.group(1)) for m in CITE_PATTERN.finditer(sentence)]
sources = [table.by_number[n].text for n in numbers if n in table.by_number]
if not sources:
reports.append({"sentence": sentence, "verdict": "無引用", "reason": "句子沒有引用任何來源"})
continue
result = verify_support(sentence, sources, dry_run=dry_run)
reports.append({"sentence": sentence, **result})
return reports
段落說明:中文句子切分用標點後的正向查找,簡單但不完美——引號、括號內的句號也會被切斷。若要更嚴謹,可改用專門的分句工具或請模型輸出結構化句子清單,這是以官方文件為準的取捨。另外,verify_all 是「每句一次模型呼叫」,句子多時成本會快速上升,實務上務必加快取或抽樣。
第六步,把答案、引用與查核結果渲染成 Markdown 報告,並寫入 reports/:
from datetime import datetime, timezone
from pathlib import Path
REPORT_DIR = Path("reports")
def render_report(question: str, answer: str, table: CitationTable,
checks: dict[str, Any], support: list[dict[str, Any]] | None = None) -> str:
lines = [
f"# 研究報告:{question}",
"",
f"產出時間:{datetime.now(timezone.utc).isoformat(timespec='seconds')}",
"",
"## 分析",
"",
answer.strip(),
"",
"## 參考來源",
"",
]
for number in table.numbers():
lines.append(f"- {table.by_number[number].label}")
lines += ["", "## 引用查核(自動產出)", ""]
lines.append(f"- 越界編號:{checks['invalid'] or '無'}")
lines.append(f"- 未被引用的來源:{checks['unused'] or '無'}")
if support:
bad = [r for r in support if r["verdict"] in ("不相符", "無引用")]
lines.append(f"- 未被支撐的敘述句:{len(bad)} 句")
for row in bad:
lines.append(f" - {row['sentence'][:40]}…→ {row['verdict']}")
return "\n".join(lines)
def write_report(question: str, markdown: str) -> Path:
REPORT_DIR.mkdir(exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
path = REPORT_DIR / f"report-{stamp}.md"
path.write_text(markdown, encoding="utf-8")
return path
段落說明:把查核結果寫進報告本身,是一個刻意的設計決定。它讓讀者一眼看到「這份報告有幾句沒有依據」,也讓後續的評估有檔案可稽。實務上你可以把自動查核區塊放在報告附錄,或只在內部版本保留,但不要讓它消失在流程裡。
第七步,串接指令列,把整條鏈路跑起來:
def build_report(question: str, dry_run: bool | None = None, verify: bool = True) -> Path:
from .retrieval import retrieve
hits = retrieve(question, dry_run=dry_run)
conn = storage.connect()
try:
context, table = build_context(hits, conn)
finally:
conn.close()
answer = generate_answer(question, context, dry_run=dry_run)
checks = check_structure(answer, table)
support = verify_all(answer, table, dry_run=dry_run) if verify else None
markdown = render_report(question, answer, table, checks, support)
return write_report(question, markdown)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="產生帶引用的研究報告")
parser.add_argument("question", help="研究問題")
parser.add_argument("--dry-run", action="store_true", help="離線模式,輸出為示範")
parser.add_argument("--no-verify", action="store_true", help="跳過支撐檢查以節省成本")
args = parser.parse_args()
path = build_report(args.question, dry_run=args.dry_run, verify=not args.no_verify)
print(f"報告已寫入:{path}")
段落說明:build_report() 把七個步驟收斂成一個入口,方便後面 Day 28 接上 MCP 或 Day 38 包成 API 時直接複用。執行方式與示範輸出如下:
uv run python -m research_agent.report "邊緣運算的功耗設計有什麼趨勢?" --dry-run
報告已寫入:reports/report-20260806-041233.md
接下來的報告內容片段是 dry-run 的示範輸出,由規則式 mock 產生,不代表真實模型的引用品質,也不代表任何真實資料來源:
## 分析
根據檢索到的來源,邊緣裝置的功耗設計正朝向動態電壓調整與異質運算分工發展 [1]。
部分來源指出,散熱限制與成本壓力仍是導入的主要障礙 [2]。
## 參考來源
- [1] (示範來源 A)(https://example.invalid/a,抓取於 2026-08-05)
- [2] (示範來源 B)(https://example.invalid/b,抓取於 2026-08-05)
## 引用查核(自動產出)
- 越界編號:無
- 未被引用的來源:[3]
- 未被支撐的敘述句:0 句
段落說明:上面的網址刻意用 example.invalid,提醒讀者這是示範而非真實來源。執行 dry-run 之後請打開產生的 Markdown 檔,逐項對照「編號、來源清單、查核結果」是否一致;這一步很多人會跳過,但它是驗證整條引用鏈路是否正確最快的方法。
常見錯誤與踩雷
第一個錯誤是編號在流程中被重排。有些實作會在精排後才編號、又在渲染前依網址去重再編號一次,兩次編號不一致,引用就全部錯位。請把「編號」當成不可變的識別,只在 build_context() 裡發生一次,後續所有階段都沿用同一張對照表。
第二個錯誤是引用粒度太粗。寫成「本文參考 [1][2][3]」等於沒有引用;讀者無法判斷哪句話對應哪個來源。務必要求引用貼在句子層級,並在查核時統計「無引用句」的數量,把它當成報告品質指標之一。
第三個錯誤是只檢查編號存在,就宣稱引用正確。編號存在只證明模型守格式,不證明內容相關。要做支撐檢查,並對「不相符」的句子安排補救流程:重新檢索、補充提示、或直接刪掉該句。刪句是最保守但最可靠的做法,因為一句沒有依據的結論,會拖累整份報告的可信度。
第四個錯誤是沒有留下抓取時間與內容雜湊。當來源頁面改版、內容改變時,你會無法判斷是「原始資料就這樣」還是「後來被改過」。這兩個欄位在第一天寫入時幾乎零成本,事後補救卻極困難,請務必在儲存階段就寫進去。
第五個錯誤是把 Markdown 連結當成唯一出處,而沒有段落位置。連結失效是常態,尤其是新聞頁面;保留段落編號與原文片段,即使連結失效,也還能靠雜湊與片段在快取或存檔中找回。若你有合規要求,甚至可以留存原文快照,但要注意授權條款與儲存空間。
第六個錯誤是忽略授權與合理使用。引用網頁內容時,請留意來源的授權條款與 robots 規範,長篇轉載與短句引用在法律上的界線不同。本系列是教學示範,正式產品請諮詢法務,不要以「技術上做得到」當成「可以這樣做」。
效能與實務提醒
先說成本結構。生成答案是一次模型呼叫,支撐檢查則是「每句一次」,假設一份報告三十句,那就是三十次呼叫再加一次生成。這在示範規模可以接受,但在正式服務會很快變成主要開銷。三個實用做法:只對「含數值、含因果詞、含比較詞」的句子做支撐檢查;把同一組來源的查核結果依 內容雜湊 快取起來;把完整查核放到離線批次,請求路徑只做結構檢查。
再談延遲與體驗。使用者送出問題後,最不想等的是「看到第一段答案」。因此建議先串流輸出帶引用的答案,查核結果稍後補在文末(AG Day 19 談過的串流輸出可以直接沿用)。這樣使用者先拿到可用內容,系統再默默補上可驗證性。如果查核發現嚴重問題,再以醒目標記更新報告,而不是讓使用者對著空白畫面等待。
第三個提醒是顯示層。編號式引用要讓讀者能「一眼跳到來源」。若是在網頁呈現,把 [1] 做成可點的元素,點選後展開該段落的原文與連結;若是在終端機輸出,至少在文末列出編號與可複製的網址。引用不是給機器看的格式,而是給人查核的路徑,呈現方式本身就是功能的一部分。
最後是與後續章節的銜接。今天我們假設所有來源都已經在知識庫裡,但真實的研究流程需要「先去網路上找」。目前的檢索只能查已擷取的內容,還不能主動發現新來源。這件事會由搜尋工具補上,也是明天的主題。
小結
今天我們把「答案」升級成「可追溯的答案」。重點整理如下:
- 資料層:documents 表記錄網址、標題、抓取時間與內容雜湊;chunks 表記錄段落與所屬來源,這是可追溯性的地基。
- 對照層:
build_context()產生「編號 → 段落 → 來源」對照表,編號只產生一次且不可變。 - 生成層:system prompt 明確要求逐句夾帶引用、限制只能用提供的編號、來源不足要坦白說明。
- 查核層:結構檢查(越界編號、未引用來源)免費且必跑;支撐檢查(相符/部分相符/不相符)昂貴但可開關。
- 呈現層:正文用編號,文末列出編號、標題、網址、抓取時間,並附上自動查核結果。
一句話總結今天的原則:沒有出處的結論,等於沒有結論。把這句話寫進你的提示詞與你的流程檢查,就抓住了報告品質的核心。
結語
到這裡,檢索區塊(Day 20 到 Day 23)算是完整了:我們能擷取網頁、切塊、向量化、改寫查詢、精排,最後產出帶引用的報告。但整條流程目前還有一個明顯的天花板——來源只能來自「已經被丟進知識庫的那些頁面」。如果讀者問的是昨天才發布的新聞,或知識庫裡根本沒收錄的主題,系統會誠實地說「來源不足」,然後就停在這裡。
明天,我們會進入「AG Day 24 搜尋工具:讓 Agent 查網路」,把網路搜尋與內容擷取包成代理可以呼叫的工具,讓研究助理第一次能夠「主動發現新來源」,而不只是被動查詢既有知識庫。我們會處理搜尋 API 的選用、結果清洗、失敗重試,以及如何把新抓到的內容接回今天的引用鏈路。
延伸資源
- Model Context Protocol 官方規格(2025-06-18 版):
https://modelcontextprotocol.io/。工具與資源的標準化介面,Day 25 起會正式使用。 - W3C PROV 概觀:
https://www.w3.org/TR/prov-overview/。資料來源與推導關係的標準模型,理解 provenance 概念的好起點。 - SQLite 官方說明:
https://sqlite.org/lang.html。本專案 documents/chunks 的查詢語法以這份說明為準。 - LangChain 相關說明:
https://python.langchain.com/。Document 的metadata欄位設計與 retriever 回傳格式,可與本篇的候選結構互相對照。 - Markdown 規格:
https://commonmark.org/。報告輸出格式以 CommonMark 為基準,確保在不同檢視器都能正確呈現。
留言
張貼留言