尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
从零搭建语义搜索引擎:向量化、Faiss与精排实战
做语义搜索之前我一直觉得这玩意儿是搜推荐系统的人才玩的活什么双塔模型、向量召回、ANN检索听着就头大。直到我亲手搭了一个基于向量化的语义搜索Demo跑通那一下才明白原来“让机器理解搜索意图”落到工程上并没有那么玄乎核心就三件事把文字变成向量、把向量存起来、算相似度。这篇文章不是我抄文档拼出来的是我把一个开源问答数据集做成“能懂人话”的搜索引擎的全过程包含完整的代码、踩坑记录、参数调优心得以及我试过之后觉得真正有用的优化细节。1. 项目概述与整体设计思路1.1 语义搜索到底解决什么问题传统的搜索引擎比如你用Elasticsearch做站内搜索本质是词面匹配用户搜“怎么缓解眼睛干涩”文档里写的是“干眼症的治疗方法”关键词完全对不上搜不到就是搜不到。BM25算法再厉害它算的还是词频和逆文档频率无法处理同义词、近义词、语序颠倒这些自然语言里最常见的情况。语义搜索解决的就是这个“query和文档长得不像但意思是同一个”的匹配问题。它不是用关键词去撞而是把问题和候选答案都映射到一个高维向量空间里意思相近的文本在这个空间里距离就近。拿我做的这个系统来说一句“银行卡被盗刷怎么办”跟一条“遭遇盗刷后如何追回资金”的答案传统搜索几乎不可能把两者关联起来但语义搜索可以把它们的向量距离拉得很近从而召回这条答案。1.2 整体技术架构我搭的是本地离线可运行的一套完整流程不依赖云服务所有的模型和数据都在自己电脑上跑数据处理层负责读取原始文档做清洗、切片、格式化最终产出文本块。向量化层加载预训练的中文Sentence-BERT模型把每一块文本编码成固定长度的向量。索引存储层把向量和原始文本元数据写入Faiss索引支持持久化到本地磁盘。查询检索层接收用户query同样向量化后去索引里做相似度搜索返回TopK结果。架构非常简单单机就能跑但每一步展开都有不少细节。项目最核心的诉求是验证“语义召回”的效果而不是追求极致的性能所以架构上没有引入分布式组件全部用Python实现。1.3 技术选型的关键考量为什么选了几个核心组件向量模型选了国产开源的bge-base-zh-v1.5。试过几个m3e-base效果其实不错但模型体积和推理速度上我还是更倾向排在最前面的那个。bge系列在中文语义匹配度上做得比较好维度是768单条文本编码速度在CPU上也能接受不需要额外上GPU。向量检索库用Faiss。不是不用Milvus或者Elasticsearch的向量检索插件而是对于这种数据集量在万级、单机体验的Demo来说Faiss轻量、却一样有GPU加速选项是实验最友好的选择。分割策略用固定长度窗口切分没有用开源的分词器做语义切分。因为测试下来固定步长结合重叠窗口比复杂切分更稳定不太容易出现一句话被拦腰截断的问题而且实现起来非常简单。注意技术选型一定是跟着数据量和场景走的不要一上来就堆组件。我的数据一共也就两万多条单进程跑Faiss毫秒级返回没必要引入分布式框架。2. 语义搜索核心原理解读2.1 从词频匹配到向量空间要想理解语义搜索就得先想明白传统搜索的问题到底出在哪。词面匹配是基于词袋模型思路的。你有一个query把它拆成词然后去文档里数这些词出现了多少次再套一个TF-IDF权重或者BM25公式给每个文档打分排序。这套方法在互联网时代统治了几十年因为简单、可解释性强、而且对“专有名词”特别有效——比如搜“iPhone 15 价格”文档里只要包含这几个词就基本靠谱。但它的天花板也很明显同义词问题搜“笔记本”用户可能想要的是电脑也可能是真的纸质笔记本。语义泛化问题搜“孩子不听话怎么办”最好的答案是讲“如何与青春期儿童沟通”。语序问题“我爱她”和“她爱我”词面几乎一样意思完全不同。传统搜索无法分辨。语义搜索用稠密向量替代稀疏关键词表达文本含义。模型会把一整句话压缩成一个768维的浮点数组这个数组中每个维度的数值并没有直观的“词”的含义而是通过深度网络学习到的隐含特征。训练目标让模型在训练阶段就知道“意思相近的句子向量方向也相近”。2.2 双塔架构与句向量模型这里得提一下Sentence-BERT的核心。它本质上是一个双塔结构左右两座塔是共享参数的编码器分别处理query和document。编码器本身就是预训练好的BERT模型在它的基础上通过对比学习继续训练让同一语义的不同表达在向量空间里对齐。关键在于直接用BERT的CLS向量做句向量效果其实很一般。因为原始BERT的训练目标掩码语言模型并不是为了把句子压成向量设计的。Sentence-BERT用孪生网络的办法把两个句子同时送进BERT然后用余弦相似度作为loss的输入这样经过微调之后模型才会真正输出具有语义区分能力的向量。实际操作中用户压根不需要关心BERT内部的Self-Attention怎么算你只需要知道同一模型编码出来的向量具有可比性不同模型的向量不能混用——这是很多新手会犯的错查询用模型A编码文档用模型B编码结果向量空间不统一检索效果直接崩掉。2.3 相似度度量余弦相似度与内积向量存好之后怎么判断两个向量“近不近”最常用的是余弦相似度similarity cos(A, B) (A · B) / (|A| × |B|)余弦相似度的本质是看两个向量在空间里的夹角不管向量本身的模长。这在文本领域很合适因为我们关心的是“方向”是否一致而不是“长度”。比如一个很长的文档和一个很短的query它们在空间里的方向如果接近就说明语义相近而它们的模长差异不应该影响结果。另一条路是内积Dot Product。内积同时受方向和模长影响所以很多检索系统在使用内积前会先把向量做归一化。归一化处理之后内积和余弦相似度算出来的结果是一样的但内积在Faiss检索时通常更快所以我实际代码里把embedding做了L2归一化然后用内积距离检索。2.4 TopK召回与重排序向量检索返回的是一批候选结果直接按相似度排序给用户其实不够好。为什么不直接全量排序因为真实场景里索引里可能有几百万条数据全量算一遍相似度虽然理论上可以但延迟不可接受。Faiss用近似最近邻ANN算法在“精度”和“速度”之间做权衡返回的结果顺序并不保证是全局最优的。所以在工业界通常的做法是粗召回 精排序。我做的这个系统也参考了这个思路先用Faiss召回Top50然后用一个更强的方式做精排。我这里用的精排方式很简单——把召回结果和query拼在一起再用交叉编码器算一次分数。交叉编码器不像双塔模型那样把句子分开编码而是把两个句子拼成一个输入让模型直接看两个句子的交互效果更好但更慢适合小规模精排。我测试下来经过精排的前三名往往能直接给到用户想要的答案而单独靠Faiss召回的结果有时会把无关但是字面相近的内容排到前面来。这个“二次筛选”的步骤是我在这个项目里收益最大的一刀。3. 环境准备与代码实现全程记录3.1 依赖安装与数据准备我的运行环境是Python 3.10、CPU版的PyTorch内存16G全程没上GPU推理速度也够用。需要安装的依赖如下pip install torch pip install transformers pip install faiss-cpu pip install pandas pip install numpy注意faiss-cpu和faiss-gpu不要同时混装容易出现奇怪的冲突。如果没有N卡老老实实装CPU版本就行数据量不大时速度差不明显。数据的格式是结构化的CSV包含编号、问题、答案三个字段。读取后我做的第一件事是清洗把答案里的大段空白符压缩、剔除HTML标签残留、过滤掉完全重复的问题。这一步看着简单实际操作中很关键——脏数据会直接影响后面切块的质量。import pandas as pd import re data pd.read_csv(qa_data.csv) # 去重 data data.drop_duplicates(subsetquestion) # 过滤空值 data data.dropna(subset[question, answer]) # 压缩空白 data[answer] data[answer].apply(lambda x: re.sub(r\s, , str(x)).strip()) print(f有效数据量: {len(data)})清洗后的数据量从两万七千多条降到了两万四千条左右过滤率不算低这些噪声数据如果直接喂给模型不仅浪费存储空间还会让检索效果变差。3.2 长文本切片策略的关键细节很多教程忽略这个环节直接说“把文本喂给模型”但实际做的时候你会发现大部分模型对输入文本长度是有上限的通常是512个token。如果一条答案有800个token直接编码会被截断末端的语义信息就丢了。我采用的策略是固定长度滑动窗口切分from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(BAAI/bge-base-zh-v1.5) def split_text(text, max_len400, stride100): tokens tokenizer.tokenize(text) chunks [] start 0 while start len(tokens): end min(start max_len, len(tokens)) chunk_tokens tokens[start:end] # 转为字符串时用 [CLS] 和 [SEP] 拼起来但模型编码时不用手动加 chunk_text tokenizer.convert_tokens_to_string(chunk_tokens) chunks.append(chunk_text) if end len(tokens): break start max(1, end - stride) return chunks为什么步长要设置重叠因为如果硬切一个完整的意思可能刚好被从中间断开重叠窗口保证句子的语义在任意一个切片里都是完整的概率更高。我实测下来重叠窗口能让最后检索的准确率提高三到五个百分点。这里还有一个细节FAQ问答数据其实不需要切片因为问题和答案本身长度可控。我之所以保留切片逻辑是因为这套系统后续要扩展到长文档场景切片函数复用性很强。3.3 向量化与索引构建向量化是整个流程里最耗资源的一步。两万多条数据每条编码需要大概一百毫秒全量跑下来大约四十分钟。实际代码里我加了进度提示并且把中间结果缓存到本地方便排查问题的时候不用重新编码一遍。from sentence_transformers import SentenceTransformer import numpy as np # 加载模型 model SentenceTransformer(BAAI/bge-base-zh-v1.5) def encode_corpus(texts): # 注意 encode 时加了 normalize_embeddingsTrue效果等同于手动做了L2归一化 embs model.encode( texts, batch_size64, show_progress_barTrue, normalize_embeddingsTrue ) return embs # 分批处理 chunks all_vectors [] metadata [] for index, row in data.iterrows(): chunks split_text(row[answer]) if not chunks: continue chunk_texts [] for c in chunks: chunk_texts.append(c) metadata.append({ question: row[question], answer: row[answer], chunk: c, source_id: row[id] }) vectors encode_corpus(chunk_texts) all_vectors.append(vectors) vectors np.vstack(all_vectors)这里用SentenceTransformer库的封装比自己用transformers拼tokenizer和model省心很多底层一样是PyTorch但API更简洁而且对bge系列做了优化。Faiss索引构建很简单但有几个参数值得留意import faiss dim vectors.shape[1] # 768 # 使用IVF索引先聚类再检索比暴力搜索快很多 nlist 100 # 聚类中心数量按数据量开根号的量级取 quantizer faiss.IndexFlatIP(dim) # 内积索引因为向量已归一化 index faiss.IndexIVFFlat(quantizer, dim, nlist, faiss.METRIC_INNER_PRODUCT) # IVF必须训练就是为了做聚类 index.train(vectors.astype(float32)) index.add(vectors.astype(float32)) # 设置查询时扫描的聚类桶数越大越准但越慢 index.nprobe 20 # 保存到磁盘 faiss.write_index(index, faiss_index.bin)代码里还是有一点最重要的点索引类型选择。我一开始用的IndexFlatIP暴力索引两万条数据查询也是毫秒级的但当数据量上了百万量级暴力索引的延迟就会指数级上涨。所以我在项目里直接用了IVF倒排文件索引方案这算是提前把可扩展性做了预留。nprobe这个参数我调过几次。默认值是10改到20召回率提升比较明显速度依然没压力改到50时间翻了三倍但效果没太大改善。最终保留20。3.4 完整的查询与检索函数查询端的核心逻辑def search(query, top_k50): # 1. query向量化 q_vec model.encode([query], normalize_embeddingsTrue) # 2. Faiss检索返回相似度和文档id scores, indices index.search(q_vec.astype(float32), top_k) # 3. 收集结果 results [] for score, idx in zip(scores[0], indices[0]): if idx -1: continue results.append({ score: float(score), metadata: metadata[idx] }) # 4. 得分从高到低排序 results.sort(keylambda x: x[score], reverseTrue) return results查询函数本身很直白但有一个细节我这里的score是余弦相似度因为向量做了归一化内积的结果就是余弦相似度它的范围在[-1, 1]之间。实操中你会看到很多结果得分是负数这并不代表完全是噪声只是说明在语义上确实有反向关联这种结果在TopK里出现也是很常见的。3.5 精排用一个高成本模型挽回Top1质量前文提到精排实际效果如何我贴一下代码。我用的精排模型是交叉编码器选择上我尝试了几种m3e做精排效果不如直接用它做召回生成的向量好最终还是回到了bge系列的交叉编码器原理上没毛病配对文本打分会更加精细。from sentence_transformers import CrossEncoder reranker CrossEncoder(maidalun1020/bce-reranker-base_v1) # 或者 # reranker CrossEncoder(BAAI/bge-reranker-base) def rerank(query, results, top_n5): if not results: return [] # 对 query 和每个结果的 chunk 配对打分 pairs [(query, r[metadata][chunk]) for r in results] scores reranker.predict(pairs) for r, s in zip(results, scores): r[rerank_score] float(s) results.sort(keylambda x: x[rerank_score], reverseTrue) return results[:top_n]跑下来的感受非常直观。比如搜索“信用卡挂失后还能恢复吗”Faiss直接感召回来的第一名是一条关于挂失流程但没提恢复的内容精排之后真正讲“挂失后如何补办、恢复使用”的回答到了最前面。这就是为什么我说精排是整个系统里性价比最高的一步——模型推理开销不大但用户感知的提升非常明显。3.6 实际跑通的完整检索结果看一组我记录的实测输出数据是我随便从测试集里抽的查询: 信用卡挂失后还能恢复吗 Top1 (精度0.783/重排0.991) 问题: 信用卡挂失后是否可以解除 答案: 信用卡挂失以后是可以解除的如果您重新找到卡片可以通过客服热线或者App办理解除挂失即可恢复正常使用... Top2 (精度0.689/重排0.987) 问题: 挂失的卡片怎么补办 答案: 挂失后如果卡确定遗失可以申请补发新卡新卡卡号会变但原额度、账单等功能都会转移... Top3 (精度0.583/重排0.912) 问题: 信用卡丢了怎么办 答案: 第一时间拨打客服热线进行挂失尽量防止盗刷之后根据情况选择补办或者销户...可以看到精排后的前三名基本直击要害而Faiss单独给的Top1和Top3顺序是有问题的尤其是第三名的答案只能算沾边。这印证了“召回管全精排管准”的思路。4. 常见问题与排查技巧实录4.1 向量库中检索不到任何结果最经常遇到也最让人头大的就是查出来的结果全是垃圾。排查顺序检查query向量和文档向量是否用了同一个模型。这是我在代码试跑阶段踩的坑一开始为了省时间加载了两个不同embedding模型分别编码文档和query结果余弦相似度全部趋近于零白白浪费了一个小时排查最后才发现是模型不一致的问题。不同模型的向量空间根本不是同一个坐标系不可以混用。检查是否做了归一化。如果没有归一化用内积距离做检索会把长文档模长偏大的权重放大结果完全偏向长文本和语义无关。检查Faiss索引类型。我用过IndexFlatIP跑通之后改成IVF时忘了调训练接口直接导致索引数据全是乱码一样的id对应。4.2 中文处理的分词问题很多人一上来就问要不要先分词再喂给模型。实际上句向量模型的tokenizer内部自带中文分词能力它用的是BERT的WordPiece方案中文一般是按字切分再组合成词。你不必用jieba之类的分词器做前置处理这反而会破坏模型的输入格式。唯一的特殊处理是停用词。像“的”“了”“是”这些词在传统BM25里需要剔除因为它们没有区分度。但在语义搜索里模型对停用词的处理已经内嵌到训练过程里了你不需要也不应该手动剔除去掉之后反而可能导致句子结构信息的丢失。4.3 检索效果不佳的调优路径如果你发现语义搜索没有“更聪明”别急着怀疑模型不好先做几件事检查数据切片质量。直接打印出几条切片后的文本看看有没有语义被腰斩、问题被切断的情况。我之前切出来的某个文本块里只有“如何进行身份验”明显损失了后半句这类文本块检索出来就是一堆残废内容。调整stride重叠窗口。重叠越大切片之间的信息冗余越多检索到候选后越不容易漏。但也不是越大越好太大等于没切还会拖慢编码和检索速度。我实测400步长、100重叠是平衡点。调整nprobe参数。IVF索引的召回率和这个参数强相关。当你发现某个query应该有结果却没出来把nprobe调大一点再搜结果就出现了说明之前搜索的聚类桶太少了。4.4 内存和速度问题两万多条向量每个768维float32存储占用的内存大约是两万四千乘以768乘以四字节大概七十多兆非常小。如果你的数据量到了千万级内存占用就会到几十个G这时候要考虑用Product QuantizationPQ压缩或者干脆上Milvus这类分布式系统。如果你是企业级场景退一步说即便用Milvus部署底层的检索原理和调优思路依然适用召回阶段管覆盖精排阶段管质量。5. 进阶优化方向与我的扩展实验这套系统跑通之后我又做了两个方向的扩展实验分享一下思路第一个方向是混合搜索。我在Faiss前面加了一层BM25的召回通道然后把两路结果合并。做法很简单两路各自召回Top50按分数归一化后求和得到新的排序。这样做的好处是对专有名词比如产品型号、地址信息比较大的查询关键词匹配往往比语义匹配更准——语义模型对生僻词的理解能力没有BM25那么可靠。在搜索场景关键词和语义双通道互补是真实的必答题。第二个方向是query改写和扩展。用户的搜索词往往很短、很模糊比如“丢了怎么办”而知识库里存的是“卡片遗失后的处理方式”。如果直接把短query扩写成几种不同的句式比如“银行卡丢失后如何挂失补办”“丢失银行卡的处理流程”等等再把扩写后的查询分别检索合并结果召回率会更好。我用语言模型生成扩写实测对短查询的提升非常明显但对长查询作用不大因为长查询已经足够表达语义了。这两个扩展加进去后系统看起来才更像一个实际能用的搜索服务而不是纸面上的Demo。如果你要往生产环境推往上再加一层用户反馈日志的收集用真实点击数据微调重排权重就完整很多了。6. 最后再列一份实战避坑清单不要混用不同模型的向量一切检索效果归零的最大原因没有之一。不要跳过分词直接暴力拼接长文本先看看tokenizer对长文本的截断行为。不要忽略查询预处理低质量query直接进模型效果差得离谱至少要做一下基本的清理。检索阶段追求“查全”所以TopK可以稍微放宽最后靠精排拉精度别试图用召回阶段的能力把精度也一并解决掉不是一次能办成的事。索引保存和加载时确认版本一致Faiss版本如果有较大差异老索引文件可能加载报错不一定能自动迁移。我个人在实际操作中的体会是语义搜索项目最大的门槛并不是算法本身而是工程上的“对齐”意识向量对齐、数据对齐、召回与精排的分工对齐。只要这几个环节控住了剩下的调参只是时间问题。
RELATED

