精華筆記

· @aihub.tw

RAG 系統實作

生成端:引用來源與防幻覺

生成端:引用來源與防幻覺

你把 RAG pipeline 做到第 5 課了——hybrid search 跑起來,reranker 也上了,召回率看起來不錯。接著你把這些文件塊送給模型,模型給你一個語氣流暢的答案,你仔細一看:它說的話有三成根本不在你傳進去的文件裡。

這叫「幻覺混入檢索結果」。更麻煩的是,模型說得很有信心,讀者根本看不出來哪句是真實文件、哪句是模型自己補腦的。這不是換個更強的模型就能解決的問題——它的根源在生成端的工程設計。換句話說,你的向量庫和 reranker 再好,生成端沒有做好,整個系統的可信度就是零。

這堂課適合誰 適合:已完成前 5 課、有可用的向量庫與檢索 pipeline 的工程師。需要基礎:Python 中階(會寫 class、處理 dict/list)、看過 Anthropic SDK 基本用法。前置課:第 5 課(檢索品質:hybrid search 與 rerank)。

這堂學什麼

  • 把多份檢索結果組進 prompt 的標準模板:讓模型知道哪是「文件」、哪是「問題」、各文件來源是什麼
  • 強制引用來源的兩種做法:prompt 工程法 vs Anthropic 原生 Citations API,以及各自的適用場景
  • 「查無資料就說不知道」的拒答工程:相似度門檻過濾 + prompt 層強制拒答指令的雙保險設計
  • 引用正確性驗證:程式層的後處理 checker,避免模型「編造」引用標記
  • 多輪對話 RAG 的核心問題:指代詞消歧與問題改寫(query rewriting)策略
  • 實戰:帶引用、帶拒答、支援多輪對話的完整問答系統 Python 實作

觀念一:把檢索結果正確組進 Prompt

第 3 課到第 5 課教你把文件切好、存好、撈出來。現在你手上有一個 list,裡面是 3~5 個和問題最相關的 chunk。問題是:怎麼把它們塞進 prompt?

新手最常見的做法是直接拼字串:

context = "\n".join([chunk["text"] for chunk in retrieved_chunks])
prompt = f"根據以下資料回答問題:\n{context}\n\n問題:{query}"

這個做法有三個隱患。第一,模型不知道「資料在哪裡結束、問題在哪裡開始」,兩者混在一起容易導致模型把問題裡的關鍵字當作資料的一部分。第二,不同文件之間沒有邊界,模型無法準確判斷哪句話出自哪份文件。第三,沒有任何來源識別資訊,模型就算想引用也無從標記。

正確做法是給每個文件塊一個明確的 ID 與 XML 標籤結構:

左側 chunk list 的 text/source/score 欄位,組裝成右側帶 id 與 source 屬性的 XML documents 結構,再接 question 標籤

def build_rag_prompt(query: str, chunks: list[dict]) -> str:
    """
    chunks 格式:每個 dict 有 text(內容)、source(來源路徑)、score(相似度分)
    """
    doc_blocks = []
    for i, chunk in enumerate(chunks):
        doc_blocks.append(
            f"""<document id="doc_{i}" source="{chunk['source']}">
{chunk['text']}
</document>"""
        )
    documents_xml = "\n".join(doc_blocks)

    return f"""你是一個精確的問答助理。請根據下方 <documents> 區塊中的文件內容回答問題。

<documents>
{documents_xml}
</documents>

<question>
{query}
</question>"""

為什麼選 XML 格式?Claude 在訓練資料裡看過大量 XML,對這種結構有很好的理解能力。每個 <document> 標籤的 id 屬性(如 doc_0)讓模型在回答時能精確引用,source 屬性保存文件來源路徑,方便前端顯示超連結。

下面這個對照表整理了三種組 prompt 方式的差異:

做法 文件邊界 來源識別 引用準確度
純字串拼接
換行符分隔 + 序號 模糊 普通
XML 標籤 + id 屬性 明確

