Ian Chou's Blog

用 BGE-M3 + LanceDB 打造本地 RAG 搜索:11ty Blog 的 AI 升級之路

為什麼需要本地搜索?

當 Blog 文章數量超過 30 篇,傳統的全文搜索開始顯得力不從心。你搜尋「部署」,卻找不到「deploy」相關的文章。這就是 語意搜索 (Semantic Search) 的用武之地。

但大多數語意搜索方案都需要雲端服務(Algolia、Pinecone),這意味著:

我的目標:100% 本地運行的語意搜索,只在需要 AI 回答時才連線。


技術選型

graph LR
    A[Markdown 文章] --> B[BGE-M3 Embedding]
    B --> C[LanceDB 向量庫]
    D[用戶搜索] --> E[Query Embedding]
    E --> C
    C --> F[Top K 結果]
    F --> G[OpenRouter LLM]
    G --> H[RAG 回答]
元件 選擇 理由
Embedding BGE-M3 中英雙語、支援 Matryoshka 降維、本地 GPU
Vector DB LanceDB 零配置、純本地、支援 FTS Hybrid
API Server FastAPI 輕量、async、自動 OpenAPI
LLM OpenRouter (Nemotron-3) 免費、串流、API 相容 OpenAI
Frontend 11ty + Vanilla JS 保持輕量,不引入框架

實作細節

1. Markdown Chunking 策略

直接把整篇文章向量化效果很差。我採用 按 H2 標題切分 的策略:

def chunk_by_h2(content: str, title: str) -> list[dict]:
    """依 H2 標題切分文章,每個 chunk 包含 section context"""
    sections = re.split(r'^## ', content, flags=re.MULTILINE)
    
    chunks = []
    for section in sections[1:]:  # 跳過 H2 前的內容
        lines = section.split('\n', 1)
        section_title = lines[0].strip()
        section_content = lines[1] if len(lines) > 1 else ""
        
        chunks.append({
            "title": title,
            "section": section_title,
            "content": section_content[:MAX_CHUNK_SIZE],
        })
    return chunks

這樣每個 chunk 都有明確的語意邊界,搜索結果更精準。

2. BGE-M3 的維度魔法

BGE-M3 原生輸出 1024 維向量,但它使用了 Matryoshka Representation Learning,前 N 維包含了最重要的語意資訊:

EMBEDDING_DIM = 512  # 效能與精度的最佳平衡

embeddings = model.encode(texts, normalize_embeddings=True)
embeddings = embeddings[:, :EMBEDDING_DIM]  # 截取前 512 維

這不是隨便砍掉資料,而是 BGE-M3 訓練時就設計好的降維方式。512 維相比 1024 維:

3. LanceDB 的坑與解法

LanceDB 的 FTS (Full-Text Search) 功能 API 有點混亂,不同版本用法不同:

❌ 錯誤用法(會報 Unknown index type fts):

table.create_index("content", index_type="fts")  # 不支援

✅ 正確用法(LanceDB 0.26+):

table.create_fts_index("content")  # 專用方法

這樣就能啟用 Hybrid Search,搜索時同時比對向量相似度和關鍵字。

4. RAG 串流回應

整合 OpenRouter 最關鍵的是串流處理,避免用戶等待完整回應:

from fastapi.responses import StreamingResponse
from openai import OpenAI

client = OpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key=os.environ["OPENROUTER_API_KEY"],
)

@app.get("/api/chat")
async def chat(q: str):
    # 1. 檢索相關 chunks
    results = table.search(query_embedding).limit(5).to_pandas()
    context = "\n\n".join(results["content"])
    
    # 2. 串流生成
    def generate():
        stream = client.chat.completions.create(
            model="nvidia/nemotron-3-nano-30b-a3b:free",
            messages=[
                {"role": "system", "content": "基於以下文章內容回答問題..."},
                {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {q}"},
            ],
            stream=True,
        )
        for chunk in stream:
            if chunk.choices[0].delta.content:
                yield chunk.choices[0].delta.content
    
    return StreamingResponse(generate(), media_type="text/plain")

5. 開發體驗優化

Windows 上 concurrently 有 path 問題,最終我拆成兩個獨立指令:

{
  "scripts": {
    "dev:search": "bun run search:index && bun run search:server",
    "dev:web": "npx @11ty/eleventy --serve --port 8080"
  }
}

開發時開兩個終端機,各跑一個。簡單粗暴但穩定。


成果展示

最終效果:

  1. 語意搜索:輸入「如何部署」能找到 deploy、GitOps 相關文章
  2. AI 問答:「總結這篇 blog 講了哪些部署方式?」直接給你摘要
  3. 本地優先:Embedding 全在本地跑,只有 AI 問答時才連線

學到的教訓

  1. 先 MVP,再優化:一開始想做 Hybrid Search,結果踩了 LanceDB bug,不如先讓 Vector Search 跑起來
  2. 維度可以砍:512 維足夠用,不需要完整 1024
  3. 串流很重要:AI 回答如果要等 10 秒才顯示,體驗會很差
  4. 環境變數要早載入dotenv.load_dotenv() 要放在檔案最上面

下一步


相關文章

如果你也想給自己的 Blog 加上 AI 搜索,歡迎參考我的 GitHub Repo