你記得照片裡有海邊夕陽,檔名卻是 IMG_0421;你記得某段錄音是海浪,資料夾卻只有日期。EmbeddingGemma 2 本機索引要解決的,就是用「海邊夕陽」這種自然語言,找回文字、照片或聲音相近的內容。真正的難題往往在第二天:新增檔案要怎麼補進去?改過的筆記怎麼換掉?刪除後為什麼還搜得到?
這篇寫給第一次做語意搜尋、願意複製一個 Python 檔案的新手。先用白話分清模型、向量與索引,再建立小型持久化資料庫,走完新增、修改、刪除三種驗收。沒有程式背景也能先讀流程圖;執行部分需要已有 Python 3.10 以上的環境。
以下依截至 2026 年 10 月 7 日的官方文件設計。AlphaLab 本次未執行模型推論,因此不提供繁中命中率、電腦 RAM 或延遲跑分;你會得到的是可操作的範例與量測方法。模型發布背景可先讀EmbeddingGemma 2 多模態搜尋解析,本文把重點放在索引的生命週期。
先說結論:EmbeddingGemma 2 本機索引有三本帳
本機語意搜尋=內容變向量+來源清單+持續同步。向量像每份檔案的「意思座標」;來源清單記住座標對應哪份檔案;同步則讓資料夾和清單保持一致。模型負責畫座標,資料庫負責保存,搜尋程式負責比距離。
- 載入帳:你需要哪些 Encoder(把某類輸入轉成模型能處理表示的編碼器)?只搜文字,不必連圖片與音訊一起載入。
- 空間帳:向量有幾個數字、每個數字占幾 bytes?縮短向量節省的是索引資料,不會把整個模型也縮成六分之一。
- 更新帳:哪份檔案新增、改動或刪除?先比檔案指紋,再決定是否重新做 embedding(嵌入向量)。
五個零件:先看懂,再動手
①「翻譯器」:同一模型,把不同內容放進同一空間
一般關鍵字搜尋找字面重合;語意搜尋找的是意思相近。官方模型卡列出文字、圖片、音訊與影片的共同向量空間。因此文字查詢可以和照片、錄音比較;搜尋結果仍需打開原檔核對,分數也不是答對機率。
②「插拔模組」:只載你需要的 Encoder
先決定資料類型,再挑模組。全是 OCR 後的文字,選 MODE=text;加照片選 MODE=image;加錄音選 MODE=audio;兩種媒體都有才選 MODE=full。名字 image/audio 在這份程式代表「文字+該媒體」,不是只剩單一媒體。

這些是參數數量,不能直接換算成你的 RAM 峰值。權重精度、解碼媒體、輸入長度與執行框架都要算。此範例固定 CPU/float32 以減少硬體差異;依模型卡,推論應使用 float32 或硬體原生支援的 bfloat16,避免 float16 所造成的非有限或退化向量。
③「查詢與文件制服」:前綴分開用
問句用 prompt_name="SearchQuery",無標題文字文件用 prompt_name="Document"。圖片與錄音則傳 {"image": "照片.jpg"} 或 {"audio": "聲音.wav"},不加文字任務前綴。Google 開發指南提供這些呼叫方式。若有真實標題,手動組成 title: 標題 | text: 內容;不要再加 Document,避免重複前綴。
④「座標長度」:查詢與資料用同一維度
MRL(套娃式表示學習)讓模型輸出的前段座標也能拿來搜尋。truncate_dim 決定保留多少數字,normalize_embeddings=True 把截短後的向量重新正規化。白話說,你換了短尺,查詢和檔案都要拿同一把,並把尺的刻度重新校準。
⑤「檔案名冊」:ID 與指紋各司其職
ID 回答「哪份檔案」,SHA-256 指紋回答「內容變了嗎」。範例用資料夾內相對路徑當 ID,檔案 bytes 的雜湊當指紋。相同 ID、相同指紋就跳過推論;相同 ID、不同指紋就替換向量;已不在資料夾的 ID 就移出索引。改檔名會被視為刪舊、新增,這是此小範例的明確規則。