觀念二:強制引用來源的兩種做法

做法 A:Prompt 工程法

在 system prompt 裡加上強制引用的規則,讓模型在答案裡標記 [doc_X]:

SYSTEM_PROMPT = """你是精確的文件問答助理,必須遵守以下規則:

1. 只能根據 <documents> 區塊中的內容作答。
2. 每個具體的事實主張,後面必須加上引用標記,格式:[doc_N],N 為文件 id 的數字。
3. 如果文件中找不到足以回答問題的資訊,必須直接說:「根據現有資料,無法回答這個問題。」
4. 禁止引用文件之外的知識。禁止推斷或補充文件沒有提到的事實。"""

這個做法的優點是成本低、彈性高,不需要特殊 API 功能。缺點是模型偶爾會「編造」一個 [doc_0] 標記但指向的文字根本不在那份文件裡——研究者稱為 postrationalization(後合理化),是幻覺的變體。這種情況在問題複雜、需要跨多份文件整合時更容易發生。

做法 B:Anthropic Citations API

2025 年 Anthropic 推出 Citations API,2026 年已全面正式上線,支援所有主力模型(Claude Haiku 3 除外)。它在 API 層保證每個引用都精確指向真實的文件位置,cited_text 欄位是直接從原文裁切的,不是模型生成的文字,也不計入 output tokens。

Citations API 流程:傳入 citations.enabled 的 document 區塊,API 自動句子切割並掛引用,回傳交錯的 text block 與帶 document_index、char index、cited_text 的 citation block

import anthropic

client = anthropic.Anthropic()

def rag_with_citations(query: str, chunks: list[dict]) -> dict:
    """
    用 Citations API 做帶原生引用的 RAG。
    chunks: [{"text": "...", "source": "doc_path", "title": "文件標題"}, ...]
    回傳解析後的答案文字與引用列表。
    """
    # 把每個 chunk 包成 document block
    content_blocks = []
    for chunk in chunks:
        content_blocks.append({
            "type": "document",
            "source": {
                "type": "text",
                "media_type": "text/plain",
                "data": chunk["text"],
            },
            "title": chunk.get("title", chunk["source"]),
            # context 欄位傳 metadata,不會被引用,也不計入 cited_text
            "context": f"來源:{chunk['source']}",
            "citations": {"enabled": True},
        })

    # 最後加上問題
    content_blocks.append({"type": "text", "text": query})

    response = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=1024,
        system="你是精確的文件問答助理。只根據提供的文件作答。若資料不足,請說明查無相關資料。",
        messages=[{"role": "user", "content": content_blocks}],
    )

    # 解析 response:content 是 text block 與 citations block 交錯的 list
    answer_parts = []
    all_citations = []
    for block in response.content:
        if block.type == "text":
            answer_parts.append(block.text)
        elif block.type == "citations":
            all_citations.extend(block.citations)

    return {
        "answer": "".join(answer_parts),
        "citations": all_citations,
    }

回傳的每個 citation block 長這樣:

{
  "type": "char_location",
  "cited_text": "RAG 系統在 2026 年的企業採用率已超過六成",
  "document_index": 0,
  "document_title": "AI 趨勢報告",
  "start_char_index": 42,
  "end_char_index": 89
}

cited_text 是直接從文件原文裁切的字串,這讓你可以在前端顯示引用片段,使用者點擊就能跳到原始文件的確切位置。另外,Citations API 和 prompt caching 可以一起用——在 document block 上加 "cache_control": {"type": "ephemeral"},重複查詢同一份文件庫時,文件的 token 只計算一次,可大幅降低成本。

Citations API 與 structured outputs 不相容 Citations API 目前不能與 structured outputs 同時使用。若你在 request 裡設定了 `output_config.format`(JSON schema 強制輸出),會拿到 `400` 錯誤。需要 JSON 格式輸出時,改用做法 A,在 system prompt 裡描述 JSON 格式,讓模型自行輸出結構化內容並加引用標記。

