
1. 為什么我要折騰一個本地知識庫1.1 從信息焦慮到動手自建我的筆記散落在四五個地方Obsidian 里有一批技術(shù)筆記Zotero 里躺著幾百篇論文的標注瀏覽器書簽夾里塞滿了稍后讀微信收藏里還有一堆截圖和鏈接。每次想找某個具體知識點都得挨個翻一遍翻不到就只能重新搜。這種狀態(tài)持續(xù)了大半年我終于受不了了。市面上的云筆記和知識管理工具我基本都試過問題集中在兩點一是數(shù)據(jù)不在自己手里二是搜索能力太弱——關鍵詞匹配根本理解不了我想找的是關于向量檢索性能優(yōu)化的內(nèi)容這種語義需求。所以我決定自己搭一套本地 embedding 向量檢索 每日自動同步數(shù)據(jù)全部落在本地 SQLite 里用 Ollama 跑 embedding 模型用 Python 寫同步腳本Obsidian 作為前端展示層。這套方案解決的核心問題是讓我的所有筆記和資料能被語義搜索而不是只能靠關鍵詞。比如我搜怎么讓模型記住上下文它能找出我寫的關于對話歷史管理和記憶機制的筆記哪怕里面根本沒出現(xiàn)上下文這三個字。適合誰參考有一定 Python 基礎、愿意花一個周末折騰、對數(shù)據(jù)隱私和檢索質(zhì)量有要求的同學。如果你完全沒寫過代碼這篇文章也能讓你看懂整體思路但實操部分可能需要先補一下 Python 基礎。1.2 整體架構(gòu)長什么樣先上一張我腦子里的架構(gòu)圖文字版數(shù)據(jù)源層Obsidian 倉庫Markdown 文件、Zotero 導出的筆記、手動整理的文本處理層Python 腳本負責讀取文件、切分文本、調(diào)用 Ollama 生成 embedding存儲層SQLite 數(shù)據(jù)庫存文本塊、向量、元數(shù)據(jù)來源、時間、標簽檢索層Python 腳本接收查詢生成查詢向量在 SQLite 里做余弦相似度計算返回 Top-K 結(jié)果展示層Obsidian 插件或簡單的本地 Web 頁面展示搜索結(jié)果調(diào)度層系統(tǒng)定時任務Linux 用 cronWindows 用任務計劃程序每天凌晨跑一次同步這個架構(gòu)的關鍵取舍是不引入向量數(shù)據(jù)庫。很多人第一反應是上 Chroma、Milvus、Qdrant 這些專業(yè)向量庫但我實測下來個人知識庫的數(shù)據(jù)量級幾千到幾萬條文本塊用 SQLite 存向量、Python 里做暴力檢索完全夠用。十萬條數(shù)據(jù)以內(nèi)一次全量檢索在普通筆記本上也就幾百毫秒。引入向量數(shù)據(jù)庫反而增加了部署復雜度和維護成本對個人項目來說是過度設計。另一個取舍是embedding 模型的選擇。我最終選了 Ollama 上的nomic-embed-text而不是 OpenAI 的 embedding API。原因很簡單數(shù)據(jù)不出本地沒有調(diào)用費用而且中文效果經(jīng)過實測可以接受。后面會詳細講模型選型的對比過程。2. 環(huán)境準備與工具選型2.1 Ollama 安裝與模型拉取Ollama 是我這套方案里最核心的依賴負責跑 embedding 模型。安裝本身不復雜但國內(nèi)網(wǎng)絡環(huán)境下拉取模型是個大坑我踩了好幾次。安裝步驟Linux 為例# 官方安裝腳本 curl -fsSL https://ollama.com/install.sh | sh # 驗證安裝 ollama --versionWindows 和 macOS 直接去官網(wǎng)下載安裝包即可。安裝完成后Ollama 默認會把模型存在系統(tǒng)盤的用戶目錄下。如果你的系統(tǒng)盤空間緊張embedding 模型雖然不大但多拉幾個也占地方可以修改模型存儲路徑# Linux/macOS設置環(huán)境變量 export OLLAMA_MODELS/data/ollama/models # 寫入 shell 配置文件使其永久生效 echo export OLLAMA_MODELS/data/ollama/models ~/.bashrc source ~/.bashrcWindows 下則在系統(tǒng)環(huán)境變量里新增OLLAMA_MODELS指向你想要的目錄然后重啟 Ollama 服務。拉取 embedding 模型ollama pull nomic-embed-text這里就是第一個大坑ollama 下載太慢了。我試過好幾次進度條卡在某個百分比不動等半小時都沒反應。后來總結(jié)出幾個應對方法換時間段凌晨或清晨拉取速度明顯好于晚上配置鏡像源部分社區(qū)維護的鏡像可以加速具體地址會變動建議自行搜索最新可用的手動下載模型文件如果實在拉不動可以找離線安裝包把模型文件放到OLLAMA_MODELS目錄下對應的文件夾里注意手動放置模型文件時目錄結(jié)構(gòu)要和 Ollama 預期的保持一致否則服務啟動后識別不到。建議先ollama pull一個小模型比如all-minilm看看它落在哪個目錄、目錄結(jié)構(gòu)是什么樣再照著放。2.2 embedding 模型怎么選熱詞里embedding模型排行是個高頻搜索我實際對比過幾個能在 Ollama 上跑的模型模型維度中文效果速度體積我的評價nomic-embed-text768良好快~274MB綜合最優(yōu)首選all-minilm384一般極快~46MB英文場景夠用中文偏弱mxbai-embed-large1024良好中等~670MB效果略好但資源占用高bge-m31024優(yōu)秀較慢~1.2GB中文最強但吃資源我的選擇邏輯是個人知識庫以中文為主但不需要極致效果速度和資源占用更重要。nomic-embed-text在中文語義檢索上表現(xiàn)穩(wěn)定768 維的向量存 SQLite 也不占太多空間。如果你主要處理英文資料all-minilm完全夠用速度快到飛起。如果你對中文檢索質(zhì)量要求極高、機器配置也好可以上bge-m3。這里有個經(jīng)驗不要盲目追求排行榜第一的模型。排行榜上的評測集和你的實際數(shù)據(jù)分布可能差很遠而且大模型意味著每次同步都要花更多時間。我建議先用nomic-embed-text跑起來覺得效果不夠再換。2.3 Python 環(huán)境與依賴Python 版本建議 3.10 以上我用的是 3.11。依賴不多核心就幾個pip install requests numpyrequests用來調(diào) Ollama 的 HTTP APInumpy用來做向量運算。如果你不想用 numpy純 Python 列表也能算余弦相似度但數(shù)據(jù)量大了會慢很多建議還是裝上。提示如果你在 Windows 上遇到pip安裝慢的問題可以配置國內(nèi)鏡像源這個網(wǎng)上教程很多不展開。2.4 SQLite 管理工具SQLite 本身是 Python 內(nèi)置的不需要額外安裝。但你需要一個工具來查看和調(diào)試數(shù)據(jù)庫內(nèi)容。我推薦DB Browser for SQLite簡稱 DB4S開源跨平臺界面直觀能直接執(zhí)行 SQL、查看表結(jié)構(gòu)、導出數(shù)據(jù)。熱詞里提到的 db browser for sqlite 和 db4s 就是它。下載安裝后直接打開你的.db文件就能看到所有表和數(shù)據(jù)。調(diào)試階段我?guī)缀趺刻於家盟匆谎巯蛄坑袥]有正確寫入、文本塊切分是否合理。3. 核心實現(xiàn)從文件到向量3.1 數(shù)據(jù)庫表結(jié)構(gòu)設計先建表。我的設計比較簡單兩張表一張存文本塊和向量一張存文件同步狀態(tài)。CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_file TEXT NOT NULL, chunk_index INTEGER NOT NULL, content TEXT NOT NULL, embedding BLOB NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS file_sync ( file_path TEXT PRIMARY KEY, last_modified REAL NOT NULL, last_synced TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_source_file ON chunks(source_file);幾個設計要點向量存成 BLOB。numpy 數(shù)組可以轉(zhuǎn)成 bytes 存進 SQLite 的 BLOB 字段讀取時再轉(zhuǎn)回來。這樣比存 JSON 字符串省空間讀寫也快。import numpy as np def vector_to_blob(vec): return np.array(vec, dtypenp.float32).tobytes() def blob_to_vector(blob): return np.frombuffer(blob, dtypenp.float32)file_sync 表用來做增量同步。每次同步前先查這個文件有沒有變過對比last_modified沒變就跳過避免重復計算 embedding。這個優(yōu)化在文件多的時候效果非常明顯。chunk_index 記錄文本塊在原文件中的順序檢索到結(jié)果后可以按文件聚合展示時更有上下文。3.2 文本切分策略文本切分是整套系統(tǒng)里最容易被忽視、但影響最大的環(huán)節(jié)。切得太碎語義不完整切得太大檢索精度下降。我的策略是按段落切分帶重疊def split_text(text, chunk_size500, overlap100): paragraphs text.split(\n\n) chunks [] current for para in paragraphs: if len(current) len(para) chunk_size: current para \n\n else: if current: chunks.append(current.strip()) # 處理超長段落 if len(para) chunk_size: for i in range(0, len(para), chunk_size - overlap): chunks.append(para[i:i chunk_size]) current else: current para \n\n if current: chunks.append(current.strip()) return chunkschunk_size500是我反復調(diào)整后的值。中文 500 字大約對應 300-400 個 token正好在 embedding 模型的最佳輸入范圍內(nèi)。overlap100是為了避免關鍵信息剛好被切在邊界上導致語義丟失。實操心得Markdown 文件里的代碼塊、表格、標題這些結(jié)構(gòu)切分時最好單獨處理。我一開始沒管結(jié)果代碼塊被從中間切開檢索出來的片段完全沒法看。后來加了個判斷遇到 包裹的代碼塊就整體保留不參與切分。3.3 調(diào)用 Ollama 生成 embeddingOllama 提供了 HTTP API默認監(jiān)聽11434端口。生成 embedding 的接口是/api/embeddingsimport requests def get_embedding(text, modelnomic-embed-text): resp requests.post( http://localhost:11434/api/embeddings, json{model: model, prompt: text}, timeout60 ) resp.raise_for_status() return resp.json()[embedding]這里有個性能問題逐條調(diào)用 API 很慢。我的知識庫有幾千個文本塊逐條調(diào)要跑十幾分鐘。優(yōu)化方法是批量處理但 Ollama 的 embeddings 接口一次只接受一個 prompt。我的做法是用多線程并發(fā)調(diào)用from concurrent.futures import ThreadPoolExecutor def batch_embed(texts, modelnomic-embed-text, workers4): with ThreadPoolExecutor(max_workersworkers) as executor: results list(executor.map(lambda t: get_embedding(t, model), texts)) return resultsworkers4是我測試下來比較穩(wěn)的并發(fā)數(shù)。再高容易把 Ollama 服務壓垮反而變慢。這個值取決于你的機器配置建議從 2 開始試。注意Ollama 默認只加載一個模型實例并發(fā)請求會排隊。如果你發(fā)現(xiàn)并發(fā)沒效果可能是 Ollama 的OLLAMA_NUM_PARALLEL環(huán)境變量沒設置??梢栽O為 4 試試但會占用更多內(nèi)存。3.4 增量同步邏輯完整的同步流程是這樣的import os import sqlite3 from pathlib import Path def sync_vault(vault_path, db_path): conn sqlite3.connect(db_path) cursor conn.cursor() for md_file in Path(vault_path).rglob(*.md): mtime os.path.getmtime(md_file) cursor.execute( SELECT last_modified FROM file_sync WHERE file_path ?, (str(md_file),) ) row cursor.fetchone() if row and abs(row[0] - mtime) 1: continue # 文件沒變跳過 # 文件變了刪除舊數(shù)據(jù) cursor.execute(DELETE FROM chunks WHERE source_file ?, (str(md_file),)) # 讀取、切分、生成 embedding content md_file.read_text(encodingutf-8) chunks split_text(content) embeddings batch_embed(chunks) for idx, (chunk, emb) in enumerate(zip(chunks, embeddings)): cursor.execute( INSERT INTO chunks (source_file, chunk_index, content, embedding) VALUES (?, ?, ?, ?), (str(md_file), idx, chunk, vector_to_blob(emb)) ) cursor.execute( INSERT OR REPLACE INTO file_sync (file_path, last_modified) VALUES (?, ?), (str(md_file), mtime) ) conn.commit() conn.close()這個邏輯的關鍵是先刪后插。文件變了就把這個文件的所有舊 chunk 刪掉重新生成避免殘留過期數(shù)據(jù)。雖然有點粗暴但對個人知識庫來說完全夠用而且邏輯簡單不容易出錯。4. 檢索與展示4.1 余弦相似度檢索檢索的核心就是算余弦相似度。SQLite 本身不支持向量運算所以我把所有向量讀到內(nèi)存里用 numpy 算def search(query, db_path, top_k10): query_vec np.array(get_embedding(query), dtypenp.float32) conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute(SELECT id, source_file, content, embedding FROM chunks) rows cursor.fetchall() conn.close() results [] for row in rows: vec blob_to_vector(row[3]) # 余弦相似度 similarity np.dot(query_vec, vec) / (np.linalg.norm(query_vec) * np.linalg.norm(vec)) results.append((similarity, row[0], row[1], row[2])) results.sort(keylambda x: x[0], reverseTrue) return results[:top_k]這個實現(xiàn)是全量暴力檢索。十萬條數(shù)據(jù)以內(nèi)一次檢索大概 200-500 毫秒完全可以接受。如果你數(shù)據(jù)量更大可以考慮用sqlite-vec擴展或者換向量數(shù)據(jù)庫但個人知識庫基本到不了那個量級。實操心得numpy 的np.dot對 float32 數(shù)組有優(yōu)化比純 Python 循環(huán)快幾十倍。另外如果向量已經(jīng)歸一化過余弦相似度就退化成點積可以省掉除法。Ollama 返回的 embedding 不一定是歸一化的但你可以自己歸一化后存進去檢索時直接點積。4.2 在 Obsidian 里展示結(jié)果檢索腳本跑通后展示層我試過兩種方案方案一Python 腳本輸出 Markdown 文件。檢索結(jié)果寫成一個.md文件放到 Obsidian 倉庫里用 Obsidian 打開看。簡單粗暴但每次都要手動跑腳本。方案二Obsidian 插件調(diào)用本地 API。寫一個簡單的 HTTP 服務包裝檢索邏輯Obsidian 插件發(fā)請求拿結(jié)果。這個體驗最好但需要寫插件門檻高一些。我目前用的是方案一的變體寫了個search.py命令行傳查詢詞結(jié)果直接打印到終端同時生成一個臨時 Markdown 文件。夠用不折騰。python search.py 向量檢索性能優(yōu)化輸出格式[0.87] /vault/notes/embedding-optimization.md 向量檢索的性能瓶頸主要在距離計算... [0.82] /vault/notes/rag-pipeline.md 檢索階段如果用暴力搜索十萬條數(shù)據(jù)...4.3 每日自動同步自動同步用系統(tǒng)定時任務。Linux 下編輯 crontabcrontab -e加入一行每天凌晨 3 點跑同步0 3 * * * /usr/bin/python3 /path/to/sync.py /var/log/kb-sync.log 21Windows 下用任務計劃程序創(chuàng)建一個每天觸發(fā)的任務操作選啟動程序程序填python.exe的路徑參數(shù)填sync.py的路徑。注意定時任務里的 Python 路徑一定要寫絕對路徑環(huán)境變量可能和你在終端里不一樣。我踩過這個坑腳本手動跑沒問題定時任務就是跑不起來查了半天發(fā)現(xiàn)是python3找不到。5. 踩坑記錄與排查技巧5.1 Ollama 相關坑坑一模型下載卡住不動。前面提過換時間段或找離線包。還有一個隱藏問題Ollama 下載是斷點續(xù)傳的如果卡住了可以 CtrlC 中斷再重新 pull它會從斷點繼續(xù)不用從頭下??佣﨩llama 服務啟動失敗。常見原因是端口被占用。11434端口如果被別的程序占了Ollama 起不來。用lsof -i :11434Linux/macOS或netstat -ano | findstr 11434Windows查一下把占用進程干掉或者改 Ollama 的監(jiān)聽端口。坑三embedding 結(jié)果為空。有時候 API 返回 200 但embedding字段是空的。這通常是因為輸入文本太長超過了模型的最大輸入長度。nomic-embed-text的最大輸入是 8192 token一般不會超但如果你切分邏輯有 bug 傳了個超長文本進去就會這樣。加個長度檢查if len(text) 6000: text text[:6000]坑四Ollama 占用內(nèi)存越來越高。長時間跑批量 embeddingOllama 的內(nèi)存占用會漲。我的做法是每處理 500 個 chunk 就重啟一次 Ollama 服務或者設置OLLAMA_MAX_LOADED_MODELS1限制同時加載的模型數(shù)。5.2 SQLite 相關坑坑一并發(fā)寫入鎖。SQLite 默認是寫鎖同一時間只能一個連接寫。如果你一邊跑同步一邊跑檢索可能會遇到database is locked。解決辦法是同步和檢索錯開時間或者用 WAL 模式conn.execute(PRAGMA journal_modeWAL)WAL 模式下讀寫可以并發(fā)對個人知識庫這種讀多寫少的場景很合適??佣﨎LOB 字段讀取報錯。numpy 的frombuffer要求 bytes 長度是 4 的倍數(shù)float32 是 4 字節(jié)。如果存的時候不是 float32 或者長度不對讀出來就會報錯。統(tǒng)一用np.float32存讀的時候也指定dtypenp.float32??尤龜?shù)據(jù)庫文件越來越大。刪除了 chunk 之后SQLite 文件不會自動縮小。需要手動執(zhí)行VACUUMVACUUM;我一般每個月跑一次能把文件縮小不少。5.3 檢索質(zhì)量相關坑坑一搜出來的結(jié)果不相關。最常見的原因是切分太碎一個 chunk 里沒有完整語義。調(diào)大chunk_size或者優(yōu)化切分邏輯。另一個原因是 embedding 模型不適合你的數(shù)據(jù)換模型試試??佣嗨贫确謹?shù)都很低。如果所有結(jié)果的相似度都在 0.3 以下說明查詢和文檔的語義空間對不齊。檢查一下查詢和文檔是不是用了同一個模型生成 embedding——必須用同一個模型不同模型的向量空間不通用??尤形臋z索效果差。nomic-embed-text的中文能力中等如果效果不滿意換bge-m3。另外查詢時可以在前面加個指令前綴比如為這個句子生成表示以用于檢索相關文章有些模型對指令前綴敏感能提升效果。5.4 常見問題速查表問題現(xiàn)象可能原因解決方法Ollama 下載卡住網(wǎng)絡問題換時間段、用離線包、斷點續(xù)傳embedding 返回空輸入超長截斷文本到 6000 字符以內(nèi)database is locked并發(fā)讀寫沖突開啟 WAL 模式、錯開同步和檢索時間檢索結(jié)果不相關切分太碎/模型不匹配調(diào)大 chunk_size、確認查詢和文檔同模型定時任務不執(zhí)行路徑或環(huán)境問題用絕對路徑、檢查日志數(shù)據(jù)庫文件過大刪除后未回收空間定期執(zhí)行 VACUUM同步速度慢逐條調(diào)用 API多線程并發(fā)、增量同步跳過未變文件6. 后續(xù)可以怎么擴展這套系統(tǒng)跑了一個多月基本滿足我的需求。后面我打算做幾個擴展接入更多數(shù)據(jù)源。目前只同步了 Obsidian 倉庫下一步想把 Zotero 的筆記也導進來。Zotero 可以導出 Markdown放到一個固定目錄同步腳本加個路徑就行。熱詞里如何將zotero的筆記導入obsidian是個高頻問題其實用 Zotero 的 Better BibTeX 插件導出 Markdown 再放進 Obsidian 倉庫是最順滑的方案。加個簡單的 Web 界面。用 Flask 或 FastAPI 包一層瀏覽器里直接搜比命令行方便。這個不難幾十行代碼的事。檢索結(jié)果重排序。先用向量檢索召回 Top-50再用一個小的交叉編碼器模型重排序精度能提升不少。不過這會增加復雜度和延遲看需求決定要不要上。多模態(tài)擴展。圖片、PDF 里的文字也可以提取出來做 embedding不過這就復雜了暫時不折騰。我個人在實際操作中的體會是這套方案最大的價值不是技術(shù)本身而是它逼著我把散落各處的筆記整理到了一起。以前筆記到處放現(xiàn)在有了統(tǒng)一的同步流程反而養(yǎng)成了隨手記、定期整理的習慣。技術(shù)是手段知識管理才是目的。如果你也在糾結(jié)要不要自建知識庫我的建議是先用最簡單的方案跑起來哪怕只是 Obsidian 加個全文搜索插件也比什么都不做強。等真的覺得不夠用了再逐步加 embedding、加自動同步一步步來別一上來就追求完美架構(gòu)。