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