兩種做法的選擇建議:產品雛形階段或需要 JSON 輸出時用做法 A;正式上線、對引用準確度要求高的場景用做法 B。

觀念三:「查無資料就說不知道」的拒答工程

幻覺最嚴重的情境不是模型答錯——而是文件裡根本沒有答案,模型卻假裝有。拒答工程的目標就是讓這個情境乾淨收場。

拒答決策流程:向量查詢後先過相似度門檻,低分紅色分支直接拒答;通過後呼叫 LLM,再由 Prompt 層判斷文件是否查無答案,查無則輸出固定拒答句,有資料才正常回答加引用

拒答工程有兩層設計,缺一不可。

第一層:相似度門檻過濾

在把 chunks 送給模型之前,先檢查檢索分數。如果最高分都低於門檻,代表資料庫裡根本沒有相關內容,直接拒答、不消耗 LLM tokens:

SIMILARITY_THRESHOLD = 0.42  # 根據你的 embedding 模型和語料調整

def filter_by_threshold(chunks: list[dict], threshold: float = SIMILARITY_THRESHOLD) -> list[dict]:
    """
    過濾相似度低於門檻的 chunk。
    - voyage-4 / voyage-4-lite:建議從 0.40~0.50 開始調整
    - text-embedding-3-small:建議從 0.35~0.45 開始調整
    回傳空 list 代表「資料庫中查無相關文件」,應直接拒答。
    """
    return [c for c in chunks if c.get("score", 0) >= threshold]

門檻值需要用你自己的語料調校,沒有通用的「最佳值」。調試方法:收集 20~30 個有標準答案的問題,把門檻從 0.30 往上每次調 0.05,記錄「拒答率」和「答案品質」,找到兩者之間的最佳平衡點。

第二層:Prompt 層拒答指令

即使有通過門檻的 chunks,文件裡也可能「有相關文字但沒有答案」——例如問「這個產品的退款政策是什麼?」,資料庫裡只有產品說明,完全沒提退款政策。這時靠 prompt 指令讓模型自行判斷:

REFUSAL_SYSTEM = """你是精確的文件問答助理。遵守以下規則:

1. 只能根據 <documents> 裡的內容作答,嚴禁引用文件以外的知識。
2. 如果問題的答案完全不在文件裡,只輸出以下這句話,不加任何其他內容:
   「根據現有資料,無法回答這個問題。若需要更多資訊,請聯繫原始資料來源。」
3. 如果問題的答案只有部分在文件裡,先回答有的部分,再標明哪些部分查無資料。
4. 每個具體事實後面加 [doc_N] 引用標記,N 是文件 id 的數字。"""

注意第 2 條的寫法:「只輸出以下這句話」比「你可以說不知道」強硬得多。模型在模糊指令下容易找藉口「部分回答」然後偷偷補充訓練資料裡的知識。要讓拒答工程有效,指令必須明確到不留任何詮釋空間。

觀念四:引用驗證——後處理 Checker

用做法 A(Prompt 工程法)時,模型產生的 [doc_N] 標記需要在程式層驗證。模型有時會在沒有引用的情況下提到 [doc_2],或者引用一個不存在的 [doc_5]——這在你只傳了 3 個文件時當然是無效引用。

import re

def verify_citations(answer: str, chunks: list[dict]) -> dict:
    """
    檢查答案中的 [doc_N] 標記是否都指向有效的文件。
    回傳:
      valid:       所有引用都合法
      invalid_refs: 引用了不存在的 doc_N(超出範圍)
      uncited_chunks: 有哪些文件完全沒被引用(僅供參考,不算錯誤)
    """
    cited_ids = set(re.findall(r'\[doc_(\d+)\]', answer))
    valid_ids = set(str(i) for i in range(len(chunks)))

    invalid_refs = cited_ids - valid_ids       # 引用了不存在的 doc_N
    uncited_chunks = valid_ids - cited_ids     # 傳入但未被引用的文件

    return {
        "valid": len(invalid_refs) == 0,
        "invalid_refs": [f"doc_{i}" for i in sorted(invalid_refs, key=int)],
        "uncited_chunks": [f"doc_{i}" for i in sorted(uncited_chunks, key=int)],
    }

