在 Day 6,我們已經成功將 Vector Search 與 LLM 串接起來,讓 AI 助理可以根據知識庫回答問題
雖然系統已經可以回答問題,但還存在一個重要限制:
使用者如何知道這個答案是來自哪一份文件
使用者可能會進一步詢問:
如果系統只回傳 LLM 產生的文字,使用者很難驗證答案
因此,今天要替 RAG Assistant 加入基本的來源引用功能
讓系統不只回答問題,也能提供回答的參考依據
今天的目標包含以下五個部份:
完成後,系統的輸出不再只有回答文字
引用來源可以協助使用者追溯資料,但不代表引用本身就能保證答案一定正確
仍然需要搭配搜尋品質、Prompt 設計與答案驗證
RAG 的引用來源是指:
將 LLM 回答所參考的文件或資料片段,附加在回答中,讓使用者可以追溯資訊來源
在實際應用中,引用資訊可以更完整:
引用內容可以根據實際資料來源調整,例如:
在 Day 5,我們將資料儲存至 ChromaDB 時,加入了 Metadata:
當時的 Metadata 只有文件名稱,但它其實可以提供更多資訊
Metadata 可以理解為:
描述一筆資料的額外資訊
文件通常沒有頁碼,因此可以只儲存實際取得的資訊。
系統可以知道這段文字來自哪一份文件
如果回答內容不正確,可以根據 Chunk ID 找到原始資料
當知識庫包含多份文件時,可以區分不同來源
未來可以根據 Metadata 進行條件搜尋,例如:
當文件更新時,可以透過文件 ID、版本或更新時間判斷哪些資料需要重新建立 Embedding
這裡有一個重要設計:
引用來源應該盡可能來自實際的搜尋結果,而不是完全交由 LLM 自行編造
LLM 可以協助整理引用,但文件名稱、Chunk ID 等資訊應由程式保留與管理
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
}
當知識庫中有多份文件時,可能產生 ID 衝突
因此,可以在 ID 中加入文件名稱或文件識別碼:
正式系統中,也可以使用 UUID 或根據文件內容產生穩定的唯一 ID
Day 6 的搜尋功能只回傳文件內容:
documents = results.get(
"documents",
[[]]
)[0]
今天需要同時取得:
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 使用的距離函數,距離通常是用來排序或評估相似程度,不應直接當成通用的相似度百分比
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 則可以使用文字訊息呈現
除了將文件內容傳給 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)
這樣做的好處是:
更新 Prompt,要求 LLM 在回答中使用來源編號
def build_prompt(query, context):
return f"""
你是一個專業的 AI 智慧助理。
請根據提供的參考資料回答使用者問題。
回答規則:
1. 只能根據參考資料回答。
2. 如果資料不足,請明確說明無法確認。
3. 不要捏造參考資料沒有提到的內容。
4. 使用繁體中文回答。
5. 如果回答有參考資料支持,請在相關句子後面標註來源編號。
6. 來源編號必須來自提供的參考資料。
7. 不要自行創造不存在的來源編號。
參考資料:
{context}
使用者問題:
{query}
請提供清楚、簡潔的回答。
"""
不過,這裡必須注意:
不能完全依賴 LLM 自行產生引用編號
因為模型可能:
因此,程式端仍然需要保留原始搜尋結果,並在最終輸出中加入可驗證的引用資訊
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 從單純的問答系統,提升為具備基本來源追溯能力的系統
重要學習內容包括:
今天最大的收穫是:
一個實用的 RAG 系統,不只要回答問題,也要讓使用者知道答案的依據,以及如何追溯原始資料
目前仍然存在的限制:
目前我們已經完成:
下一步要進一步改善 RAG 的搜尋品質
因為:
如果一開始搜尋到的資料就不相關,即使 LLM 能力再強,也很難產生可靠的回答
因此,Day 8 將會探討:
從「能夠說明答案依據」,進一步走向「確保取得的依據真的相關」