用 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 維:
- 儲存減半
- 搜索速度提升 50%
- 精度損失 < 3%
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"
}
}
開發時開兩個終端機,各跑一個。簡單粗暴但穩定。
成果展示
最終效果:
- 語意搜索:輸入「如何部署」能找到 deploy、GitOps 相關文章
- AI 問答:「總結這篇 blog 講了哪些部署方式?」直接給你摘要
- 本地優先:Embedding 全在本地跑,只有 AI 問答時才連線
學到的教訓
- 先 MVP,再優化:一開始想做 Hybrid Search,結果踩了 LanceDB bug,不如先讓 Vector Search 跑起來
- 維度可以砍:512 維足夠用,不需要完整 1024
- 串流很重要:AI 回答如果要等 10 秒才顯示,體驗會很差
- 環境變數要早載入:
dotenv.load_dotenv()要放在檔案最上面
下一步
- [x]
嘗試 LanceDB 新版修復 FTS→ 已修復!用create_fts_index()方法 - [x]
加入對話歷史(多輪 RAG)→ 已實作!詳見 多輪 RAG 對話記憶實作指南 - [ ] 部署到 Cloudflare Workers(用 WASM 版 LanceDB?)
相關文章
如果你也想給自己的 Blog 加上 AI 搜索,歡迎參考我的 GitHub Repo!