如果 valid 為 False,實際產品可以選擇:把這個回答標記為「需人工複審」、記錄到 log 供後續分析、或觸發一次重試(重試前把拒答規則強化後再送一次 request)。不建議把無效引用的回答直接呈現給使用者。

觀念五:多輪對話中的 RAG

多輪對話讓 RAG 複雜了一個數量級。使用者說「它的價格是多少?」——「它」指的是上一輪問的產品。如果你直接用「它的價格是多少?」做 embedding 查詢,向量庫根本不知道「它」是什麼,回傳的結果跟問題毫無關係,最終觸發拒答或回答錯誤內容。

多輪 RAG 問題改寫:第二輪「它的 context window 是多少?」經問題改寫器補上指代對象,變成「Claude Sonnet 5 的 context window 是多少?」再送向量庫,正確撈到文件;沒有改寫則查詢為空

解法是「問題改寫(query rewriting)」:在每一輪,先用一個輕量呼叫把含指代詞的問題改寫成獨立完整的問題,再用改寫後的問題做向量查詢。改寫任務可以用最輕量的模型(如 claude-haiku-4-5)來做,成本很低:

def rewrite_query(conversation_history: list[dict], current_query: str) -> str:
    """
    把含指代詞的問題改寫成獨立查詢。
    conversation_history: [{"role": "user"/"assistant", "content": "..."}, ...]
    只傳最近 6 筆(3 輪問答),過多的歷史對改寫幫助有限但會增加 token 消耗。
    """
    if not conversation_history:
        return current_query  # 第一輪沒有歷史,直接用原問題

    history_str = "\n".join(
        f"{msg['role'].upper()}: {msg['content']}"
        for msg in conversation_history[-6:]
    )

    rewrite_prompt = f"""以下是對話歷史:
{history_str}

使用者的最新問題是:「{current_query}」

請把這個問題改寫成一個獨立完整的問題,補上所有必要的指代對象,讓這個問題在沒有上下文時也能被理解。
只輸出改寫後的問題,不要加任何解釋。"""

    response = client.messages.create(
        model="claude-haiku-4-5",  # 改寫任務用輕量模型省錢
        max_tokens=200,
        messages=[{"role": "user", "content": rewrite_prompt}],
    )
    return response.content[0].text.strip()

多輪對話還有另一個問題:對話歷史會持續增長,最終把 context window 撐爆。解法是設定硬性上限——在更新 conversation_history 時只保留最近 20 筆(10 輪問答)。超過上限的舊歷史直接丟棄,對改寫任務的影響通常可以接受。

手把手實戰

把上面所有觀念串成一個完整的 RAG 問答 class,帶引用、帶拒答、帶多輪對話:

安裝依賴

pip install anthropic qdrant-client voyageai

假設你已有第 3 課建好的 Qdrant 向量庫,裡面存有文件的 voyage-4-lite 向量。如果你用的是 pgvector 或 Pinecone,把 _retrieve() 方法裡的客戶端換掉,其他邏輯完全一樣。

定義完整的 RAG 生成器

import re
import anthropic
import voyageai
from qdrant_client import QdrantClient

VOYAGE_MODEL = "voyage-4-lite"      # $0.02/MTok,新帳號有 200M free tokens
CLAUDE_MODEL = "claude-sonnet-5"    # 生成用
REWRITE_MODEL = "claude-haiku-4-5"  # 問題改寫用,省成本

SIMILARITY_THRESHOLD = 0.42  # 低於此值直接拒答(不送 LLM)
TOP_K = 5                     # 每次檢索前 5 塊送給模型

