iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
AI Engineering

AI Agent 系統開發 30 天系列 第 17 篇

讓客服 Agent 透過 RAG 查詢退貨規定並回答

  • 分享至 

  • xImage
  •  

當顧客詢問「已拆封的藍牙耳機可以退貨嗎?」時,最直覺的處理方式不是讓語言模型憑著記憶直接作答,而是讓系統在回答前先去「查詢」商城的退換貨規章。

這類退換貨政策屬於企業內部資料,隨時可能修訂,不同商品類別也有各自的限制。若直接讓模型自由回答,模型只能依賴訓練時讀過的公開資料憑空猜測,容易給出錯誤承諾;若每次發問都把整份數萬字的規章手冊直接塞進 Prompt,每一輪對話都會產生高昂的 Token 費用與延遲,且過多無關條款容易稀釋模型的注意力。

更有效的做法是在生成答案前,先由檢索流程從外部知識庫翻出與提問最相關的兩至三條具體條文,再交由模型組織出客服回覆。這就是 RAG(Retrieval-Augmented Generation,檢索增強生成) 架構。

整個處理架構如下圖所示:
https://ithelp.ithome.com.tw/upload/images/20260928/20111896oXxv3625UP.png

系統收到提問後,先由檢索器(Retriever)查詢相關條款片段,再連同原始問題一起交給生成模型組織回覆。

檢索器裡查詢的,是預先儲存在向量資料庫(Vector Database)中的外部知識。如果檢索只依賴傳統資料庫的關鍵字比對(如 SQL LIKE 或全文檢索),容易因為用詞差異而漏搜。例如顧客問的是「已拆封的藍牙耳機可以退貨嗎?」,但規章標題與內文寫的是「入耳式耳機、耳塞式耳機屬於個人衛生用品,經拆封恕不接受退換貨」。兩者沒有完全相同的關鍵字,但在語意上高度相關。

向量資料庫解決了這個限制。它不依賴完全相同的字詞,而是將文字轉換為代表語意的數值向量,藉由計算向量之間的距離找出意思最相近的條款。單篇規章、客服 FAQ 或產品手冊都適合這種方式。依部署環境不同,常見選擇包括:

  • 本機開發與測試:本文採用的 Chroma,資料直接存於本機磁碟,不需架設外部伺服器即可完整跑通切塊與查詢流程。
  • 正式環境自架:Qdrant、Milvus,或在既有關聯式資料庫上啟用向量擴充(如 PostgreSQL 的 pgvector)。
  • 雲端代管服務:如 AWS Bedrock Knowledge Bases、Google Cloud Vertex AI RAG Engine。

這個範例使用 Chroma,讓讀者能直接在本機驗證完整資料流。接著,系統將運作流程拆成「事前建立索引」與「即時檢索與生成」兩個階段:

  • 事前建立索引(只做一次):在規章發布或修改時,預先將整份文件切分為具備獨立主題的片段,並計算向量存入 Chroma;只要規章未修改,就不需在每次對話時重複計算。
  • 即時檢索與生成(每次提問):顧客提問時,系統只需計算該提問的單一向量,在 Chroma 中快速比對出意思最相近的兩至三條規定,組裝進 Prompt 交給模型組織回覆。

確定了儲存架構與運作時序後,首先理解系統如何將原始文件轉換為向量索引。

將條款切為 Chunk 並計算 Embedding

若要讓檢索器能精準找到答案,首先必須為企業規章建立向量索引。本文以一份電商的《商城退貨與售後服務政策》為例,完整條款收錄在範例程式 ai-agent-sample/rag-chroma-return-policy 的 data/商城退貨與售後服務政策.md。文件包含鑑賞期原則、特殊商品例外、瑕疵換貨、物流管道與退款時程等五個章節。

我們不能把整份文件直接轉成單一向量,否則耳機拆封、退款時程等具體細節會被稀釋,檢索時也無法精準挑出特定條文。因此在存入向量資料庫前,必須先將文件切成具備獨立主題的文字片段,這在 RAG 實務中稱為 Chunk。

