目錄
使用者說:「我最近完攀了白虎 5.11d、閃電 5.12a、看起來我可以 5.11c、泡泡龍 5.11b、新竹客家人 5.10d,推薦 3 條我沒爬過的路線。」系統看到白虎,就 return 了。後面四條?不存在。
這是在 NobodyClimb 攀岩推薦系統上遇到的真實 bug。這篇記錄問題的根因、學術界和業界怎麼處理類似問題、以及最終選擇的分層解法。
問題:for...return 的代價
extractRouteReference() 的邏輯很簡單:遍歷已知路線名稱,比對查詢字串,命中第一條就 return。這在「推薦跟白虎類似的路線」這種單實體查詢完全沒問題。但使用者一次給了五條路線時,三種資訊直接蒸發:
難度範圍損失 -- 五條路線橫跨 5.10d 到 5.12a,代表使用者能力涵蓋三個大等級。只取白虎 5.11d,推薦範圍被壓在 5.11a-5.12b(正負三步),完全忽略使用者已經能完攀 5.12a。
風格偏好損失 -- 多條路線可能分佈在不同岩場、不同類型(sport / trad / boulder),這些分佈反映攀登偏好。單一路線代表不了。
排除清單不完整 -- 使用者說「沒爬過的」,但 excludeRouteId 只排除白虎,其餘四條照樣出現在推薦結果裡。
在 IR 領域,這叫 complex information need。Metzler & Croft (2005) 的 Markov Random Field 檢索模型當年就是為了打破「查詢中的詞彼此獨立」這個方便但錯誤的假設;多實體查詢是同一類問題的放大版——不只是詞之間有依賴,是查詢裡塞了好幾個各自完整的實體。在推薦系統的語境裡,這是 cold-start 問題的反面:使用者主動給了豐富的偏好訊號,系統卻只消費了一小部分。
解法一覽
研究了一輪之後,解法大致分五條路線(雙關)。
Multi-Entity Extraction
最直覺的修法:把 extractRouteReference() 改成 extractRouteReferences(),回傳陣列而非單一結果。
Rule-based 做法:把 for...return 改成 for...push,加上 consumed-range 機制避免重疊匹配(「看起來我可以」匹配後,「我可以」不能再匹配)。多條路線的難度取 union range:
gradeFilter = {
$gte: min(allGrades) - margin,
$lte: max(allGrades) + margin
}
LLM-based 做法:讓 LLM 用 structured output 一次擷取所有路線:
{
"routes": [
{"name": "白虎", "grade": "5.11d"},
{"name": "閃電", "grade": "5.12a"},
{"name": "看起來我可以", "grade": "5.11c"},
{"name": "泡泡龍", "grade": "5.11b"},
{"name": "新竹客家人", "grade": "5.10d"}
],
"intent": "recommend_next_challenge",
"exclude_mentioned": true
}
學術上,Li et al. (2020) 的 FLAT(Flat-Lattice Transformer)用 lattice 結構同時吃字與詞,發表當時在中文 NER 上是 SOTA(現在早被後續模型推進了,但架構思路仍有參考價值)。Yan et al. (2021) 則把 flat / nested / discontinuous 三種 NER 子任務統一成一個 seq2seq 生成問題,一次把所有實體「生成」出來,天然支援多實體擷取。業界的對應物是各家 NLU 工具的聯合意圖+實體模型,例如 Rasa 的 DIET(Dual Intent and Entity Transformer)與 CRF entity extractor。
Query Decomposition
把一個複雜查詢拆成多個子查詢,分別檢索再合併。以這個案例來說:
- Profile Sub-query:擷取五條路線 → 建立使用者能力 profile
- Exclusion Sub-query:排除上述五條 → 建立排除清單
- Recommendation Sub-query:以 profile 為基礎 → 執行推薦檢索
相關框架不少。Self-Ask (Press et al., 2023) 讓 LLM 自問自答,把複雜問題拆成可獨立回答的子問題。IRCoT (Trivedi et al., 2023) 更進一步,交錯執行 Chain-of-Thought 推理和資訊檢索,每步推理產生新的檢索需求。業界常見的 LangChain Multi-Query Retriever 和 LlamaIndex Sub-Question Query Engine 也是同一個思路。要注意 LangChain 1.0 之後這些既有 retriever 被移到 langchain-classic 套件(SelfQueryRetriever 現在的 API 文件在此),照舊版教學的 import 路徑會直接 ImportError。
User Profile Aggregation
不把每條路線當獨立的檢索錨點,而是聚合成使用者偏好 profile:
| 維度 | 聚合方式 | 本案例結果 |
|---|---|---|
| 能力上界 | max grade | 5.12a → 推薦 5.12a-5.12c |
| 舒適區 | median grade | 5.11c |
| 路線類型 | sport/trad/boulder 比例 | 偏好 sport → 加權 |
| 岩場 | crag 分佈 | 多岩場 → 不限定 |
| 排除 | 收集所有 route_id | 5 條全排除 |
Embedding 層面可以算 centroid:
query_vector = mean([embed(白虎), embed(閃電), embed(看起來我可以), embed(泡泡龍), embed(新竹客家人)])
這就是推薦系統經典的 average pooling of item embeddings。YouTube 推薦系統論文 (Covington et al., 2016) 用 watch history 的 embedding 平均當 user representation,同一個概念。
Plan-and-Execute RAG
引入 planning 階段,先分析完整意圖再逐步執行:
Query → Planner → [Step 1: Extract all routes]
[Step 2: Build user profile]
[Step 3: Determine search criteria]
[Step 4: Vector search with aggregated filter]
[Step 5: Re-rank and exclude]
→ Executor → Response
跟現有 LangGraph 架構的 multi_tool 路徑有關聯,但差異在於 Plan-and-Execute 的 plan 階段更結構化(不只決定用哪些 tool,還決定如何聚合中間結果),execute 階段有 feedback loop。Wang et al. (2023) 的 Plan-and-Solve Prompting、Yao et al. (2023) 的 ReAct 都是這個方向。
Collaborative Filtering
看「爬過相同路線的其他使用者還爬了什麼」:
-- 找品味相似的使用者
SELECT user_id, COUNT(*) as overlap
FROM ascents
WHERE route_id IN ('白虎_id', '閃電_id', '看起來我可以_id', '泡泡龍_id', '新竹客家人_id')
AND user_id != current_user_id
GROUP BY user_id
ORDER BY overlap DESC
LIMIT 10;
-- 從相似使用者找推薦
SELECT route_id, COUNT(*) as popularity
FROM ascents
WHERE user_id IN (top_10_similar_users)
AND route_id NOT IN (excluded_routes)
GROUP BY route_id
ORDER BY popularity DESC
LIMIT 3;
概念漂亮,但限制很實際:需要足夠多的使用者紀錄、計算量在 Cloudflare Workers 的 CPU 時間限制內吃不消、更適合離線預計算而非即時查詢。
Multi-Hop QA:相關的學術脈絡
多實體查詢其實跟 Multi-Hop QA 是近親。幾篇關鍵論文:
- HotpotQA (Yang et al., 2018) -- 多跳問答 benchmark,要求跨多個文件推理
- MDR (Xiong et al., 2021) -- 迭代式檢索,每一跳根據前一跳結果調整查詢
- Baleen (Khattab et al., 2021) -- condensed retrieval,在多跳檢索中壓縮中間結果提升效率
- DSP (Khattab et al., 2023) -- DSPy 前身,demonstrate-search-predict pipeline
Query Decomposition 方面,Ma et al. (2023) 的 Query Rewriting 和 Shao et al. (2023) 的 ITER-RETGEN(迭代式檢索-生成)也值得一看。推薦系統的多訊號融合,SASRec (Kang & McAuley, 2018) 用 self-attention 處理互動序列,Li et al. (2023) 的 GPT4Rec 直接把推薦轉成語言任務。
選擇的方案:分層遞進
在 Cloudflare Workers runtime、D1 SQLite、現有 LangGraph 架構的限制下,不可能一步到位。分三層:
P0(立即做):Multi-Entity Extraction
改動最小、效益最大。extractRouteReference() → extractRouteReferences(),回傳陣列。呼叫端聚合多條路線的 filter,excludeRouteId 改成 excludeRouteIds。不增加 LLM 呼叫次數,延遲不變,預估解決 80% 的多實體查詢問題。
P1(短期):User Profile Aggregation
新增 buildUserProfile(),從多條路線計算能力上界、舒適區、偏好類型、岩場分佈。Profile 注入 LLM prompt 的 system context,讓模型在生成回答時有完整的使用者背景。
P2(中期):Embedding Centroid
如果多條路線都有 embedding,計算 centroid vector 作為查詢向量。搜尋結果再經過 re-ranking:排除已提及路線、難度適當性加權、多樣性加權。Vectorize 的 query() 本來就是吃一個任意向量,這點不是問題;真正的前提在 metadata 那邊(見下)。
與現有架構的整合點:
nlp.ts → extractRouteReferences() 回傳陣列
tool-selection.ts → routeRefs: RouteReference[],聚合 filter
GraphState → excludeRouteIds: string[]
filter-build.ts → 處理多 crag($in 而非 $eq)
vector search → post-filter 排除多條路線($nin 而非 $ne)
這裡有三個 Vectorize 的硬限制值得先寫在便利貼上,官方 metadata filtering 文件講得很清楚:
- metadata index 必須在寫入向量之前建好(
wrangler vectorize create-metadata-index),而且一個 index 最多 10 個 metadata index。事後才想到要 filter 某個欄位,等於要重灌整包向量。 - string 型 metadata 只索引前 64 bytes,超過的部分無法參與過濾。中文一個字 3 bytes,長路線名或岩場全名很容易撞到。
- 範圍查詢(
$gt/$gte/$lt/$lte)只能上下界互相搭配,不能跟同一個 key 上的$in混用。所以「難度取 union range」和「crag 用$in」必須落在不同欄位上,這剛好符合我們的設計,但別想在 grade 上同時做 range 和白名單。
整體來說
核心取捨是「在 edge runtime 的限制下,用最少的額外計算換最大的推薦品質提升」。P0 的 rule-based 多實體擷取幾乎零成本就能解決大部分問題,這是最划算的。P1 加入 profile aggregation 讓 LLM 有更完整的 context,代價是多一些字串處理。P2 的 embedding centroid 才真正碰到計算和 API 限制,需要驗證可行性。
Query Decomposition 和 Collaborative Filtering 先放著。前者在已有 LangGraph multi-tool 路徑的前提下有重疊,後者需要更多使用者資料。等 P0-P2 上線,再看推薦品質的瓶頸在哪裡決定下一步。
更新紀錄
- 2026-08-19:對照官方文件逐篇查證翻新,移除易腐內容,並收進「RAG 技法大全」系列
參考資料
- FLAT: Chinese NER Using Flat-Lattice Transformer (Li et al., 2020)
- A Unified Generative Framework for Various NER Subtasks (Yan et al., 2021)
- Measuring and Narrowing the Compositionality Gap in Language Models (Press et al., 2023)
- Decomposed Prompting: A Modular Approach for Solving Complex Tasks (Khot et al., 2023)
- Interleaving Retrieval with Chain-of-Thought Reasoning (Trivedi et al., 2023)
- Deep Neural Networks for YouTube Recommendations (Covington et al., 2016)
- Matrix Factorization Techniques for Recommender Systems (Koren et al., 2009)
- Plan-and-Solve Prompting (Wang et al., 2023)
- ReAct: Synergizing Reasoning and Acting in Language Models (Yao et al., 2023)
- Reflexion: Language Agents with Verbal Reinforcement Learning (Shinn et al., 2023)
- BPR: Bayesian Personalized Ranking from Implicit Feedback (Rendle et al., 2009)
- Neural Collaborative Filtering (He et al., 2017)
- HotpotQA: A Dataset for Diverse, Explainable Multi-hop QA (Yang et al., 2018)
- Answering Complex Open-Domain Questions with Multi-Hop Dense Retrieval (Xiong et al., 2021)
- Baleen: Robust Multi-Hop Reasoning at Scale via Condensed Retrieval (Khattab et al., 2021)
- Demonstrate-Search-Predict: Composing Retrieval and Language Models (Khattab et al., 2023)
- Query Rewriting for Retrieval-Augmented Large Language Models (Ma et al., 2023)
- ITER-RETGEN: Enhancing Retrieval-Augmented LLMs with Iterative Retrieval-Generation Synergy (Shao et al., 2023)
- Self-Attentive Sequential Recommendation (Kang & McAuley, 2018)
- GPT4Rec: A Generative Framework for Personalized Recommendation (Li et al., 2023)
Loading...