SYSTEM_PROMPT = """你是精確的文件問答助理。遵守以下規則:
1. 只能根據 <documents> 區塊的內容作答,禁止引用文件以外的任何知識。
2. 每個具體事實後面必須加引用標記 [doc_N],N 是文件 id 的數字。
3. 若文件中完全找不到答案,只回傳:「根據現有資料,無法回答這個問題。」
4. 不要推測或補充文件沒有明確提到的資訊。"""

class RAGGenerator:
    def __init__(self, collection_name: str):
        self.voyage = voyageai.Client()
        self.qdrant = QdrantClient(host="localhost", port=6333)
        self.claude = anthropic.Anthropic()
        self.collection = collection_name
        self.conversation_history: list[dict] = []

    def _embed_query(self, text: str) -> list[float]:
        result = self.voyage.embed([text], model=VOYAGE_MODEL, input_type="query")
        return result.embeddings[0]

    def _retrieve(self, query: str) -> list[dict]:
        """向量查詢 + 相似度門檻過濾,回傳合格的 chunk list。"""
        vector = self._embed_query(query)
        hits = self.qdrant.search(
            collection_name=self.collection,
            query_vector=vector,
            limit=TOP_K,
            with_payload=True,
        )
        # 門檻過濾:低分 chunk 直接排除
        return [
            {
                "text": hit.payload["text"],
                "source": hit.payload.get("source", "未知來源"),
                "title": hit.payload.get("title", hit.payload.get("source", "")),
                "score": hit.score,
            }
            for hit in hits
            if hit.score >= SIMILARITY_THRESHOLD
        ]

    def _rewrite_if_needed(self, query: str) -> str:
        """只有在有對話歷史時才做問題改寫,節省 API 呼叫。"""
        if not self.conversation_history:
            return query

        history_str = "\n".join(
            f"{m['role'].upper()}: {m['content']}"
            for m in self.conversation_history[-6:]
        )
        prompt = (
            f"對話歷史:\n{history_str}\n\n"
            f"最新問題:「{query}」\n\n"
            "把最新問題改寫成獨立完整的問題(補上指代對象)。只輸出改寫結果。"
        )
        resp = self.claude.messages.create(
            model=REWRITE_MODEL,
            max_tokens=150,
            messages=[{"role": "user", "content": prompt}],
        )
        return resp.content[0].text.strip()

    def _build_prompt(self, query: str, chunks: list[dict]) -> str:
        """把 chunks 組成帶 XML 邊界的 prompt,加上問題。"""
        doc_blocks = "\n".join(
            f'<document id="doc_{i}" source="{c["source"]}">\n{c["text"]}\n</document>'
            for i, c in enumerate(chunks)
        )
        return (
            f"<documents>\n{doc_blocks}\n</documents>\n\n"
            f"<question>\n{query}\n</question>"
        )

    def _verify_citations(self, answer: str, chunks: list[dict]) -> bool:
        """確認答案裡所有 [doc_N] 都指向合法的文件索引。"""
        cited = set(re.findall(r'\[doc_(\d+)\]', answer))
        valid = set(str(i) for i in range(len(chunks)))
        return cited.issubset(valid)

    def ask(self, query: str) -> dict:
        # 步驟 1:問題改寫(多輪對話用)
        search_query = self._rewrite_if_needed(query)

        # 步驟 2:向量檢索 + 門檻過濾
        chunks = self._retrieve(search_query)
        if not chunks:
            # 查無相關文件,直接拒答
            return {
                "answer": "根據現有資料,無法回答這個問題。",
                "sources": [],
                "refused": True,
            }

        # 步驟 3:組 prompt
        user_content = self._build_prompt(query, chunks)

        # 步驟 4:生成答案(帶對話歷史)
        messages = self.conversation_history + [
            {"role": "user", "content": user_content}
        ]
        response = self.claude.messages.create(
            model=CLAUDE_MODEL,
            max_tokens=1024,
            system=SYSTEM_PROMPT,
            messages=messages,
        )
        answer = response.content[0].text

        # 步驟 5:驗證引用
        if not self._verify_citations(answer, chunks):
            # 偵測到無效引用,記 log(實際產品可觸發重試或標記待審)
            print(f"[WARN] 無效引用標記,原始問題:「{query}」")

        # 步驟 6:更新對話歷史(存原始問題,不存改寫版)
        self.conversation_history.append({"role": "user", "content": query})
        self.conversation_history.append({"role": "assistant", "content": answer})
        # 硬性上限:最多保留 20 筆(10 輪問答)
        if len(self.conversation_history) > 20:
            self.conversation_history = self.conversation_history[-20:]

        sources = [
            {"id": f"doc_{i}", "source": c["source"], "score": round(c["score"], 4)}
            for i, c in enumerate(chunks)
        ]
        return {"answer": answer, "sources": sources, "refused": False}