當我們將規章切分為一個個獨立的 Chunk 後,Embedding 模型會將每個片段映射到多維空間中的幾何座標;語意越相近的內容,在空間中的距離越近。以下用二維位置簡化呈現條款在空間中的距離關係:

https://ithelp.ithome.com.tw/upload/images/20260928/20111896nGErkdK2j0.png

透過這張分佈圖,可以看出向量空間語意聚集的本質:

  1. 同主題的條款自然聚集成群:第二章「不適用七日鑑賞期之特殊商品類別」中的個人衛生用品條款(第 2.1 節),雖然分別列舉了「耳機」、「內衣泳衣」與「電動牙刷」等不同商品,但因為規範核心皆為「拆封後恕不接受退貨」,Embedding 模型會將它們映射在相鄰的座標區域。
  2. 不同業務流程的條款在空間中拉開距離:第四章的退貨物流管道(超商退貨便代碼、宅配到府收件)與第五章的退款時程(信用卡退款天數、LINE Pay 即時退款)在語意上屬於不同操作環節,因此與衛生用品群組保持明顯距離。
  3. 向量資料庫的任務:Chroma 負責儲存這些條款的空間座標與原文;後續檢索時,只需計算幾何距離,就能找出意思最相近的條款。

條款切塊策略的選擇

既然進入向量庫的最小單元是 Chunk,且目標是讓每個條款在向量空間中擁有清晰的語意座標,那麼要如何將整份連續的文件切成片段?在 RAG 實務中,常見的切塊策略分為固定長度切分與結構化切分兩種:

固定長度切分(Fixed-size Token Chunking)

固定長度切分不考慮文件結構,機械式地每固定字數(如 300 或 500 個 Token)切成一塊。由於固定字數切割容易切在句子中間,實務上通常會設定 10% 至 20% 的重疊(Overlap),讓後面的片段能向前重複涵蓋前一個片段結尾的文字,避免語意在交界處硬生生中斷。

https://ithelp.ithome.com.tw/upload/images/20260928/20111896tLbHYjMtnz.png

以商城規章第 1.3 節的「若原廠外盒損毀,退款將扣除 10% 至 30% 整新費用。」為例,有無 Overlap 會直接影響片段的可用性:

  1. 沒有 Overlap:若切分剛好落在金額前,Chunk 2 只分到「10% 至 30% 整新費用。」,遺失了前半句的關鍵動作與受詞。單獨檢索出這個片段時,模型看不懂在扣什麼費用、為什麼被扣。
  2. 加了 Overlap:Chunk 2 往前重複抓取了前一個片段結尾的「退款將扣除」,拼成「退款將扣除 10% 至 30% 整新費用」,讓語意保持完整可讀。

雖然 Overlap 能改善斷句問題,但固定長度切分本質上依然無法辨識文件結構:它既不知道這段話屬於「第一章 鑑賞期與一般退貨原則 > 1.3 退貨包裝要求」的前提脈絡,也容易將無關的條款硬塞在同一個片段中。對於規章、法規與說明手冊等結構嚴謹的文件,更合適的做法是採用結構化切分。

結構化切分(Structure-aware Chunking)