EmbeddingGemma 2 本機索引上手:先做十份小資料
第一步:準備可人工驗收的資料夾
建立新的工作資料夾與 data 子資料夾,放少量自己有權使用的 .txt、.jpg/.png、.wav。先取十份左右,包含海浪與雨聲、夕陽與室內燈光這種近似干擾項。這是教學樣本數,不是品質保證;文字限 UTF-8、非空且 2,000 字元內,錄音採 16 kHz 單聲道 PCM WAV、20 秒內。
在索引前寫十個繁中查詢,逐題列出可接受的來源 ID。例如「海邊夕陽」可接受哪張照片,「持續海浪聲」可接受哪段錄音。錄音另分 spoken content(說了什麼)與環境聲(聽起來是什麼);兩類需求分開驗,不把海浪能找到就當成會議逐字搜尋也做好。
第二步:隔離環境,固定模型版本
以下使用 macOS/Linux 的 shell 語法,在已有 Python 的電腦執行下列指令。SentenceTransformers 安裝文件說明 Python 與框架需求;官方 EmbeddingGemma 2 開發指南要求 SentenceTransformers 6.1.0 以上。引號保護套件 extras,避免 shell 把中括號當萬用字元。首次執行會下載模型;先預留磁碟、連線與載入記憶體,再開始。
python3 -m venv .venv
source .venv/bin/activate
python -m pip install "sentence-transformers[image,audio]==6.1.0" "transformers==5.19.0"
python -m pip freeze > requirements-run.txt
把下面程式存成 local_index.py。它固定模型到 這次查核的 revision;requirements-run.txt 留下實際套件版本。若新環境找不到 embedding_gemma2 或接受不了多模態字典,先核對官方Transformers 實作文件及安裝版本,停止建立索引,不要偷偷換成第一代模型。
第三步:保存向量,讓同步與搜尋可以分開跑
下列是小資料夾的教學實作:SQLite 保存紀錄與 float32 向量,搜尋時逐筆做內積。它沒有加入背景檔案監控、長文件分塊、大量資料近似搜尋或多使用者權限;這些是擴充點。每次同步請停止修改資料夾,由單一程序寫入。SQLite 的transaction context manager讓中途推論失敗時回滾本次更新,避免只換了一半就清掉舊索引。
import hashlib, json, os, sqlite3, sys, time, wave
from pathlib import Path
from importlib.metadata import version
import numpy as np
import torch
from sentence_transformers import SentenceTransformer
ROOT = Path(os.environ.get("DATA", "data")).resolve()
DB = os.environ.get("DB", "index.sqlite3")
DIM = int(os.environ.get("DIM", "768"))
MODE = os.environ.get("MODE", "full")
REV = "914f7f89142e33e77833254d9c9b90c3cef7303b"
MODEL_ID = "google/embeddinggemma-2"
CONFIGS = {"text": {"vision_config": None, "audio_config": None},
"image": {"audio_config": None},
"audio": {"vision_config": None}, "full": {}}
KINDS = {".txt": "text", ".png": "image", ".jpg": "image", ".wav": "audio"}
assert ROOT.is_dir(), "先建立 data 資料夾"
assert DIM in (128, 256, 512, 768) and MODE in CONFIGS
versions = [version(x) for x in ("sentence-transformers", "transformers", "torch", "numpy")]
signature = json.dumps([str(ROOT), MODEL_ID, REV, DIM, MODE, "cpu-float32", "SearchQuery/Document-v1", versions])
con = sqlite3.connect(DB)
con.execute("CREATE TABLE IF NOT EXISTS config (signature TEXT NOT NULL)")
con.execute("CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY, sha TEXT, kind TEXT, vector BLOB)")
saved = con.execute("SELECT signature FROM config").fetchone()
if saved and saved[0] != signature:
raise ValueError("配置不同,請換一個 DB 檔名建立新索引")
if not saved:
con.execute("INSERT INTO config VALUES (?)", (signature,))
con.commit()
model = SentenceTransformer(MODEL_ID, revision=REV,
device="cpu", config_kwargs=CONFIGS[MODE], model_kwargs={"torch_dtype": torch.float32})
def encode(value, prompt=None):
kw = {} if prompt is None else {"prompt_name": prompt}
v = np.asarray(model.encode(value, truncate_dim=DIM,
normalize_embeddings=True, **kw), dtype=np.float32).reshape(-1)
if v.shape != (DIM,) or not np.isfinite(v).all() or not np.isclose(np.linalg.norm(v), 1, atol=1e-3):
raise ValueError("向量尺寸、有限值或正規化驗收失敗")
return v
if sys.argv[1:] == ["sync"]:
seen, changed = set(), 0
start = time.perf_counter()
with con:
for p in sorted(ROOT.rglob("*")):
if not p.is_file() or p.suffix.lower() not in KINDS:
continue
if p.is_symlink() or ROOT not in p.resolve().parents:
raise ValueError("此範例拒絕符號連結與資料夾外檔案")
kind = KINDS[p.suffix.lower()]
if kind != "text" and MODE not in (kind, "full"):
raise ValueError("檔案需要未啟用的 Encoder,請調整資料或 MODE")
fid = p.relative_to(ROOT).as_posix()
seen.add(fid)
data = p.read_bytes()
sha = hashlib.sha256(data).hexdigest()
old = con.execute("SELECT sha FROM items WHERE id=?", (fid,)).fetchone()
if old and old[0] == sha:
continue
if kind == "text":
text = data.decode("utf-8")
if not text.strip() or len(text) > 2000:
raise ValueError("教學文字請用非空、2000 字元內 UTF-8 txt")
v = encode(text, "Document")
else:
if kind == "audio":
with wave.open(str(p), "rb") as wav:
if wav.getnchannels() != 1 or wav.getframerate() != 16000 or wav.getnframes() > 320000:
raise ValueError("教學 WAV 請用 16 kHz 單聲道、20 秒內")
v = encode({kind: str(p)})
if hashlib.sha256(p.read_bytes()).hexdigest() != sha:
raise ValueError("索引期間媒體被改動,請停止修改後再同步")
con.execute("INSERT OR REPLACE INTO items VALUES (?,?,?,?)",
(fid, sha, kind, v.astype("<f4").tobytes()))
changed += 1
stale = [row[0] for row in con.execute("SELECT id FROM items") if row[0] not in seen]
con.executemany("DELETE FROM items WHERE id=?", [(x,) for x in stale])
print({"embedded": changed, "deleted": len(stale), "items": len(seen),
"sync_seconds": round(time.perf_counter()-start, 3)})
elif len(sys.argv) == 3 and sys.argv[1] == "search":
start = time.perf_counter()
q = encode(sys.argv[2], "SearchQuery")
hits = []
for fid, kind, blob in con.execute("SELECT id,kind,vector FROM items"):
v = np.frombuffer(blob, dtype="<f4")
hits.append((float(q @ v), fid, kind))
for score, fid, kind in sorted(hits, reverse=True)[:5]:
print(f"{score:.4f}\t{kind}\t{fid}")
print("query_and_search_seconds", round(time.perf_counter()-start, 3))
else:
raise SystemExit('用法:python local_index.py sync 或 search "繁中問題"')
con.close()
程式先檢查資料夾、維度和載入模式,再核對資料庫配置;配置不一致會要求換 DB。這份入門版把模型 ID、revision、套件版本與 MODE 一起鎖住。官方同 revision 的模組組合共享向量空間,進階系統可以驗證相容後沿用舊文字向量;此處改模式則另建索引,讓新手容易對帳。
第四步:同步一次,再用繁中查詢
MODE=full DIM=768 DB=index-768.sqlite3 python local_index.py sync
MODE=full DIM=768 DB=index-768.sqlite3 python local_index.py search "海邊夕陽"
MODE=full DIM=768 DB=index-768.sqlite3 python local_index.py search "持續海浪聲"
輸出會列分數、資料類型與相對路徑,最多五筆。打開它們,對照事先寫好的標準答案;不要只看第一筆分數好像很高。如果你只做 OCR 文字搜尋,把 data 換成自己的文字資料,再用 MODE=text 和新的 DB;圖片或音訊放進未啟用相應模組的模式時,程式會報錯,避免誤以為已經索引。
增量更新驗收:新增、修改、刪除各一次
- 先測不變:同一命令再同步一次,預期
embedded=0、deleted=0。它仍掃描並計算檔案雜湊,只省掉未變檔案的模型推論。 - 新增:放進一份新的短文字,再同步;若只有它新增,預期
embedded=1,來源 ID 出現在清單。再查詢新文字的主題。 - 修改:改動該 txt 內容且保留檔名,再同步;預期一筆向量被替換,總 items 數不變。檢查資料庫 SHA 已變,再驗新的主題排名。
- 刪除:把這份測試檔移到 data 之外,再同步;預期
deleted=1,搜尋不再回傳那個 ID。原本相關的其他檔案仍可能命中,不能要求所有近義結果消失。
直接核對清單可執行 python -c "import sqlite3; c=sqlite3.connect('index-768.sqlite3'); print(c.execute('SELECT id,sha,kind,length(vector) FROM items ORDER BY id').fetchall())"。先證明生命週期正確,再看語意品質;搜尋排名因內容相近而沒有大變化,和根本沒更新,是不同問題。
128/768 維怎麼選?把空間、命中與時間分開量
先保留 768 維基線,再另建 MODE=full DIM=128 DB=index-128.sqlite3 python local_index.py sync;搜尋時也用同一組 MODE、DIM、DB。對完全相同的十題記錄前五筆 ID,算「有至少一個可接受來源出現在前五筆」的題數。這是本教學的 Hit@5 驗收口徑,不代表通用 benchmark。

