LangChain 課程 – RAG:檢索增強生成
主題:Context Compression(壓縮策略)
簡介
在 RAG(Retrieval‑Augmented Generation) 工作流程中,模型會先從外部知識庫取回相關文件,然後把這些文件的內容(context)塞進大型語言模型(LLM)作為提示(prompt),讓模型產生更精確且具備專業知識的回應。
然而,實務上常會遇到兩個瓶頸:
- Token 限制:大多數商業 LLM(如 OpenAI GPT‑3.5/4、Claude、Gemini)每次呼叫只能接受幾千個 token。檢索回來的文件往往遠超過這個上限。
- 訊息噪聲:檢索結果中往往混雜了許多與問題無關的段落,直接送進模型會降低產出品質,甚至造成「幻覺」現象。
Context Compression(上下文壓縮)正是為了解決這兩個問題而設計的策略:在送入 LLM 前,先把大量檢索結果 精簡、濃縮、或重新組織 成較小且高度相關的文字片段。這不僅讓我們在 token 數量上更有彈性,也能提升模型對關鍵資訊的聚焦度,最終得到更可靠的答案。
本篇文章將從概念、實作範例、常見陷阱與最佳實踐,甚至實際應用情境,完整說明 Context Compression 在 LangChain 中的使用方式,幫助你在 RAG 專案中快速上手、避免踩雷。
核心概念
1. 為什麼需要壓縮?
| 問題 | 造成的影響 |
|---|---|
| Token 超限 | API 請求失敗或被截斷,答案不完整 |
| 資訊冗餘 | 模型需在大量不相關文字中找出關鍵點,增加幻覺風險 |
| 成本上升 | 每 1k token 都要付費,冗長上下文直接提升成本 |
結論:透過壓縮,我們可以在不犧牲關鍵資訊的前提下,把「大量檔案」變成「精簡摘要」或「關鍵句子」供模型使用。
2. 壓縮的主要策略
| 策略 | 說明 | 適用情境 |
|---|---|---|
| 摘要(Summarization) | 使用 LLM 或專用模型,把整段文字濃縮成數句摘要。 | 文本篇幅大、主題較為廣泛 |
| 關鍵句抽取(Key Sentence Extraction) | 只保留與問題最相關的句子或段落。 | 問題聚焦在特定事實或數據 |
| 向量聚類(Vector Clustering) | 先把檢索結果向量化,聚類後只取每個群的代表向量。 | 大量相似文件(如 FAQ、說明書) |
| 分層檢索(Hierarchical Retrieval) | 先快速過濾出「高相關」文件,再在這些文件內做更細緻的壓縮。 | 多層次知識庫(章節 → 段落) |
| 混合式(Hybrid) | 同時使用摘要 + 關鍵句抽取,取得最完整的資訊。 | 複雜問題需要多種視角 |
LangChain 已經提供了 LLMChain、MapReduceDocumentsChain、RefineDocumentsChain 等內建工具,讓開發者可以快速組合上述策略。
3. 壓縮流程概覽
flowchart TD
A[使用者提問] --> B[向量檢索 (Retriever)]
B --> C[取得 Top‑k 文件]
C --> D[壓縮策略 (CompressionChain)]
D --> E[生成提示 (PromptTemplate)]
E --> F[呼叫 LLM]
F --> G[回傳答案]
- Retriever 取得最相關的
k份文件。 - CompressionChain 依照選擇的策略,將
k份文件壓縮成 一段或數段 簡短文字。 - 把壓縮後的文字塞進 PromptTemplate,送給 LLM。
程式碼範例
以下範例均以 Python 為主(LangChain 主要支援 Python),若你使用 JavaScript,概念完全相同,只是語法略有差異。每個範例都會說明 為什麼、怎麼做、以及 注意事項。
範例 1:最簡單的摘要壓縮(Summarization)
from langchain.llms import OpenAI
from langchain.chains.summarize import load_summarize_chain
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.document_loaders import TextLoader
# 1️⃣ 載入原始文件(假設是一段長說明文字)
loader = TextLoader("data/knowledge_base.txt")
documents = loader.load()
# 2️⃣ 先把文件切成適合 LLM 處理的段落(避免一次塞太多 token)
splitter = RecursiveCharacterTextSplitter(chunk_size=1500, chunk_overlap=200)
chunks = splitter.split_documents(documents)
# 3️⃣ 建立一個「摘要」Chain
llm = OpenAI(model="gpt-3.5-turbo", temperature=0)
summarize_chain = load_summarize_chain(
llm, chain_type="map_reduce", verbose=True
)
# 4️⃣ 執行摘要,得到壓縮後的文字
compressed_summary = summarize_chain.run(chunks)
print("🗒️ 壓縮後的摘要:")
print(compressed_summary)
說明
RecursiveCharacterTextSplitter會把長文件切成 1500 個字元 的 chunk,確保每次呼叫 LLM 不會超過 token 限制。load_summarize_chain內建了 Map‑Reduce 摘要方式:先對每個 chunk 做「map」摘要,再把所有 map 結果合併成最終的「reduce」摘要。- 適用情境:文件長度超過 4k token,且問題需要整體概觀(例如產品說明書、法律條款)。
範例 2:關鍵句抽取(Key Sentence Extraction)
from langchain.prompts import PromptTemplate
from langchain.llms import OpenAI
from langchain.chains import LLMChain
from langchain.vectorstores import FAISS
from langchain.embeddings import OpenAIEmbeddings
from langchain.document_loaders import PyPDFLoader
# 1️⃣ 建立向量檢索器
loader = PyPDFLoader("data/annual_report.pdf")
docs = loader.load_and_split()
embeddings = OpenAIEmbeddings()
vectorstore = FAISS.from_documents(docs, embeddings)
# 2️⃣ 取得前 5 個相關文件
retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
relevant_docs = retriever.get_relevant_documents("2023 年營收成長的主要驅動因素為何?")
# 3️⃣ 設計「關鍵句抽取」的 Prompt
extract_prompt = PromptTemplate(
input_variables=["context", "question"],
template="""
以下是一段與問題相關的文字,請只挑出 **最能直接回答問題** 的句子,並以條列式呈現。若無明確答案,請回傳「無法判斷」。
問題:{question}
文字:
{context}
"""
)
# 4️⃣ 用 LLM 產生關鍵句
llm = OpenAI(model="gpt-3.5-turbo", temperature=0)
extract_chain = LLMChain(llm=llm, prompt=extract_prompt)
# 把所有檢索結果合併成一段,再交給 LLM 抽取
combined_context = "\n\n".join([doc.page_content for doc in relevant_docs])
key_sentences = extract_chain.run({"context": combined_context, "question": "2023 年營收成長的主要驅動因素為何?"})
print("🔑 抽取的關鍵句:")
print(key_sentences)
說明
- 這裡我們先 檢索 出與問題最相關的 5 份 PDF 頁面。
PromptTemplate明確指示 LLM 只回傳關鍵句,避免產生冗長摘要。- 適用情境:問題聚焦在單一事實或數據(如 KPI、財報指標),不需要完整的背景說明。
範例 3:結合向量聚類與摘要的混合壓縮
from langchain.vectorstores import FAISS
from langchain.embeddings import OpenAIEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.llms import OpenAI
from langchain.chains import LLMChain
from langchain.prompts import PromptTemplate
from sklearn.cluster import KMeans
import numpy as np
# 1️⃣ 載入文件並切塊
loader = TextLoader("data/faq_collection.txt")
docs = loader.load_and_split()
splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
chunks = splitter.split_documents(docs)
# 2️⃣ 向量化
embeddings = OpenAIEmbeddings()
vectors = embeddings.embed_documents([c.page_content for c in chunks])
# 3️⃣ K‑Means 聚類(假設想要 3 個主題)
num_clusters = 3
kmeans = KMeans(n_clusters=num_clusters, random_state=42)
cluster_ids = kmeans.fit_predict(vectors)
# 4️⃣ 每個 Cluster 取出最靠近中心的代表文件
representative_chunks = []
for i in range(num_clusters):
idxs = np.where(cluster_ids == i)[0]
# 計算與中心的距離
center = kmeans.cluster_centers_[i]
distances = np.linalg.norm(vectors[idxs] - center, axis=1)
rep_idx = idxs[np.argmin(distances)]
representative_chunks.append(chunks[rep_idx])
# 5️⃣ 對每個代表文件做「摘要」 (Map‑Reduce)
llm = OpenAI(model="gpt-3.5-turbo", temperature=0)
summary_prompt = PromptTemplate(
input_variables=["text"],
template="請把以下內容濃縮成 2 句要點,保持資訊完整性:\n\n{text}"
)
summary_chain = LLMChain(llm=llm, prompt=summary_prompt)
summaries = []
for chunk in representative_chunks:
s = summary_chain.run({"text": chunk.page_content})
summaries.append(s)
compressed_context = "\n\n".join(summaries)
print("🧩 混合壓縮後的上下文:")
print(compressed_context)
說明
- 向量聚類 先把大量的 FAQ 切成小段,然後用 K‑Means 把相似段落分組。
- 每個群只保留 最具代表性的段落,再交給 LLM 做簡短摘要。
- 這樣同時解決 冗餘資訊(聚類)與 token 超限(摘要)兩個問題。
- 適用情境:大量相似問答、說明文件、客服對話紀錄等。
範例 4:使用 RefineDocumentsChain 進行「增量式摘要」
from langchain.chains import RefineDocumentsChain
from langchain.llms import OpenAI
from langchain.prompts import PromptTemplate
from langchain.document_loaders import DirectoryLoader
# 讀取一個資料夾內的多個 txt 檔
loader = DirectoryLoader("data/manuals/", glob="*.txt")
docs = loader.load()
# 建立「增量式」摘要 Prompt
refine_prompt = PromptTemplate(
input_variables=["existing_answer", "new_chunk"],
template="""
以下是一段新資訊:
{new_chunk}
請根據已存在的摘要(如果有)更新它,使摘要同時包含新資訊且保持簡潔。已存在的摘要:
{existing_answer}
"""
)
# 建立 Chain
llm = OpenAI(model="gpt-3.5-turbo", temperature=0)
refine_chain = RefineDocumentsChain(
llm=llm,
question_prompt=PromptTemplate(
input_variables=["question"],
template="請為以下問題產生一段 150 字以內的摘要:{question}"
),
refine_prompt=refine_prompt
)
# 執行增量式摘要(先對第一段產生摘要,再逐段 refine)
compressed = refine_chain.run({"input_documents": docs, "question": "產品安裝步驟要點"})
print("🔄 增量式摘要結果:")
print(compressed)
說明
RefineDocumentsChain會 逐段 讀取文件,先產生初始摘要,之後每讀入新段落就 refine(優化)先前的摘要。- 這種方式特別適合 文件順序很重要(例如安裝手冊、流程說明),因為每次都會把新資訊融合進去。
範例 5:在 JavaScript 中使用 LangChain 的 LLMChain 進行關鍵句抽取
import { OpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/prompts";
import { LLMChain } from "@langchain/chains";
import { FAISS } from "@langchain/vectorstores/faiss";
import { OpenAIEmbeddings } from "@langchain/embeddings/openai";
// 1️⃣ 建立向量檢索器(假設已經有 docs 向量化)
const vectorstore = await FAISS.fromDocuments(docs, new OpenAIEmbeddings());
// 2️⃣ 取得前 3 筆相關文件
const retriever = vectorstore.asRetriever({ k: 3 });
const relevantDocs = await retriever.getRelevantDocuments("如何在 Node.js 中使用 LangChain?");
// 3️⃣ 合併成單一文字
const combined = relevantDocs.map(d => d.pageContent).join("\n\n");
// 4️⃣ 設計關鍵句抽取 Prompt
const prompt = PromptTemplate.fromTemplate(`
以下是一段與問題相關的文字,請只挑出能直接回答問題的句子,並以條列式呈現。
問題:{question}
文字:
{context}
`);
// 5️⃣ 呼叫 LLM
const llm = new OpenAI({ modelName: "gpt-3.5-turbo", temperature: 0 });
const chain = new LLMChain({ llm, prompt });
const answer = await chain.run({
question: "如何在 Node.js 中使用 LangChain?",
context: combined,
});
console.log("🔑 关键句抽取结果:\n", answer);
說明
- JavaScript 版的程式碼與 Python 版概念相同,只是使用
@langchain/*套件。 - 適用情境:前端或 Node.js 後端服務需要即時從文件庫中抽取關鍵資訊。
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 解決方式 / 最佳實踐 |
|---|---|---|
| 壓縮過度:摘要太短,失去關鍵細節 | LLM 受 token 限制,開發者過度縮減文字 | 設定合理的字數上限(例如 150~300 字),並在 Prompt 中明確要求「保留關鍵數據」 |
| 上下文斷層:抽取的句子缺少前後文,導致模型誤解 | 只抽出單句,卻缺少必要的前置說明 | 使用「段落」或「2‑3 句」作為最小單位,或在 Prompt 中加入「提供必要的背景」 |
| 檢索結果品質低:相關文件本身不夠好 | VectorStore 訓練資料品質差、embedding 不適合領域 | 使用領域特化的 Embedding(如 text-embedding-3-large),或自行微調向量模型 |
Prompt 變數未對齊:忘記傳入 question 或 context |
手寫 Prompt 時容易漏掉佔位符 | 使用 PromptTemplate 產生 Prompt,IDE 可即時檢查變數 |
| 成本失控:大量摘要或抽取導致 token 數暴增 | 每個文件都跑一次摘要,累積 token 超過預期 | 先做快速過濾(例如 BM25)再走 LLM 壓縮,或設定 max_tokens 上限 |
| 多語言混雜:文件中同時有中、英、日等語言 | LLM 可能在混雜語言下產生奇怪的摘要 | 先做語言偵測與分流,針對每種語言使用對應的 LLM |
其他最佳實踐
分層檢索 + 分層壓縮
- 第 1 層:使用快速的稀疏檢索(BM25)找出 20 篇候選文件。
- 第 2 層:對這 20 篇做向量檢索,取前 5 篇。
- 第 3 層:對 5 篇採用摘要或關鍵句抽取,得到最終上下文。
動態調整
k值- 依問題長度或複雜度自動決定要檢索多少文件。
- 例如「是/否」問題只取
k=2,而「比較」問題取k=10。
使用
ChatPromptTemplate- 若 LLM 為聊天模型(ChatGPT、Claude),使用
ChatPromptTemplate可以更好地控制系統訊息與使用者訊息的角色分配。
- 若 LLM 為聊天模型(ChatGPT、Claude),使用
監控 Token 使用量
- 在生產環境加入 middleware,紀錄每次請求的
prompt_tokens、completion_tokens,若超過設定門檻即觸發警報。
- 在生產環境加入 middleware,紀錄每次請求的
實際應用場景
| 場景 | 為何需要 Context Compression | 典型流程 |
|---|---|---|
| 客服機器人 | 客服知識庫常有上千篇 FAQ,直接檢索會超過 token 限制 | 1️⃣ BM25 快速過濾 → 2️⃣ 向量檢索 Top‑5 → 3️⃣ 關鍵句抽取 → 4️⃣ 產生回覆 |
| 醫療問答系統 | 法規、臨床指南篇幅龐大,且必須保留精確數據 | 1️⃣ 先使用領域特化 Embedding → 2️⃣ 聚類挑選代表段落 → 3️⃣ 法規摘要(保留條文編號) → 4️⃣ 產生答案 |
| 企業內部文件搜尋 | 員工查詢政策或 SOP 時,文件往往包含大量冗餘文字 | 1️⃣ 向量檢索 Top‑10 → 2️⃣ 每篇做「段落抽取」 → 3️⃣ 合併為 2‑3 段摘要 → 4️⃣ 交給 LLM 回答 |
| 程式碼輔助工具 | 大型程式庫(如 TensorFlow)說明文件超過數十 MB | 1️⃣ 以 DocArray 分段 → 2️⃣ K‑Means 聚類相似 API → 3️⃣ 針對每個聚類做簡短功能說明 → 4️⃣ 提供給開發者 |
| 新聞摘要平台 | 每天上千篇新聞,使用者只想看要點 | 1️⃣ 先用新聞標題向量檢索相關性 → 2️⃣ Map‑Reduce 摘要每篇 → 3️⃣ 再對同主題的摘要做二次精煉 → 4️⃣ 顯示給使用者 |
總結
- Context Compression 是 RAG 工作流中不可或缺的環節,能有效解決 token 限制、資訊噪聲 與 成本 三大痛點。
- 常見的壓縮策略包括 摘要、關鍵句抽取、向量聚類、分層檢索,依需求選擇或混合使用即可。
- LangChain 已提供
MapReduceDocumentsChain、RefineDocumentsChain、LLMChain等高階工具,讓開發者只需組合 Prompt 與向量檢索,即可快速實作各種壓縮方案。 - 實作時要避免 過度壓縮、斷層上下文 與 成本失控,建議採用 分層檢索 + 分層壓縮、動態調整
k、以及 監控 Token 使用 等最佳實踐。
透過本文的概念說明與實作範例,你應該已經能在自己的 LangChain 專案中,自信地設計與部署 Context Compression,讓 RAG 系統在效能、成本與品質上都達到最佳平衡。祝開發順利,期待看到你在實務中創造出更智慧、更高效的對話體驗!