與其盲目算字數,更直覺的做法是依文件天然的格式標記(如 Markdown 的 ## 章節與 ### 條款)切分,讓每一條規定獨立成為一個 Chunk。

但直接按標題切分時,容易遇到一個問題:小節會遺失上層章節的前提條件。

例如「2.1 個人衛生與貼身用品」小節只列出耳機、內衣拆封不退,卻沒有包含父章節「第二章 不適用七日鑑賞期之特殊商品類別」的大標題。單獨把小節切出來時,文字裡甚至沒有出現「鑑賞期」這三個字。

因此切塊時,必須將章節路徑(Breadcrumbs)直接加在內文前面,組成完整的條款片段:

商城退貨與售後服務政策 > 第二章 不適用七日鑑賞期之特殊商品類別 > 2.1 個人衛生與貼身用品
基於衛生安全考量,以下商品經拆封後恕不接受退貨:
- 內衣褲、泳衣、襪子。
- 入耳式耳機、耳塞式耳機、耳罩式耳機。

把標題路徑與條款內文合在一起送去計算向量,Embedding 模型就能同時掌握「這是哪份政策」、「屬於什麼大類」與「具體有哪些商品」,大幅提升檢索的精準度。

實作:切塊並建立 Chroma 向量庫

理解了切塊策略與向量空間的映射原理後,接下來動手實作切塊並寫入 Chroma。

範例專案 ai-agent-sample/rag-chroma-return-policy 的資料夾結構如下:

rag-chroma-return-policy/
├── data/
│   └── 商城退貨與售後服務政策.md
├── src/
│   └── rag_chroma_return_policy/
│       ├── ingest.py    # 讀取 Markdown、切塊並建立 Chroma 向量庫
│       ├── tool.py      # 封裝 Chroma 查詢為客服檢索 Tool
│       └── demo.py      # 整合 Prompt 與模型生成附引用的回答
└── pyproject.toml

data/商城退貨與售後服務政策.md 是尚未切分與向量化的原始政策語料,共有五大章節與十五個具體條款:

  1. 第一章 鑑賞期與一般退貨原則(第 1.1 至 1.3 節):七日猶豫期規範、計算方式與退貨包裝整新費。
  2. 第二章 不適用七日鑑賞期之特殊商品類別(第 2.1 至 2.4 節):個人衛生用品、生鮮短效期、客製化給付與拆封影音軟體。
  3. 第三章 瑕疵品處理與換貨規則(第 3.1 至 3.2 節):瑕疵判定標準,以及非瑕疵商品不提供直接換貨的原則。
  4. 第四章 退貨流程與物流方式(第 4.1 至 4.2 節):線上申請步驟與超商、宅配退回管道。
  5. 第五章 退款方式與作業時程(第 5.1 至 5.2 節):各付款工具退款天數表與折價券、購物金處理。

在 src/rag_chroma_return_policy/ingest.py 中,切塊函式實作了層級走訪與標題繼承邏輯:

from pathlib import Path

def chunk_policy_markdown(file_path: Path) -> list[dict]:
    content = file_path.read_text(encoding="utf-8")
    lines = content.splitlines()

    chunks = []
    chapter = "一般條款"
    section = ""
    buffer: list[str] = []

    def flush(fallback: str) -> None:
        nonlocal buffer
        body = "\n".join(buffer).strip()
        if body:
            if section:
                title = f"{chapter} > {section}"
            elif fallback:
                title = f"{chapter} > {fallback}"
            else:
                title = chapter
            chunks.append({
                "chapter": chapter,
                "section": section or fallback,
                "title": title,
                "text": body,
            })
        buffer = []

    for line in lines:
        if line.startswith("## "):
            flush("概述")
            chapter = line.removeprefix("## ").strip()
            section = ""
            continue
        if line.startswith("### "):
            flush("")
            section = line.removeprefix("### ").strip()
            continue
        buffer.append(line)

    flush("")
    return chunks

chunk_policy_markdown 循序走訪行內容:遇到 ## 時更新當前章節名稱並將章節引言切成概述;遇到 ### 時則更新小節名稱並切出條款片段。每個片段的 title 會自動組合「章節 > 小節」的路徑。整份文件共切出 15 個片段。

切出 Chunk 後,接著在同一個檔案中建立 Chroma collection,並指定 paraphrase-multilingual-MiniLM-L12-v2 作為 Embedding 模型。這個模型支援中文查詢與中文條款之間的語意比對:

# 將向量資料持久化儲存在 data/chroma
client = chromadb.PersistentClient(path="data/chroma")

# 指定負責把中文問題與條款轉成向量的 Embedding 模型
emb_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="paraphrase-multilingual-MiniLM-L12-v2"
)

