標籤: OpenSearch

  • 從 BM25 到稀疏向量混合檢索打造可靠多語 RAG

    從 BM25 到稀疏向量混合檢索打造可靠多語 RAG

    📌 本文重點

    • 多語場景下單一檢索技術不可靠
    • Sparse + dense 混合檢索能顯著提升召回
    • RRF 等穩健融合策略優於簡單加權
    • 現有「只用 embedding」RAG 可漸進升級

    多語 RAG 最大的痛點,不是模型能力,而是檢索可靠性:民眾怎麼描述需求,和政府怎麼寫方案,永遠長得不一樣。從 MyScheme 這種多語、口語化的政府資料集可以看到,純 BM25 找不到語義相近但字面不同的文件,純 dense embedding 又容易在多語、多拼寫下語義漂移或召回太亂。本文聚焦一件事:如何用 sparse + dense 混合檢索,在現有 RAG 專案裡實際提升 recall,而不是只靠「換一個更大的 embedding 模型」。


    重點說明

    1. 純 BM25 vs 純 dense:多語查詢的失敗模式

    以 MyScheme 的農民補助方案為例,同一個意圖可能長這樣:

    • farmer income support scheme
    • kisan ko har saal paisa milne wali scheme
    • किसानों को हर साल पैसे देने वाली योजना
    • farmer ko 6000 rupees wali yojna

    失敗模式很典型:

    • 純 BM25
    • 英文 query 找到英文文件還行,但遇到混 Hindi/English 的查詢時,停用詞與分詞完全不對齊,得分被噪音稀釋。
    • 政府文件常用「PM-KISAN」「beneficiary」「disbursement」,民眾只說「har saal paisa」「6000 rupees」,詞彙不重疊直接失敗。
    • 純 dense embedding
    • 多語模型雖然能把 Hindi/English 映射到同一語義空間,但口語拼寫、錯字、混寫系統很容易落在 embedding 邊緣,導致相似度偏低。
    • RAG 常見做法是 top-k = 5~10,一旦語義有點偏,整批召回的文件都錯,LLM 再會「瞎補」也救不回來。

    💡 關鍵: 在多語口語查詢下,BM25 容易因詞彙不重疊失效,而 dense 在 top-k = 5~10 時只要稍微語義偏離就會整批召回錯誤文件。

    結論:lexical 是精確但太窄,dense 是寬但容易飄。混合檢索的目標就是用 sparse 捕捉字面線索、用 dense 捕捉語義,再在 ranking 階段做穩健融合。


    2. Sparse + Dense Hybrid 的設計拆解

    混合檢索不是「把兩個分數加起來」這麼簡單,它牽涉到幾個具體設計。

    (1) 索引結構:一個引擎還是兩層系統?

    • 單一引擎模式(適合小團隊):
    • 用 Elasticsearch / OpenSearch 的 BM25 + 稀疏向量(如 SPLADE、ELASTICSPLADE)+ dense 向量三合一索引。
    • 優點:部署簡單、一套 API;缺點:向量能力受限於 ES/OpendSearch 版本,調參空間較小。
    • 兩層模式(適合大型系統):
    • 第一層:search engine 做 sparse(BM25 + sparse vector)coarse recall。
    • 第二層:向量資料庫(PGVector / Qdrant)做 dense rerank / 精細相似度計算。
    • 可在第二層加上 cross-encoder reranker 或 LLM-based re-ranking。

    (2) 查詢改寫與 normalization

    多語、多拼寫下,query preprocessing 本身就是一個模組:

    1. 語言偵測:
    2. 使用 fastText / CLD3 或 LLM 自帶的語言偵測,標記 query 語言。
    3. Normalization:
    4. script normalization:全形/半形、Devanagari vs Latin transliteration。
    5. lowercasing、去除標點與顯而易見的 noise。
    6. 多語 query 擴展(選配):
    7. 將 Hindi query 透過 LLM 翻成英文:किसानों को हर साल पैसे देने वाली योजना -> farmer yearly income support scheme,兩個版本都丟進檢索。

    實務上,一個簡單但有效的策略是:統一把查詢轉成英文+原語言兩個 view,分別檢索,再在 ranking 層融合。

    (3) Score Fusion / RRF:怎麼「穩健」地合併分數

    常見幾種做法:

    • 線性加權(BM25_score * α + dense_score * β + sparse_score * γ):
    • 問題:不同分數尺度差很大、對參數敏感,容易在某些 query 下完全偏向單一來源。
    • Reciprocal Rank Fusion(RRF):
    • 每個檢索器給一個排序,對某文件的融合分數是:

    [
    RRF(d) = \sum_{s \in \text{sources}} \frac{1}{k + rank_s(d)}
    ]

    • k 為平滑常數(常用 k=60),不用調很多權重,對不同 query 分布也比較穩。

    💡 關鍵: 使用 RRF 搭配 k=60 能在多種檢索來源之間提供穩健且低參數敏感度的排序融合。

    對多語 RAG,我會優先選 RRF 作為第一版融合策略,然後再針對特定語言或場景加權調整。


    實作範例

    以下用一個簡化架構示範:

    • OpenSearch:BM25 + sparse vector(透過插件或內建向量)做第一層 hybrid search。
    • Qdrant:dense embedding 的向量查詢與 rerank。

    1. OpenSearch 索引設定:文件 + 稀疏/密集向量

    PUT myscheme-schemes
    {
      "settings": {
        "analysis": {
          "analyzer": {
            "multilingual_analyzer": {
              "type": "custom",
              "tokenizer": "standard",
              "filter": ["lowercase", "stop_multilingual"]
            }
          },
          "filter": {
            "stop_multilingual": {
              "type": "stop",
              "stopwords": ["_english_", "_hindi_"]
            }
          }
        }
      },
      "mappings": {
        "properties": {
          "title": {
            "type": "text",
            "analyzer": "multilingual_analyzer"
          },
          "description": {
            "type": "text",
            "analyzer": "multilingual_analyzer"
          },
          "sparse_vector": {
            "type": "rank_features"
          },
          "dense_vector": {
            "type": "dense_vector",
            "dims": 768,
            "index": true,
            "similarity": "cosine"
          }
        }
      }
    }
    

    說明:

    • multilingual_analyzer 同時掛載英文和印地語 stopwords,但要注意不要過 aggressive(後面會講坑)。
    • sparse_vector 可以存像 SPLADE 這種模型產出的 token->weight,透過 rank_features 來用 BM25-like scoring。
    • dense_vector 用預先算好的多語 embedding,例如 Jina Embeddings / LaBSE / m3e。

    2. 查詢流程:先 ES hybrid,再 Qdrant rerank

    伪程式碼:

    def search_myscheme(query: str):
        lang = detect_language(query)  # fastText / LLM
        norm_query = normalize_query(query, lang)
    
        # 1. 產生 sparse + dense 查詢向量
        sparse_q = splade_encode(norm_query)   # 稀疏向量:token->weight
        dense_q = embed_multilingual(norm_query)  # 768-d 向量
    
        # 2. 在 OpenSearch 做 hybrid search
        es_res = es.search(
            index="myscheme-schemes",
            body={
                "size": 50,
                "query": {
                    "bool": {
                        "should": [
                            {"match": {"description": norm_query}},
                            {"rank_feature": {"field": "sparse_vector", "boost": 2}},
                            {
                              "script_score": {
                                "query": {"match_all": {}},
                                "script": {
                                  "source": "cosineSimilarity(params.query_vector, 'dense_vector') + 1.0",
                                  "params": {"query_vector": dense_q}
                                }
                              }
                            }
                        ]
                    }
                }
            }
        )
    
        # 3. 把 top-50 丟進 Qdrant 用 dense similarity 做 rerank
        points = [
            {"id": doc["_id"], "vector": doc["_source"]["dense_vector"]}
            for doc in es_res["hits"]["hits"]
        ]
    
        qdrant_res = qdrant.search(
            collection_name="myscheme_schemes",
            query_vector=dense_q,
            search_params={"hnsw_ef": 128},
            limit=10,
            # 可以在這邊實作 RRF 或線性融合
        )
    
        return qdrant_res
    

    如果想在單一 OpenSearch 裡就做 RRF,可以改成兩個子查詢各自出 top-k,再用 client 端做 RRF 合併。


    3. 在現有「只用 embedding」的 RAG 上漸進式升級

    典型現有流程:

    # 既有做法
    vec = embed(query)
    results = vector_db.search(vec, top_k=10)
    context = build_context(results)
    answer = llm.generate(prompt_with(context, query))
    

    漸進式升級建議:

    1. 第一步:加入 BM25 coarse recall
    2. 把 query 同時丟到 search engine:
      python
      bm25_results = es_bm25_search(query, top_k=50)
      dense_results = vector_db.search(embed(query), top_k=30)
      fused = rrf_fusion(bm25_results, dense_results, k=60)
    3. 第二步:換 dense 為 multilingual + 加入 sparse
    4. 把原本英語 embedding 換成多語模型,再用 sparse 模型(SPLADE)重建索引。
    5. 第三步:加入語言偵測與 normalization
    6. 先實作簡單版:detect → normalize(lowercase+簡單清洗)→ bilingual query expansion。

    💡 關鍵: 將現有「只用 embedding」流程按步驟加入 BM25、sparse 向量與語言偵測,可以在不重寫架構的情況下逐步提升召回與穩定度。

    每一步都要搭配線上或離線評估,避免「看起來很厲害但實際沒有變好」。


    建議與注意事項

    1. 多語 stopwords 與錯誤分詞

    • 坑 1:停用詞把關鍵字吃掉
    • 多語 stopwords 列表往往過於粗糙,像 Hindi 裡有些詞在政府文本是關鍵字,在口語查詢卻被錯當停用詞。
    • 建議先用 統計 + 標註樣本檢查停用詞對召回的影響,必要時為特定欄位(如 title)使用較少的停用詞。

    • 坑 2:錯誤分詞導致 BM25 完全失效

    • 非空白分隔語言(中文、某些印度語)如果切錯字,BM25 的詞頻意義直接失真。
    • 解法:用 適語言分詞器(jieba、HanLP、Indic NLP),或在部分欄位改用 n-gram 分詞降低風險。

    2. 長文本切片策略:token 限制下如何不破壞語義

    混合檢索遇到長文件(例如政策全文)時,常見坑是:

    • chunk 切太細,BM25 還能找得到,但 dense embedding 像是記憶碎片,語義不完整。
    • chunk 切太粗,dense 相似度還好,但 BM25 命中的字詞被大量無關文本稀釋,排序變差。

    建議策略:

    • 以 語段(paragraph)或條款為單位切片,長度控制在 200–400 tokens。
    • 每個 chunk 保留 標題 / 小節名,讓 lexical 的命中不只在正文。
    • 如果有政策層級結構,建立 hierarchical RAG:先檢索 policy,再在 policy 下檢索條款。

    3. 標註與線上 A/B:怎麼確認 recall 真的變好

    不要只看「LLM 回答看起來比較合理」,要量化:

    1. 離線標註:
    2. 從真實 query(或合成 query)抽樣,為每個 query 標註「有用的文件 ID」。
    3. 比較純 BM25、純 dense、hybrid 在 top-k 的 Recall@k / MRR / nDCG。

    4. 線上 A/B:

    5. 對真實使用者流量,隨機分流到 dense-only vs hybrid pipeline。
    6. 收集:
      • 使用者是否點選推薦方案。
      • 是否更快完成查詢(減少反覆搜尋)。
    7. 避免只用主觀「好像比較好」,用行為指標判斷。

    4. 架構選型:小團隊 vs 大型系統

    • 小團隊 / MVP 階段:
    • 優先選一個支援向量的 search engine(OpenSearch + k-NN plugin / Elasticsearch + vector)。
    • 提示重點:一個 index 同時存 text + sparse + dense,client 端做 RRF fusion 就足夠支撐絕大部分 RAG 應用。

    • 大型系統 / 高流量服務:

    • 分層:
      • Tier 1:search engine 做 BM25 + sparse coarse recall(top-100~200)。
      • Tier 2:向量資料庫(PGVector / Qdrant / Milvus)做 dense rerank + optional cross-encoder。
    • 好處:
      • 可以針對不同語言、資料域調不同策略,如 Hindi 專用索引、英語專用索引再做 union。
      • 資源隔離:檢索與 rerank 分別 scale。

    核心結論:在多語、口語化場景下,單一檢索技術不夠可靠。把 BM25、稀疏向量和密集向量結合,搭配語言偵測與穩健的 score fusion(如 RRF),可以在不大改現有 RAG 架構的前提下,顯著提升查詢召回與答案穩定度。對已經在用「只用 embedding」的專案來說,這是一條可漸進落地的技術升級路線,而不是重寫整套系統。


    🚀 你現在可以做的事

    • 在現有 RAG 專案中加入一個 BM25 搜尋來源,並用 RRF(k=60) 與 dense 結果融合測試效果
    • 選定一個多語 embedding 模型(如 LaBSE 或 m3e),為核心資料集建立 dense_vector 欄位
    • 抽樣實際使用者查詢,標註「關鍵文件 ID」,離線比較 dense-only 與 hybrid pipeline 的 Recall@k