相关推荐

μP理论被新研究推翻?大模型学习率需按深度修正

μP理论被新研究推翻?大模型学习率需按深度修正

直接说结论:这条消息在技术社区里炸开之前,我自己先后把μP(Maximal Update Parametrization,最大更新参数化)理论反复读过几遍,也按“小模型调好超参,直接迁移到大模型”的思路复现过若干次。每…

📅 2026/10/23 3:26:35
Excel技巧163条拆解:高频操作、函数图表与避坑实战

Excel技巧163条拆解:高频操作、函数图表与避坑实战

简介:这份文档系统收录了163种Excel实用技巧,覆盖基本方法、数据处理、公式函数、图表可视化、宏与编程等模块,适合需要提升表格处理效率的职场人士、学生及数据分析工作者。资源包共1个文件,类型为doc格式文档,压缩包…

📅 2026/10/23 3:26:35
Tent混沌映射改进麻雀算法优化BP神经网络的回归预测实现

Tent混沌映射改进麻雀算法优化BP神经网络的回归预测实现

不知道你有没有这种感觉:一篇回归预测的论文或项目方案,看标题全是“改进”“优化”“混合”,但真正跑起来后,大部分时间都耗在了调参、复现和“不知道算法到底改对没有”的自我怀疑上。前阵子我刚好做完一个用Tent混沌映射改进麻…

📅 2026/10/23 3:26:35
MORE NEWS

更多资讯

📰

YOLOv6工业视觉检测优化与部署实战:从能检出到敢上线

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Seedance 2.5 视频生成提示词完全指南:从底层逻辑到实操避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

STM32寄存器白话手册:从地址映射到位操作的系统级理解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Ollama本地大模型部署全流程:硬件、量化、调优与接口实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

WSL烧录STM32实战:用GDB Server桥接绕过USB限制

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

从照亮到编码:光度立体、相移与格雷码的逻辑与工程实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