# collection 會使用同一個模型處理寫入資料與後續查詢
collection = client.create_collection(
    name="return_policy",
    embedding_function=emb_fn,
)

寫入 collection 的每個 Chunk 包含三組資料:documents 是計算向量的文字,metadatas 是引用來源,ids 則是 Chroma 用來識別資料的唯一值:

# 在條款內文前加上文件名稱與標題,一起送去計算向量
documents = [
    f"商城退貨與售後服務政策 > {c['title']}\n{c['text']}"
    for c in chunks
]

# 保留引用來源;metadata 不會送進 Embedding 模型
metadatas = [
    {
        "doc_title": "商城退貨與售後服務政策",
        "title": c["title"],
        "version": "2026-03-01",
    }
    for c in chunks
]

# 為每個 Chunk 產生 Chroma collection 內的唯一 ID
ids = [f"pol-{i+1:02d}" for i in range(len(chunks))]

# Chroma 計算 documents 的向量,再連同原文與 metadata 一起寫入
collection.add(ids=ids, documents=documents, metadatas=metadatas)

collection.add() 會把 documents 交給指定的 Embedding 模型計算向量,再將向量、原文、metadata 與 ID 一起寫入 Chroma。

到這裡,src/rag_chroma_return_policy/ingest.py 的程式碼就已全部完成。這是一個獨立執行的離線前置腳本,目的不是回答顧客問題,而是先把規章切塊並將向量持久化寫入磁碟。

先在終端機執行這個腳本,建立本機的 Chroma 向量庫:

$ uv run python -m rag_chroma_return_policy.ingest
成功寫入 15 筆政策條款至向量庫:data/chroma

執行完成後,磁碟目錄 data/chroma/ 中就有了現成的向量資料。以第 2.1 節為例,這個 Chunk 寫入 Chroma 後包含以下資料:

ID
pol-04

document(送進 Embedding 模型計算向量)
商城退貨與售後服務政策 > 第二章 不適用七日鑑賞期:個人衛生與貼身用品(2.1)
依據《通訊交易解除權合理例外情事適用準則》,基於衛生安全考量……

metadata(檢索時隨原文一起取回)
doc_title: 商城退貨與售後服務政策
title: 第二章 不適用七日鑑賞期:個人衛生與貼身用品(2.1)
version: 2026-03-01

向量搜尋使用 document 判斷這個 Chunk 是否與顧客問題相關。找到這個 Chunk 後,程式再使用 metadata 顯示文件名稱、條款名稱與版本,讓模型能在回答中標示引用來源。ID 只用來識別 Chroma 中的這筆資料。

建立好離線向量庫後,接下來進入即時服務階段,撰寫查詢這個向量庫的客服 Tool。

檢索相關條款片段

收到顧客提問時,系統呼叫 Chroma 的 collection.query() 進行向量搜尋。檢索時,顧客查詢的問題必須先經由與建立索引時相同的 Embedding 模型轉換為向量,投射到同一個語意空間中,藉由幾何距離比對出與問題最靠近的條款:

https://ithelp.ithome.com.tw/upload/images/20260928/20111896AmLunsFYbg.png

如上圖所示,向量檢索比對的過程如下:

  1. 提問投影到相同空間:顧客詢問「已拆封藍牙耳機可退貨嗎?」(圖中橘色虛線方塊),經由相同的 Embedding 模型計算後,被投射到同一個語意座標系中。
  2. 跨越用詞差異精準命中:雖然顧客提問寫的是「藍牙耳機」,政策條款寫的是「入耳式耳機、耳塞式耳機」,但兩者在語意空間中高度重合,座標直接落在「個人衛生用品」群組旁,與「耳機拆封恕不接受退貨」距離最近。
  3. 自然排除無關主題:距離較遠的「物流方式」與「退款作業」條款,因幾何距離過大,在計算 top_k 排序時被自然排除在外。