執行測試

if __name__ == "__main__":
    rag = RAGGenerator(collection_name="my_docs")

    # 第一輪:正常問題
    result = rag.ask("RAG 系統的主要優點是什麼?")
    print("答案:", result["answer"])
    print("來源:", result["sources"])
    print("---")

    # 第二輪:含指代詞,測試問題改寫
    result2 = rag.ask("它有什麼限制?")  # 「它」= RAG 系統
    print("答案:", result2["answer"])
    print("---")

    # 測試拒答:問資料庫裡沒有的資訊
    result3 = rag.ask("台灣總統是誰?")
    print("答案:", result3["answer"])   # 應該輸出拒答訊息
    print("拒答:", result3["refused"])  # 應該是 True

執行後重點觀察兩件事:第一,第二輪的問題「它有什麼限制?」要看 log 裡的 search_query 是否已被正確改寫為「RAG 系統有什麼限制?」;第二,拒答測試要確認回傳的 refusedTrue 而不是讓模型亂答。

完整 RAG 生成 pipeline 架構:使用者輸入經問題改寫、向量查詢 Qdrant、相似度門檻過濾、XML Prompt 組裝、Claude 生成加引用、引用驗證 checker,最後回傳並更新對話歷史;門檻低分走紅色拒答路徑,驗證失敗走橘色 WARN 路徑

常見坑

坑 1:傳進去的 context 超過 token 限制

症狀:呼叫 API 時拿到 400 Bad Request,錯誤訊息包含 prompt is too longInput tokens exceed the model maximum

原因:TOP_K 設太高或每個 chunk 太大,加上對話歷史和 system prompt,總 token 超過模型上限。Claude Sonnet 5 的 context window 是 1M tokens,一般不會爆;但如果你用的是 200K context 的模型(如 Claude Haiku 4.5),對話歷史累積幾輪後就很容易超標,而且就算沒超標,把過多低相關 chunk 塞進去也會拉高成本與延遲。

解法:用 Anthropic SDK 的 client.beta.messages.count_tokens() 在送出 request 前先計算 token 數。如果快超限,把 TOP_K 從 5 降到 3,或把每個 chunk 的 text 截到 500 tokens 以內。長遠要在 _retrieve() 之後加 token budget 截斷邏輯:計算目前所有 chunk 的 token 總和,超過預算就丟掉分數最低的那幾個。

坑 2:模型無視拒答指令,繼續用訓練知識回答

症狀:文件裡明明查無資料,模型還是給了一個「看起來合理」但完全是捏造的答案,而且語氣非常肯定。

原因:system prompt 的拒答指令太軟。例如用「可以說不知道」而不是「必須只說這一句」,或問題的措辭讓模型判斷「這個我從訓練資料就知道,不需要查文件」。另一個常見原因是相似度門檻太低——有低相關的 chunk 通過了門檻,模型以為有依據就開始混合訓練知識。

解法:把拒答指令措辭改得更強硬,用「禁止」、「絕對不可以」、「唯一允許的回答是」這類詞;同時提高 SIMILARITY_THRESHOLD 到 0.45~0.50 試試。如果問題還是持續發生,可以在拒答判斷上加一個後驗步驟:讓另一個輕量模型檢查「這個回答裡有沒有 以外的資訊」。

