據(jù)庫不一定要上:8513個向量的本地語義檢索實踐)
1. 先聊清楚“8,513 個向量”的規(guī)模再聊要不要引專用庫1.1 8513個向量占多少內(nèi)存可能就是幾十MB我這次要處理的是本地一批知識庫文檔按段落拆完之后正好是8513個文本片段。每個片段用一個768維的浮點(diǎn)向量表示拼成一個二維矩陣就是(8513, 768)的形狀按float32存儲8513 × 768 × 4 字節(jié) ≈ 24.9MB也就是說全量索引放進(jìn)內(nèi)存都不到25MB這還沒有某些圖片資源大。很多人一聽到“語義檢索”就默認(rèn)要上向量數(shù)據(jù)庫但先冷靜看一下數(shù)據(jù)規(guī)模8513條記錄一次搜索的最壞情況就是把每條記錄都比一遍而一次點(diǎn)積是768維的乘加運(yùn)算整體算下來不過千萬次浮點(diǎn)操作。在這種量級上外部索引結(jié)構(gòu)帶來的速度收益幾乎感知不到復(fù)雜度卻是實打?qū)嵉?。那么?.21毫秒”是怎么來的我用查詢向量和這個矩陣做了一次矩陣乘拿到8513個相似度分?jǐn)?shù)再取topK排序。整個過程只算搜索階段不含文本向量化的耗時。模型把查詢句子變成向量的時間通常在幾十毫秒以上搜索部分反而快了一個數(shù)量級。所以在整個鏈路里搜索根本不是瓶頸真正決定效果和體驗的是前面兩步文本分塊和向量質(zhì)量。1.2 不引向量數(shù)據(jù)庫省下的不止是部署成本引入一個專用向量數(shù)據(jù)庫意味著什么至少要做這幾件事裝服務(wù)、建索引、寫客戶端連接、配置數(shù)據(jù)同步、考慮備份和升級。如果你只是在本地維護(hù)幾千條筆記或文檔片段這些全部屬于“為了偶爾炒個菜先建一座中央廚房”。業(yè)務(wù)代碼里多一個外部依賴排查問題時就多一個變量索引沒刷新、服務(wù)沒起來、端口沖突、連接池耗盡哪個都比矩陣乘本身難纏。那到底什么規(guī)模才需要認(rèn)真考慮專用庫我的經(jīng)驗是個很寬泛的區(qū)間僅供參考幾萬條以內(nèi)內(nèi)存矩陣加線性掃描完全夠用省心。幾十萬到幾百萬條線性掃描可能到幾十毫秒開始考慮近似最近鄰索引。上千萬條或高并發(fā)接口再討論分布式向量庫。這不是定理是成本判斷。8513個向量連索引都算不上它就是一塊普通的內(nèi)存數(shù)組。與其叫“不引向量數(shù)據(jù)庫”不如叫“當(dāng)前規(guī)模下不需要”。后面數(shù)據(jù)真漲上去了再按后面的遷移路線走本文第五節(jié)會聊我怎么規(guī)劃這條路。2. 檢索鏈路拆開看切塊、向量化、相似度計算2.1 文本切塊先決定“被檢索的基本單元”長什么樣很多人做完語義檢索后覺得效果差回頭想是嵌入模型不行其實問題往往出在索引單元切得太隨意。你的檢索最終返回的是一段文本那這段文本就必須是語義上盡量獨(dú)立的一個單元。如果用一個固定字符長度硬切很可能把一個完整觀點(diǎn)切成兩半或者把兩個無關(guān)內(nèi)容湊在一起向量被平均成一個“什么都是、什么都不像”的混合體。我的做法是先按段落拆段落超長的再按窗口切窗口之間留一點(diǎn)重疊。import re def split_text(text, chunk_size400, overlap80): text re.sub(r\n{3,}, \n\n, text) paragraphs [p.strip() for p in re.split(r\n, text) if p.strip()] chunks [] for para in paragraphs: if len(para) chunk_size: chunks.append(para) continue start 0 while start len(para): end start chunk_size chunks.append(para[start:end]) if end len(para): break start end - overlap return chunks400字符的窗口對中文來說差不多200字左右信息密度足夠。80字符的重疊能避免一句關(guān)鍵結(jié)論剛好被切在分界點(diǎn)上。這個參數(shù)不是硬標(biāo)準(zhǔn)如果你文檔里全是技術(shù)說明書300字符更銳利如果是問答對直接按一條問答一個單元根本不用窗口切。2.2 文本到向量的那一步到底在算什么這一步稱為嵌入embedding本質(zhì)是把一段文本映射成一個固定維度的數(shù)值向量。現(xiàn)在的開源句向量模型基本都能在本地跑無需把文本傳到外部服務(wù)這在處理私有知識庫時很重要。對小白讀者可以這樣理解每個文本片段被壓縮成了一張“簡歷”里面雖然不直接存原文但把意思編碼進(jìn)了數(shù)值分布里。內(nèi)容越接近的文本向量在空間里的方向越接近。所以后面比較相似度時我們不看原始文字只看兩個向量靠不靠近。選模型時我一般關(guān)注三點(diǎn)支持的語言中文語料優(yōu)先用多語言版本的模型或者專門在中文語料上調(diào)過的變體。輸出維度常見有384、512、768維維度越高信息容量越大但存儲和計算成本也線性上漲。最大輸入長度很多模型默認(rèn)只能吃512個token超長會被截斷。這也是分塊窗口不宜過大的原因。具體代碼上我習(xí)慣把向量化封裝成一個函數(shù)先用占位實現(xiàn)等選好模型直接替換內(nèi)部邏輯。import numpy as np # 替換成你的本地嵌入模型調(diào)用 def to_embedding(text: str) - np.ndarray: # 這里應(yīng)該返回一個一維向量例如 shape (768,) # 由你的嵌入模型產(chǎn)生 raise NotImplementedError2.3 相似度不是“距離越短越好”這里是余弦相似度向量空間里的“語義距離”不是直線距離更常用的是余弦相似度兩個向量方向越一致值越接近1。公式寫作cos(a, b) (a · b) / (|a| × |b|)這是因為文本向量的絕對值長度受很多無關(guān)因素影響。最典型的場景是同一個意思寫短句和長句比如“貓坐在墊子上”和“貓坐在墊子上安靜地坐著時不時看看窗外”如果只看直線距離兩個向量會被長度差拉遠(yuǎn)但余弦相似度只看方向語義相近的文本哪怕長度差很多方向也基本一致。落實到代碼上有個偷懶技巧向量做好歸一化也就是讓每個向量的模等于1那么余弦相似度就直接等于內(nèi)積。這樣檢索時一次矩陣乘就搞定了不用每一步都重新算分母。def normalize(vec): norm np.linalg.norm(vec) 1e-12 return vec / norm那個1e-12是防御分母為零實際不會出現(xiàn)但加了更穩(wěn)。3. 一套可運(yùn)行的本地語義檢索代碼與耗時實測3.1 建索引從txt/md目錄到matrix.npy下面是建索引的完整流程。輸入是某個目錄下的文本文件輸出兩個文件一個matrix.npy向量矩陣一個meta.json元信息用來記錄每個向量對應(yīng)的原文出處和文本內(nèi)容。import os import json import numpy as np CORPUS_ROOT ./corpus def load_corpus(root_dir): chunks [] for fname in sorted(os.listdir(root_dir)): path os.path.join(root_dir, fname) if not os.path.isfile(path): continue with open(path, r, encodingutf-8) as f: text f.read() for piece in split_text(text): chunks.append({source: fname, text: piece}) return chunks chunks load_corpus(CORPUS_ROOT) vecs [] for idx, chunk in enumerate(chunks): vec to_embedding(chunk[text]) vecs.append(normalize(vec).astype(float32)) if (idx 1) % 1000 0: print(fembedded {idx 1}) X np.vstack(vecs) np.save(matrix.npy, X) with open(meta.json, w, encodingutf-8) as f: json.dump(chunks, f, ensure_asciiFalse, indent2)8513條文本在全量生成向量時也就幾十秒到一兩分鐘取決于你的嵌入模型速度。這里有一個容易被忽略的細(xì)節(jié)所有向量都要統(tǒng)一float32。float64占內(nèi)存翻倍但精度對相似度檢索沒有任何可感知的提升純屬浪費(fèi)。3.2 查詢一次矩陣乘加一次topK查詢階段的所有邏輯可以濃縮成下面這個函數(shù)def search(query_text, top_k5): X np.load(matrix.npy) meta json.load(open(meta.json, r, encodingutf-8)) q normalize(to_embedding(query_text)).astype(float32) scores X q idx np.argpartition(scores, -top_k)[-top_k:] idx idx[np.argsort(scores[idx])[::-1]] return [(meta[i][source], meta[i][text], float(scores[i])) for i in idx]X q這一步返回一個長度8513的數(shù)組里面就是查詢向量和每個索引向量的內(nèi)積。因為兩邊都已經(jīng)歸一化這份內(nèi)積就是余弦相似度。排序用np.argpartition而不是np.argsort這里藏著一個性能細(xì)節(jié)如果你只需要topK個最大項不需要知道剩余的8000多個向量誰前誰后。全排序是O(N log N)而部分選擇可以做到接近O(N)。當(dāng)向量數(shù)只有8513時差別不大但這個習(xí)慣能讓你以后平滑擴(kuò)展到更大規(guī)模。3.3 如何穩(wěn)定復(fù)現(xiàn)“0.21毫秒”的延遲測延遲最忌諱的是把冷啟動時間和檢索時間混在一起。我做過一次錯測第一次查詢耗時300多毫秒后來發(fā)現(xiàn)絕大部分時間花在了NumPy庫加載和模型推理上。正確做法是先把模型加載好、矩陣也讀進(jìn)內(nèi)存預(yù)熱幾次再用循環(huán)算平均值。import timeit # 預(yù)熱 for _ in range(3): search(示例查詢) t timeit.timeit( lambda: search(給本地知識庫搭一套語義檢索), number200 ) / 200 print(faverage: {t * 1000:.3f} ms)注意這里的時間統(tǒng)計對象是整個search函數(shù)它內(nèi)部還包含了一次文本向量化如果嵌入模型慢測出來就不是0.21毫秒。這是合理的因為你真的在跑完整流程。我在基準(zhǔn)測試時會先把查詢向量算好單獨(dú)統(tǒng)計matrix q和topK的部分這樣能明確看到檢索本身的開銷。實測環(huán)境是普通辦公級CPU、Python3.10、NumPy默認(rèn)配置多線程BLAS處于開啟狀態(tài)。8513個向量取top5穩(wěn)定在0.21毫秒左右。topK改成20也幾乎沒變化因為排序占比很小。3.4 兩個能再快一點(diǎn)的細(xì)節(jié)第一矩陣文件加載后不要反復(fù)np.load全局變量只加載一次。上面的search函數(shù)每次都在加載文件實際項目絕不會這么寫。應(yīng)該把矩陣和元數(shù)據(jù)放到模塊級或緩存里查詢只做矩陣乘和排序。第二如果結(jié)果不要求實時可以考慮加一層緩存相同或相似的查詢直接返回上次結(jié)果。這在知識庫工具里很常見畢竟用戶反復(fù)問同一類問題的概率很高。4. 從0.21毫秒到百萬向量暴力檢索的邊界與升級路徑4.1 復(fù)雜度賬線性掃描為什么能撐這么久暴力檢索的復(fù)雜度是O(M × D)M是索引向量數(shù)D是向量維度。維度固定為768時耗時基本隨M線性增長。8513條是0.21毫秒那么理論上1萬條約0.25毫秒10萬條約2.5毫秒100萬條約25毫秒1000萬條約250毫秒不過這只是粗略外推實際還要看內(nèi)存帶寬、CPU緩存和BLAS實現(xiàn)。但趨勢很明確在十萬級以下單次檢索都能保持在個位數(shù)毫秒級別這對本地知識庫工具完全夠用。還有一點(diǎn)容易被忽略如果你用Python列表逐個算點(diǎn)積而不是一次性矩陣乘復(fù)雜度同樣是線性但常數(shù)會大幾十倍。8513條可能直接變成幾十毫秒。所以“暴力檢索慢”這句話經(jīng)常是沒用向量化計算造成的誤判。先把數(shù)據(jù)堆成連續(xù)矩陣讓底層BLAS做批量運(yùn)算比上任何索引都立竿見影。4.2 數(shù)據(jù)漲上去之后先加索引還是直接換庫如果哪一天索引向量漲到幾十萬甚至上百萬我的建議不是立刻上專用數(shù)據(jù)庫而是先做兩件事一是考慮是否需要索引。你可以繼續(xù)用暴力掃描100萬條25毫秒很多內(nèi)部工具都扛得住。先問自己“延遲預(yù)算到底是多少”。如果一次人工查詢可以接受50毫秒那暴力掃描還能再用很久。二是如果延遲預(yù)算吃緊再上近似最近鄰索引。這類索引的核心思路是“不跟全部向量比較只跟一小部分比較”。常見的HNSW結(jié)構(gòu)把向量組織成一張多層圖查詢時從頂層快速向下每層只看少量節(jié)點(diǎn)IVF則先把向量聚類成幾百個桶查詢時先定位最近的幾個桶再桶內(nèi)精排。兩者都會引導(dǎo)出一種“先用索引縮小候選集再對候選集做精確打分”的流程。到這一步你仍然不需要引入完整向量數(shù)據(jù)庫一個開源ANN索引庫就能搞定。換庫的成本在于多一個服務(wù)要部署、多一套配置要維護(hù)、索引更新策略要重新設(shè)計。做之前先把收益算清楚。4.3 一個低成本中間方案先粗篩后精排如果你擔(dān)心未來數(shù)據(jù)量漲上去又不想立刻換基礎(chǔ)設(shè)施可以嘗試聚類粗篩。原理很簡單給全部向量做一次KMeans聚類比如分成256組每個組有一個中心向量。查詢時先算一下查詢向量和256個中心的相似度挑出最相近的幾個組只在這些組內(nèi)部做精確檢索。這一步本質(zhì)上就是IVF索引的樸素實現(xiàn)但代碼量很小。8513條向量分32個組查詢時可能只需要比較其中幾百條延遲能從0.21毫秒降到更低當(dāng)然這個量級已經(jīng)無所謂了。真正的價值在于當(dāng)你把數(shù)據(jù)換成20萬條時這種粗篩結(jié)構(gòu)能讓你繼續(xù)維持低延遲而不用馬上請出專用庫。代價是召回率會打一點(diǎn)折扣因為最相似的那個向量可能落在你沒選的組里。粗篩分組越多召回?fù)p失越大分組越少精排的壓力越大。需要在自己數(shù)據(jù)上跑一下調(diào)分組數(shù)屬于典型的“用少量召回?fù)Q速度”的工程取舍。5. 落地時會遇到的幾個坑與調(diào)優(yōu)經(jīng)驗5.1 固定長度切塊很省事但對長文不友好前面給的split_text是按“先段落、再窗口”的邏輯。如果偷懶直接按固定400字符從頭切到尾很容易切碎上下文。我踩過很典型的坑一篇技術(shù)文檔里的“注意事項”被切到下一條的開頭查詢“配置文件在哪里”時返回的內(nèi)容只有半句話。后來我把切塊邏輯改成“標(biāo)題感知”的文檔先按Markdown標(biāo)題或段落拆分每個標(biāo)題下的小節(jié)作為基本單元基本單元過長才用窗口二次切。這個改動讓檢索命中率明顯提升因為索引單元和語義單元的邊界重合了。一個簡單版本是先按\n拆段落段落長度超過閾值的再按窗口切。千萬別把一個長段落直接整段喂給模型超過模型最大長度后超出的部分會被截斷等于你索引的內(nèi)容和原文不是同一段話。5.2 別把相似度分?jǐn)?shù)直接當(dāng)置信度很多人做好檢索后習(xí)慣定一個閾值比如“分?jǐn)?shù)低于0.7就判定為不相關(guān)”。這個習(xí)慣在語義檢索里很危險原因有兩個第一不同嵌入模型的輸出分?jǐn)?shù)分布差異很大。同一個語料換一個模型后原本最低0.6的相似度可能整體降到0.3。閾值跟著模型走不能跨模型套用。第二語義相似度和“事實正確”完全是兩回事。用戶問“報銷流程是什么”知識庫里有一條“報銷需先提交申請單”的片段語義上高度相似分?jǐn)?shù)可能很高但它回答不了完整流程。所以檢索結(jié)果需要結(jié)合業(yè)務(wù)規(guī)則再判斷相似度只是一個排序信號。我現(xiàn)在的做法是先跑一批真實查詢把分?jǐn)?shù)分布打出來確認(rèn)自己的模型在哪些區(qū)間比較可信。比如看到絕大多數(shù)正確答案的分?jǐn)?shù)在0.55到0.8之間就用0.5作為“值得展示給用戶”的下限但不會只憑分?jǐn)?shù)做硬過濾。5.3 增量更新多數(shù)小項目最優(yōu)解就是全量重建本地知識庫是會變的新增一篇文檔、修改一段話索引就要更新。很多人會想復(fù)雜增量嵌入、局部索引刷新、后臺任務(wù)。實際上你這點(diǎn)數(shù)據(jù)量全量重建是最省心的方案。全量重建指的是重新加載所有文檔重新切塊重新生成向量重新覆蓋matrix.npy和meta.json。8513條向量嵌入計算加上矩陣存儲在普通機(jī)器上可能不到一分鐘。這個頻率如果一天只跑幾次完全沒有任何壓力。你覺得全量重建太笨是因為你拿大數(shù)據(jù)量系統(tǒng)的思路套小場景。小型知識庫的核心訴求是“結(jié)果可復(fù)現(xiàn)、出錯好排查”全量重建天然滿足只要文檔目錄是確定的索引結(jié)果就是確定的。增量更新則必須處理“哪條文本變了、哪些向量要刪掉、哪些要重插”這些狀態(tài)問題出錯了很難追溯。5.4 語義召回之外建議保留一層關(guān)鍵詞兜底語義檢索擅長處理“換了個說法但其實同一個意思”的查詢比如用戶說“怎么重置密碼”知識庫里寫的是“修改登錄憑證流程”兩者都能通過語義關(guān)聯(lián)起來。但它也有短板遇到精確的專有名詞、代碼標(biāo)識符、錯誤碼語義向量可能會把關(guān)鍵token稀釋掉。例如用戶搜“ERR_403”如果語義向量模型沒見過這個token它很可能把它當(dāng)成不重要的亂碼返回一堆包含“錯誤”“權(quán)限”的通用內(nèi)容。這種情況用關(guān)鍵詞檢索反而更準(zhǔn)。我現(xiàn)在會在語義結(jié)果旁邊同時跑一層簡單的關(guān)鍵詞匹配比如用BM25或甚至就是in判斷只要索引片段里包含查詢中的精確詞就給一個加分。融合規(guī)則可以很簡單關(guān)鍵詞命中直接排到前面或者給命中項提高一定分?jǐn)?shù)。這個“混合檢索”思路很成熟實現(xiàn)也便宜強(qiáng)烈建議在語義檢索系統(tǒng)里保留。還有一個冷啟動的經(jīng)驗嵌入模型通常要加載權(quán)重首次加載可能需要幾百毫秒甚至更久。不要把模型加載邏輯放在查詢函數(shù)內(nèi)部應(yīng)該進(jìn)程啟動時加載為全局變量。否則每次查詢都會把模型重新初始化一遍延遲會高到讓人誤以為系統(tǒng)有問題。實測延遲一定要預(yù)熱后測這也是為什么很多人在本地跑和壓測場景跑結(jié)果差很多的原因。