在 src/rag_chroma_return_policy/tool.py 中,載入建立索引時寫入的 return_policy collection,並指定相同的 Embedding 模型:

from pathlib import Path
import chromadb
from chromadb.utils import embedding_functions
from langchain_core.tools import tool

# 指向 ingest.py 建立的持久化向量庫
CHROMA_DIR = Path("data/chroma")

def get_collection() -> chromadb.Collection:
    # 開啟磁碟上既有的 Chroma 資料
    client = chromadb.PersistentClient(path=str(CHROMA_DIR))

    # 查詢時沿用建立索引時使用的 Embedding 模型
    emb_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
        model_name="paraphrase-multilingual-MiniLM-L12-v2"
    )

    # 載入已寫入 15 個政策 Chunk 的 collection
    return client.get_collection(name="return_policy", embedding_function=emb_fn)

取得 collection 後,query_return_policy() 將顧客問題交給 Chroma。Chroma 先計算問題向量,再取回距離最近的 top_k 個 Chunk。函式最後把每個 Chunk 的來源資訊與內文組成可交給語言模型閱讀的文字:

# 將函式註冊成可供 Agent 呼叫的 Tool
@tool
def query_return_policy(question: str, top_k: int = 2) -> str:
    """查詢電商商城的退換貨政策、鑑賞期例外規則、瑕疵換貨與退款時程。"""
    collection = get_collection()

    # 取回語意最接近問題的 top_k 筆原文與來源資訊
    results = collection.query(
        query_texts=[question],
        n_results=top_k,
        include=["documents", "metadatas"],
    )

    # collection 沒有任何可用資料時,不交給模型自行猜測
    if not results["documents"] or not results["documents"][0]:
        return "查無相關政策規章。"

    # Chroma 支援批次查詢;這裡只有一個問題,因此取索引 0
    docs = results["documents"][0]
    metas = results["metadatas"][0]

    # 將每筆條款整理成帶有引用編號、文件名稱與版本的文字
    formatted_chunks = []
    for i in range(len(docs)):
        meta = metas[i]
        doc_text = docs[i]
        item = f"[{i+1}] 《{meta['doc_title']}》{meta['title']}(版本:{meta['version']})\n{doc_text}"
        formatted_chunks.append(item)

    # 用空白行隔開不同 Chunk,避免條款內容混在一起
    return "\n\n".join(formatted_chunks)

測試單獨執行檢索:

$ uv run python -m rag_chroma_return_policy.demo "已拆封的藍牙耳機可以退貨嗎?" --retrieval-only
顧客提問:已拆封的藍牙耳機可以退貨嗎?

【檢索到的條款片段】:
[1] 《商城退貨與售後服務政策》第一章 鑑賞期與一般退貨原則 > 1.3 退貨包裝要求(版本:2026-03-01)
商城退貨與售後服務政策 > 第一章 鑑賞期與一般退貨原則 > 1.3 退貨包裝要求
退貨時請以原寄送紙箱包裝完整。若原紙箱已遺失,請使用其他完整紙箱包裝,切勿直接在商品原廠外盒上黏貼宅配單、膠帶或書寫文字。若原廠外盒損毀或商品有使用痕跡,本商城將依損壞程度自退款中扣除 10% 至 30% 之整新整修費用。

[2] 《商城退貨與售後服務政策》第二章 不適用七日鑑賞期之特殊商品類別 > 2.1 個人衛生與貼身用品(版本:2026-03-01)
商城退貨與售後服務政策 > 第二章 不適用七日鑑賞期之特殊商品類別 > 2.1 個人衛生與貼身用品
基於衛生安全考量,以下商品經拆封(包含封口貼紙撕毀、封膜破壞)後恕不接受退貨:
- 內衣褲、泳衣、襪子、塑身衣。
- 入耳式耳機、耳塞式耳機、耳罩式耳機。
- 電動牙刷、刮鬍刀、美容儀、體脂計。
- 寢具(床包、枕套、被套)、毛巾、浴巾。

