承接前兩篇的 LlamaIndex 基礎,本篇聚焦在「檢索完成之後」的兩件事。
一個 query_engine.query(...) 呼叫內部大致是這樣的流程:
使用者問題
│
▼
Retriever ──► 取出 top-k 相關的 Node(文字片段)
│
▼
Node Postprocessor(可選)──► 過濾、重排序節點
│
▼
Response Synthesizer ──► 把節點餵給 LLM,組裝成最終答案 (← 本節重點)
│
▼
回傳 Response(或 Streaming Response)
Response Mode講的就是最後一步:Response Synthesizer 該用什麼策略,把檢索到的多個文字片段餵給 LLM,並組成一個答案。這件事在地端場景特別重要,因為它直接決定了:
這次查詢會呼叫幾次 LLM(地端 9B 模型每次生成都有實質延遲與顯卡負載)
每次呼叫要塞多少上下文(受限於 context window)
最終答案的詳細程度 vs. 回應速度如何取捨
| Mode | 運作方式 | LLM 呼叫次數 | 地端適用情境 |
|---|---|---|---|
compact(預設) |
先把能塞進 context window 的節點盡量「壓縮串接」成一個 prompt,塞不下才切下一輪,再用 refine 方式接續 | 較少 | 地端首選。9B 模型 context window 有限,能省一次是一次 |
refine |
逐一節點跑:先用第一個節點回答,再把「前一輪答案 + 下一個節點」丟給模型精修(refine),依序做完所有節點 | 每個節點一次(最多) | 需要非常仔細、逐段消化的長文摘要/精修,但地端會明顯變慢,較少用 |
tree_summarize |
把節點分批串接、各自送 LLM 得到多個「子答案」,再把子答案當成新的一批節點遞迴往上彙總,直到剩一個答案 | 呈樹狀遞迴,通常多於 compact |
適合「大量文件做摘要」這類任務;因為每一層彼此獨立,理論上可以平行送給地端模型(但要注意單張顯卡的併發上限) |
simple_summarize |
直接把所有節點文字截斷(truncate)到塞進單一 prompt,只呼叫一次 LLM | 1 次 | 需要極快回應、可接受漏掉部份細節時的地端快速摘要 |
no_text |
只跑 Retriever,不呼叫 LLM,回傳的 response.source_nodes 可直接檢視命中的節點 |
0 次 | 地端資源寶貴,可用於「先確認有沒有命中相關文件」再決定要不要真的送給 LLM 生成,或用來做 Debug/檢索品質評估 |
accumulate |
對每個節點各自套用同一個 query,把每次的回答獨立收集成陣列,最後串接成一份結果 | 每個節點一次 | 需要「對每段文件分別回答同一問題」的情境,例如逐份文件抽取欄位 |
compact_accumulate |
與 accumulate 相同,但送出前先做跟 compact 一樣的壓縮串接,減少呼叫次數 |
較 accumulate 少 |
accumulate 的地端省資源版本 |
query_engine = index.as_query_engine(
response_mode="compact", # 也可以是 "tree_summarize" / "no_text" / "accumulate" ...
similarity_top_k=5,
)
response = query_engine.query("公司的請假流程是什麼?")
print(response)
若你是用低階 API(自己組 Retriever + Response Synthesizer),則是在 get_response_synthesizer 上指定:
from llama_index.core import get_response_synthesizer
from llama_index.core.query_engine import RetrieverQueryEngine
synthesizer = get_response_synthesizer(response_mode="tree_summarize")
query_engine = RetrieverQueryEngine(
retriever=retriever,
response_synthesizer=synthesizer,
)
預設就用 compact:在 9B 這個等級的地端模型上,refine 模式一旦命中 5、6 個節點,等於連續發動好幾次生成,實測延遲會非常明顯,compact 幾乎都能取代它、且品質差異有限。
先 no_text 再決定要不要生成:如果你的應用場景常常「問了半天結果知識庫根本沒有相關資料」,可以先用 no_text 檢查 source_nodes 的相似度分數,分數太低就直接回覆「查無相關資料」,不必浪費一次地端 LLM 生成。
大量摘要任務用 tree_summarize,但注意平行度:地端如果只有一張顯卡在跑 ornith-1.5-9b,tree_summarize 理論上可平行的子任務,實際上還是會被 GPU 排隊序列化,效益會打折扣,記得評估。
simple_summarize 是犧牲品質換速度的手段:truncate 會直接丟掉塞不下的內容,只在「使用者要的是快速抓重點」而非精確答案時使用。
雲端大模型(如 GPT-4 等級)生成速度快,使用者等個一兩秒還能接受;但地端 9B 模型受限於硬體,完整生成一段答案可能要好幾秒甚至十幾秒。如果讓使用者盯著空白畫面等到最後才一次顯示,體驗會很差。Streaming 讓我們一收到第一個 token 就能往前端送,大幅降低「感受到的延遲」(perceived latency),即使總生成時間沒有變快。
官方文件的作法是在建立 query engine 時打開 streaming=True:
query_engine = index.as_query_engine(streaming=True, similarity_top_k=5)
streaming_response = query_engine.query("公司的請假流程是什麼?")
for text in streaming_response.response_gen:
print(text, end="", flush=True)
有幾個重點:
回傳的物件是 StreamingResponse,query() 呼叫本身在 LLM 開始生成時就會回傳(不是等生成完),實際文字要透過 response_gen 這個 generator 逐段取得。
若 query engine 內部需要多次呼叫 LLM(例如 refine 模式命中多個節點),只有最後一次 LLM 呼叫會被 streaming,前面的呼叫還是同步跑完。這也是另一個建議地端優先用 compact 的理由:呼叫次數少,代表使用者等待「開始 streaming」之前的空窗期也比較短。
要能 streaming,LLM 的整合必須支援串流輸出。Ollama 的 LLM 整合本身有支援 stream_complete / stream_chat,所以 ornith-1.5-9b 透過 Ollama 起服務時可以正常拿到逐 token 的輸出;如果換成不支援串流的 LLM 整合,呼叫時會丟出 NotImplementedError,建議先用小段文字測試一次再上線。
aquery + async_response_gen同步的 for text in streaming_response.response_gen 會阻塞整個 event loop,不適合放進像 FastAPI 這種非同步 Web 服務裡(會卡住其他請求)。正確做法是改用非同步查詢 aquery,並用 async for 搭配 async_response_gen():
query_engine = index.as_query_engine(streaming=True, similarity_top_k=5)
streaming_response = await query_engine.aquery("公司的請假流程是什麼?")
async for text in streaming_response.async_response_gen():
# 這裡就可以直接 yield 給前端,而不會卡住整個服務
print(text, end="", flush=True)
備註:
async_response_gen()是否可用、行為是否與response_gen完全對稱,會隨llama-index-core版本略有調整,正式上線前建議針對你安裝的版本跑一次最小範例驗證。若某個版本沒有提供,退而求其次的作法是把同步的response_gen丟進run_in_executor這類的執行緒池,避免阻塞主 event loop。
把上面的非同步 generator 包進 FastAPI 的 StreamingResponse,就能做出一個「邊生成邊顯示」的地端 RAG API:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
app = FastAPI()
class QueryRequest(BaseModel):
question: str
@app.post("/query")
async def query_endpoint(req: QueryRequest):
query_engine = index.as_query_engine(streaming=True, similarity_top_k=5)
streaming_response = await query_engine.aquery(req.question)
async def event_generator():
async for token in streaming_response.async_response_gen():
# SSE 格式:每則訊息用 "data: ..." 開頭,並以空白行結尾
yield f"data: {token}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
前端只要用 EventSource(原生瀏覽器 API)或任何支援 SSE 的 fetch 套件監聽這個端點,就能一段一段把文字接上畫面,體驗跟 ChatGPT 網頁版的「打字機效果」一致。
跟雲端 API 不同,地端服務背後就是你自己那一台(或那幾張)顯卡在跑 ornith-1.5-9b,這裡有幾個實務上要處理的坑:
模型服務本身的併發上限:Ollama 預設同時能處理的生成請求數是有限的(受顯存與設定影響),FastAPI 端就算開了非同步,多個使用者同時發問時,實際生成還是會在 Ollama 那端排隊。可以在 FastAPI 這層用 asyncio.Semaphore 主動限制同時進入生成階段的請求數,避免大量請求把服務打到 OOM 或逾時。
連線中斷處理:使用者關掉分頁或提前中止請求時,記得讓 generator 能被正常 cancel(FastAPI/Starlette 預設會處理 asyncio.CancelledError),避免地端 GPU 資源被「孤兒生成」佔用。
逾時設定要放寬:地端 9B 模型的 Time To First Token 可能比雲端 API 慢,Ollama(...) 的 request_timeout 建議抓寬一點(例如 120–180 秒),避免長文檢索 + 生成還沒完成就被判定逾時。
這一篇把 LlamaIndex query engine 檢索完成之後的兩件事講完了:
Response Modes 決定了「LLM 要被呼叫幾次、怎麼消化檢索到的節點」,在地端 9B 模型的資源限制下,compact 是最實用的預設值,no_text 則是省算力的好幫手。
Streaming(特別是非同步版本)是地端服務體感速度的關鍵,透過 aquery + async_response_gen() 搭配 FastAPI 的 StreamingResponse,可以做出跟雲端服務一樣流暢的打字機效果。