iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
自我挑戰組

AI 不只會回答:30 天打造一套真正能上線的智慧助理系列 第 7

[Day 7] 讓 RAG 回答有依據:加入文件引用與 Metadata

  • 分享至 

  • xImage
  •  

[Day 7] 讓 RAG 回答有依據:加入文件引用與 Metadata

今天為什麼要做這件事

在 Day 6,我們已經成功將 Vector Search 與 LLM 串接起來,讓 AI 助理可以根據知識庫回答問題

雖然系統已經可以回答問題,但還存在一個重要限制:

使用者如何知道這個答案是來自哪一份文件

使用者可能會進一步詢問:

  • 這個答案來自哪份文件
  • 是文件中的哪一個段落
  • 這段資訊是否真的存在於知識庫
  • 如果答案錯誤,該如何追查來源

如果系統只回傳 LLM 產生的文字,使用者很難驗證答案

因此,今天要替 RAG Assistant 加入基本的來源引用功能
讓系統不只回答問題,也能提供回答的參考依據

今天要解決什麼問題

今天的目標包含以下五個部份:

  1. 讓 RAG 回答附帶引用來源
  2. 理解 Metadata 在 RAG 中的實際用途
  3. 顯示文件名稱與 Chunk 資訊
  4. 降低回答無法追溯來源的問題
  5. 建立具備基本引用功能的 RAG Assistant

完成後,系統的輸出不再只有回答文字

引用來源可以協助使用者追溯資料,但不代表引用本身就能保證答案一定正確

仍然需要搭配搜尋品質、Prompt 設計與答案驗證

什麼是 RAG 的引用來源

RAG 的引用來源是指:

將 LLM 回答所參考的文件或資料片段,附加在回答中,讓使用者可以追溯資訊來源

在實際應用中,引用資訊可以更完整:

引用內容可以根據實際資料來源調整,例如:

  • 文件名稱
  • 文件路徑
  • 文件 ID
  • 頁碼
  • 段落編號
  • Chunk ID
  • 資料建立時間
  • 文件版本
  • 網頁 URL

Metadata 的實際用途

在 Day 5,我們將資料儲存至 ChromaDB 時,加入了 Metadata:

當時的 Metadata 只有文件名稱,但它其實可以提供更多資訊

Metadata 是什麼

Metadata 可以理解為:

描述一筆資料的額外資訊

文件通常沒有頁碼,因此可以只儲存實際取得的資訊。

Metadata 可以解決哪些問題

顯示引用來源

系統可以知道這段文字來自哪一份文件

協助錯誤追查

如果回答內容不正確,可以根據 Chunk ID 找到原始資料

支援多份文件

當知識庫包含多份文件時,可以區分不同來源

支援文件過濾

未來可以根據 Metadata 進行條件搜尋,例如:

  • 只搜尋某一份文件
  • 只搜尋特定版本
  • 只搜尋某個部門的文件
  • 只搜尋指定類型的資料

協助文件管理

當文件更新時,可以透過文件 ID、版本或更新時間判斷哪些資料需要重新建立 Embedding

更新 RAG 架構

這裡有一個重要設計:

引用來源應該盡可能來自實際的搜尋結果,而不是完全交由 LLM 自行編造

LLM 可以協助整理引用,但文件名稱、Chunk ID 等資訊應由程式保留與管理

實作

Step 1:調整 Vector Database 的 Metadata

from pathlib import Path

import chromadb
from langchain_text_splitters import (
    RecursiveCharacterTextSplitter,
)
from sentence_transformers import SentenceTransformer


DATA_PATH = Path("data/sample.txt")
CHROMA_PATH = "./data/chroma"


def create_collection():
    client = chromadb.PersistentClient(
        path=CHROMA_PATH
    )

    collection = client.get_or_create_collection(
        name="knowledge_base"
    )

    return collection


def load_chunks():
    text = DATA_PATH.read_text(
        encoding="utf-8"
    )

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=100,
        chunk_overlap=20
    )

    return splitter.split_text(text)


def build_vector_database():
    collection = create_collection()
    chunks = load_chunks()

    model = SentenceTransformer(
        "all-MiniLM-L6-v2"
    )

    embeddings = model.encode(chunks)

    ids = [
        f"sample-txt-chunk-{i}"
        for i in range(len(chunks))
    ]

    metadatas = [
        {
            "source": DATA_PATH.name,
            "chunk_id": ids[i],
            "file_type": DATA_PATH.suffix,
            "chunk_index": i
        }
        for i in range(len(chunks))
    ]

    collection.upsert(
        ids=ids,
        documents=chunks,
        embeddings=embeddings.tolist(),
        metadatas=metadatas
    )

    return collection