坑 3:多輪對話後,檢索結果開始不準

症狀:前幾輪回答正常,對話進行到第 4~5 輪後,refused: True 的比例明顯上升,但使用者問的是合理的跟進問題。

原因:最常見是 _rewrite_if_needed 沒有被呼叫,或對話歷史傳入改寫器時截取的輪數太少,導致改寫器沒有足夠上下文、改寫結果不完整甚至完全沒變。另一個原因是對話歷史裡存的是 XML 格式的 prompt(含 <documents> 標籤),傳給改寫器的歷史太噪雜,反而干擾改寫品質。

解法:確認 conversation_history 存的是「原始問題和原始答案」,而不是帶 <documents> 標籤的 prompt。debug 時在 _rewrite_if_needed 裡 print 出改寫後的 search_query,確認指代詞有被正確展開再繼續。

坑 4:Citations API 的 document_index 對不上文件順序

症狀:用 Citations API 時,citation block 裡的 document_index 是 2,但你的第 2 份文件內容跟引用文字完全不符。

原因:document_index 是以整個 content list 裡所有 document block 的順序為準(0-indexed),跨越整個 request 的所有訊息。如果你在 document block 之前插了一個純文字 block 做說明,索引就會跑掉:那個文字 block 不是 document block 所以不算,但如果前面的某個 document block 的順序跟你預期的不同,就會對不上。

解法:把所有 document block 連續放在 content list 的最前面,最後才放問題的 text block,確保 document 的索引序號從 0 開始、和你的 chunks list 一一對應。可以用 enumerate(chunks)content_blocks 時同步建一個 index_to_source dict,之後解析 citations 時直接查這個 dict。

坑 5:回答品質很好但引用率很低——大量 uncited_chunks

症狀:verify_citations 回報 uncited_chunks 很多,答案裡根本沒有 [doc_N] 標記,但答案內容看起來正確。

原因:system prompt 裡的引用規則和答案格式要求之間有衝突,或問題的答案需要整合多份文件的資訊,模型選擇「說清楚概念」而不是「逐句標引用」。有時候是中文 prompt 對引用格式的描述不夠精確,模型產生了 (文件一) 這種格式而不是 [doc_0]

解法:在 system prompt 裡加上格式範例——「例如:○○○ [doc_0],○○○ [doc_1]」。範例比描述更有效。如果問題確實是要整合多份文件,接受「段落末尾引用」而不是「句句引用」的設計,對 uncited_chunks 的容忍度可以放寬。

作業

  1. 用你在第 5 課建好的 pipeline,串上這堂課的 RAGGenerator,對你自己的文件庫出 10 個問題測試,記錄「拒答率」、「無效引用率」、「回答耗時」三個指標。
  2. SIMILARITY_THRESHOLD 從 0.35 調到 0.55,每次調 0.05 做一輪測試,找出你語料的最佳門檻值,把結果做成小表格。
  3. 選做 A:把做法 A(Prompt 引用法)換成做法 B(Citations API),比較同一組問題下兩種做法的引用準確度差異,留意 Citations API 在你的語料上是否有更好的句子邊界切割。
  4. 選做 B:加一個「引用正確性評估」步驟——呼叫另一個 Claude 實例,讓它判斷「這個引用標記 [doc_N] 所對應的文件內容,真的能支撐答案裡的這個主張嗎?」用這個自動評估器跑 20 個問題,看看兩種引用做法的通過率差異。

下一課預告

第 6 課把生成端的工程做紮實了——你現在有一個帶引用、會拒答、能應付多輪對話的 RAG 問答核心。模組齊備,但這些還是在本機跑的腳本。第 7 課是整個課程的綜合實戰:我們要把這條完整的 pipeline 包進一個實際的 Web 服務,加上文件的自動更新機制與使用者回饋收集,讓系統在上線後能持續優化——第 1 課到第 6 課累積的所有模組,全部在這裡接起來跑。

#RAG#防幻覺#引用來源#Citations API#Prompt Engineering

← 回所有文章