從執行結果可以看到,真正包含解答的「2.1 個人衛生與貼身用品」排在第二名 [2],第一名 [1] 則是同樣包含「退貨」與「原廠外盒損毀」詞彙的「1.3 退貨包裝要求」。

向量檢索只比對主題語意距離,無法判斷哪一段條款才具備解答的充分條件。因此實務上必須設定 top_k > 1(此處取 2 筆)取回候選條款,再交由後續的語言模型篩選依據;若設定 top_k=1,系統就會因為遺漏關鍵條款而答錯。

這也是提供 --retrieval-only 的原因:排查 RAG 錯誤時,先確認正確條款是否落在 top_k 候選集內;若檢索階段未命中,調整後續 Prompt 或模型也無法解決問題。

結合檢索結果與 Prompt 產生回答

顧客送出問題後,Chroma 先取回相關條款,再由語言模型根據問題與條款生成客服回覆:

在 src/rag_chroma_return_policy/demo.py 中,將檢索到的條款與顧客問題組裝進 Prompt,並設定嚴格的依據生成規則:

from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, SystemMessage
from rag_chroma_return_policy.tool import query_return_policy

SYSTEM_PROMPT = """你是一名專業的電商客服助理。請嚴格根據提供的【參考條款】回答顧客的退換貨問題。

規則:
1. 僅能根據提供的參考條款事實回答,不得捏造或使用條款以外的常識補充。
2. 回答時必須明確引用依據條款編號,例如 [1]、[2]。
3. 若參考條款中未包含回答問題所需的資訊,請明確說明「目前規章未包含相關資訊,請聯繫人工客服」,不可自行推測。
4. 態度親切客氣,先給出明確結論(可退/不可退/退貨步驟),再列出詳細說明與條件。"""

def ask_agent(question: str, top_k: int = 2) -> None:
    # 1. 檢索相關條款
    context_text = query_return_policy.invoke({"question": question, "top_k": top_k})

    # 2. 交由模型生成回答
    model = ChatAnthropic(model="claude-3-5-haiku-latest")
    response = model.invoke([
        SystemMessage(content=SYSTEM_PROMPT),
        HumanMessage(
            content=f"【參考條款】:\n{context_text}\n\n【顧客問題】:{question}"
        ),
    ])

    print(f"顧客提問:{question}\n")
    print("客服回覆:")
    print(response.content)

到這裡,系統完成了標準 RAG 的核心管線:預先將政策語料切為帶階層標題的 Chunk 存入向量庫,在每次提問時依語意距離取回候選片段,再由語言模型依據檢索內容組織出附帶條款引用的客服回覆。

這套固定管線能解決資料即時性與模型憑空猜測的問題,但在實際客服場景中仍有三項明顯限制:

  1. 無條件觸發檢索:無論顧客只是發送「你好」、「謝謝」等問候語,或是詢問與退換貨無關的事項,管線都必定計算 Embedding 並查詢向量庫,造成不必要的運算延遲與成本。
  2. 無法處理複合問題:單次向量檢索只能比對單一語意焦點。若顧客提出「客製化保溫杯刻錯名字可以換貨嗎?運費誰出?多久能收到新品?」,單次搜尋無法同時涵蓋特殊商品例外、瑕疵換貨運費與配送天數等多項獨立條款。
  3. 缺乏證據驗證機制:若檢索器取回的片段未包含解答,系統依然會直接交給生成模型,全依賴 Prompt 約束模型拒答,無法在程式層確保證據完整。

為了解決固定管線的限制,下一步是在流程中加入決策能力——由模型作為 Agent 自行判斷何時檢索、將複合問題拆解為多個獨立查詢,並在生成前驗證取回的證據是否充分


上一篇
用 MCP 接入外部工具伺服器
下一篇
Agentic RAG:由 Agent 決定是否檢索、拆解問題與驗證證據
系列文
AI Agent 系統開發 30 天 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言