這次的 Metadata 比 Day 5 更完整:

{
    "source": "sample.txt",
    "chunk_id": "sample-txt-chunk-0",
    "file_type": ".txt",
    "chunk_index": 0
}

為什麼要加入 chunk_id

當知識庫中有多份文件時,可能產生 ID 衝突

因此,可以在 ID 中加入文件名稱或文件識別碼:

正式系統中,也可以使用 UUID 或根據文件內容產生穩定的唯一 ID

Step 2:讓 Vector Search 回傳 Metadata

Day 6 的搜尋功能只回傳文件內容:

documents = results.get(
    "documents",
    [[]]
)[0]

今天需要同時取得:

  • 文件內容
  • Metadata
  • 距離資訊
from sentence_transformers import (
    SentenceTransformer,
)

from src.rag.vector_db import create_collection


def search_documents(
    query,
    n_results=2
):
    collection = create_collection()

    model = SentenceTransformer(
        "all-MiniLM-L6-v2"
    )

    query_embedding = model.encode(
        [query]
    )

    results = collection.query(
        query_embeddings=query_embedding.tolist(),
        n_results=n_results,
        include=[
            "documents",
            "metadatas",
            "distances"
        ]
    )

    documents = results.get(
        "documents",
        [[]]
    )[0]

    metadatas = results.get(
        "metadatas",
        [[]]
    )[0]

    distances = results.get(
        "distances",
        [[]]
    )[0]

    retrieved_documents = []

    for i, document in enumerate(documents):
        retrieved_documents.append(
            {
                "content": document,
                "metadata": metadatas[i],
                "distance": distances[i]
            }
        )

    return retrieved_documents

注意:ChromaDB 的 distance 數值意義取決於 Collection 使用的距離函數,距離通常是用來排序或評估相似程度,不應直接當成通用的相似度百分比

Step 3:建立引用來源格式

def format_citation(document, index):
    metadata = document.get(
        "metadata",
        {}
    )

    source = metadata.get(
        "source",
        "未知文件"
    )

    chunk_id = metadata.get(
        "chunk_id",
        "未知 Chunk"
    )

    chunk_index = metadata.get(
        "chunk_index",
        "未知"
    )

    return (
        f"[{index}] "
        f"文件:{source}|"
        f"Chunk ID:{chunk_id}|"
        f"索引:{chunk_index}"
    )


def build_citations(documents):
    citations = []

    for index, document in enumerate(
        documents,
        start=1
    ):
        citation = format_citation(
            document,
            index
        )

        citations.append(citation)

    return citations

測試:

documents = [
    {
        "content": "本產品支援 Windows 10。",
        "metadata": {
            "source": "sample.txt",
            "chunk_id": "sample-txt-chunk-0",
            "chunk_index": 0
        }
    }
]

citations = build_citations(documents)

for citation in citations:
    print(citation)

引用來源的格式可以依照產品介面調整

例如在 Web 介面可以改成可點擊的來源卡片、在 LINE Bot 則可以使用文字訊息呈現

Step 4:建立帶有來源的 Context

除了將文件內容傳給 LLM,也可以在 Context 中標示來源

def build_context(documents):
    context_parts = []

    for index, document in enumerate(
        documents,
        start=1
    ):
        content = document.get(
            "content",
            ""
        )

        metadata = document.get(
            "metadata",
            {}
        )

        source = metadata.get(
            "source",
            "未知文件"
        )

        chunk_id = metadata.get(
            "chunk_id",
            "未知 Chunk"
        )

        context_part = (
            f"[來源 {index}]\n"
            f"文件:{source}\n"
            f"Chunk ID:{chunk_id}\n"
            f"內容:{content}"
        )

        context_parts.append(context_part)

    return "\n\n".join(context_parts)

這樣做的好處是:

  • LLM 可以理解每段文字的來源
  • 後續可以要求 LLM 使用來源編號
  • 方便檢查回答與文件的關聯

Step 5:調整 RAG Prompt

更新 Prompt,要求 LLM 在回答中使用來源編號

def build_prompt(query, context):
    return f"""
你是一個專業的 AI 智慧助理。

請根據提供的參考資料回答使用者問題。

回答規則:
1. 只能根據參考資料回答。
2. 如果資料不足,請明確說明無法確認。
3. 不要捏造參考資料沒有提到的內容。
4. 使用繁體中文回答。
5. 如果回答有參考資料支持,請在相關句子後面標註來源編號。
6. 來源編號必須來自提供的參考資料。
7. 不要自行創造不存在的來源編號。

參考資料:
{context}

使用者問題:
{query}

請提供清楚、簡潔的回答。
"""