此程式每個數字以 4 bytes 保存,單筆 768 維是 3,072 bytes、128 維是 512 bytes,純向量恰好差六倍。SQLite 整檔還包含 ID、指紋、頁面與其他開銷;刪除紀錄也不一定立即縮小檔案。因此同時保存 SUM(length(vector)) 與 DB 檔案大小,不把兩者混算。
效能也分三段:外部從啟動到退出的總時間,包含載入模型;sync_seconds 包含掃描、雜湊、改動檔案推論和寫入,但不含前面的模型載入;query_and_search_seconds 含查詢 embedding 與逐筆搜尋,也不含載入。768/128 交錯順序各跑數次,保留中位數和範圍;測 RAM 時記電腦、框架、精度與峰值量測工具。
官方模型卡建議 128 維優先用於純文字,並指出多模態品質下降明顯。若照片或音訊的可接受來源常掉出前五筆,保留 768 維,或再試 256/512。維度省下的 bytes 很確定,你的命中率改善或退步需要題集。 不同資料、版本或框架的耗時也不能直接互比。
只有 OCR 文字,是否值得換?先保留對照組
把已辨識的文字先用 MODE=text 建索引,記錄既有關鍵字搜尋與新語意搜尋各自的命中。若需求是精確單號、日期、金額,保留字面查找;若需求是「那份談提前終止合約的文件」,語意搜尋更值得評估。照片裡的版面、圖表或環境聲才是你要找的資訊時,再加原生媒體索引,不必為了新模型重做全部資料流程。
資料入庫品質同樣重要。OCR 漏字、錄音取樣不對或選錯來源,會讓向量很整齊卻代表錯內容;可以把RAG 入庫前五格檢查的來源與完整性思路移過來。私人資料庫與原檔要一起受本機帳號權限保護;若未來做多人搜尋,先依讀者權限篩選候選檔案,再排名,避免用相似度代替授權。
常見問題:八個直接答案
這個索引會自動監控資料夾嗎?
這份程式採手動同步。新增、修改或刪除後再執行 sync;背景監控是下一階段要加的工作。
只搜文字可以用 270M 配置嗎?
可以。選 MODE=text,同時停用 vision_config 與 audio_config;資料夾也只放支援的文字樣本。
128 維就是六倍快嗎?
不能這樣推算。純向量容量差六倍,載入、輸入編碼、檔案雜湊與資料庫開銷仍存在;速度要分段量。
搜尋錄音等於拿到逐字稿嗎?
兩種成果不同。此處回傳錄音來源 ID 和相似度;需要逐字文本或時間定位,要另外設計對應流程與驗收。
改模型 revision 可以沿用 DB 嗎?
此範例會拒絕。改模型、維度、模式或根目錄時另開 DB,保留舊基線,再做配對比較。
查詢 768 維、文件 128 維可以直接比嗎?
不可以直接做本例內積。兩側需同維度,截短後重新正規化;程式把維度寫进資料庫配置。
刪除資料後 DB 大小沒變,是失敗嗎?
先看紀錄。來源 ID 與向量列移除才是本例驗收;SQLite 檔案配置與回收是另一層。
十題都找到就可以擴到全部檔案嗎?
先擴題集。加入相近但不正確的照片、同主題錄音、繁中同義詞與精確字串;再增加資料量,觀察錯誤與成本。
給新手的三個重點
- 先選資料類型,再選 Encoder;先確定流程能核對,再追低記憶體。
- 同一 revision、同一維度、正確前綴與正規化,是比較相似度的基本約定。
- 用 ID 與雜湊把更新做成可驗收的工作,不用搜尋結果「看起來差不多」當成功證據。
接著閱讀
左右滑動查看更多推薦
下一步:先交出一份能對帳的索引
記住這個公式:本機語意搜尋=內容變向量+來源清單+持續同步。今天先準備十份小資料、寫十題標準答案、跑一次 sync,再讓一份檔案經歷新增、修改和刪除。接著才比較 128/768 維;這樣你省下的是有證據的空間,保留下的是能核對的搜尋能力。更多學習路線可從AI 文章總覽挑選,或到AlphaLab 課程接著建立你的 AI 工作流。