不過,這裡必須注意:

不能完全依賴 LLM 自行產生引用編號

因為模型可能:

  • 遺漏來源
  • 使用錯誤的來源編號
  • 引用與回答不相關的文件
  • 產生不存在的來源

因此,程式端仍然需要保留原始搜尋結果,並在最終輸出中加入可驗證的引用資訊

Step 6:整合完整 RAG Pipeline

from src.llm.client import client
from src.rag.retrieval import search_documents
from src.rag.vector_db import build_vector_database
from src.rag.citation import build_citations


MODEL_NAME = "gemini-2.5-flash"


def build_context(documents):
    context_parts = []

    for index, document in enumerate(
        documents,
        start=1
    ):
        content = document["content"]
        metadata = document["metadata"]

        source = metadata.get(
            "source",
            "未知文件"
        )

        chunk_id = metadata.get(
            "chunk_id",
            "未知 Chunk"
        )

        context_parts.append(
            f"[來源 {index}]\n"
            f"文件:{source}\n"
            f"Chunk ID:{chunk_id}\n"
            f"內容:{content}"
        )

    return "\n\n".join(context_parts)


def build_prompt(query, context):
    return f"""
你是一個專業的 AI 智慧助理。

請根據提供的參考資料回答使用者問題。

回答規則:
1. 只能根據參考資料回答。
2. 如果資料不足,請明確說明。
3. 不要捏造參考資料沒有提到的內容。
4. 使用繁體中文回答。
5. 如有引用,請使用提供的來源編號。
6. 不要自行創造不存在的來源編號。

參考資料:
{context}

使用者問題:
{query}

請提供回答:
"""


def ask_rag(query):
    documents = search_documents(
        query=query,
        n_results=2
    )

    if not documents:
        return {
            "answer": "目前找不到相關參考資料。",
            "citations": []
        }

    context = build_context(documents)

    prompt = build_prompt(
        query=query,
        context=context
    )

    response = client.models.generate_content(
        model=MODEL_NAME,
        contents=prompt
    )

    citations = build_citations(
        documents
    )

    return {
        "answer": response.text,
        "citations": citations,
        "documents": documents
    }


if __name__ == "__main__":
    build_vector_database()

    query = "這個產品支援哪些作業系統?"

    result = ask_rag(query)

    print("回答:")
    print(result["answer"])

    print("\n參考來源:")

    for citation in result["citations"]:
        print(citation)

今天學到什麼

今天將 RAG Assistant 從單純的問答系統,提升為具備基本來源追溯能力的系統

重要學習內容包括:

  1. Citation 可以讓回答具備參考依據
  2. Metadata 用來描述文件與 Chunk 的額外資訊
  3. Vector Search 應該同時回傳文件內容與 Metadata
  4. 文件名稱與 Chunk ID 可以協助問題追查
  5. 引用資訊最好由程式保留與管理
  6. LLM 產生的引用仍然需要驗證
  7. 引用來源不代表答案必然正確
  8. 文件版本管理會影響引用的可靠性

今天最大的收穫是:

一個實用的 RAG 系統,不只要回答問題,也要讓使用者知道答案的依據,以及如何追溯原始資料

今天的系統完成到哪裡

目前仍然存在的限制:

  • 尚未支援 PDF 頁碼引用
  • 尚未建立完整的引用驗證機制
  • 尚未建立來源點擊功能
  • 尚未實作文件版本管理
  • 尚未評估回答與引用的對應正確率
  • 尚未處理多輪對話中的來源管理

明天要做什麼

目前我們已經完成:

  • 文件切塊
  • Embedding
  • Vector Database
  • Vector Search
  • LLM 問答
  • 基本引用來源

下一步要進一步改善 RAG 的搜尋品質

因為:

如果一開始搜尋到的資料就不相關,即使 LLM 能力再強,也很難產生可靠的回答

因此,Day 8 將會探討:

  • Top-K 對搜尋結果的影響
  • Chunk Size 與 Chunk Overlap 的調整
  • 如何評估搜尋結果是否相關
  • Retrieval Precision 的基本概念
  • 建立簡單的 RAG 搜尋品質測試

從「能夠說明答案依據」,進一步走向「確保取得的依據真的相關」


上一篇
[Day 6] 串接 Vector Search 與 LLM 讓 AI 助理真正看著資料回答問題
下一篇
[Day 8] 提升 RAG 搜尋品質:Top-K、Chunk 調整與 Retrieval Precision
系列文
AI 不只會回答:30 天打造一套真正能上線的智慧助理12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言