☰
从零搭建语义搜索引擎:向量化、Faiss与精排实战
2026/10/23 3:26:34 网站建设 项目流程

做语义搜索之前,我一直觉得这玩意儿是搜推荐系统的人才玩的活,什么双塔模型、向量召回、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(subset="question") # 过滤空值 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_len=400, stride=100): 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_embeddings=True,效果等同于手动做了L2归一化 embs = model.encode( texts, batch_size=64, show_progress_bar=True, normalize_embeddings=True ) 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_k=50): # 1. query向量化 q_vec = model.encode([query], normalize_embeddings=True) # 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(key=lambda x: x["score"], reverse=True) 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_n=5): 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(key=lambda x: x["rerank_score"], reverse=True) 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 Quantization(PQ)压缩或者干脆上Milvus这类分布式系统。

如果你是企业级场景,退一步说,即便用Milvus部署,底层的检索原理和调优思路依然适用:召回阶段管覆盖,精排阶段管质量。

5. 进阶优化方向与我的扩展实验

这套系统跑通之后,我又做了两个方向的扩展实验,分享一下思路:

第一个方向是混合搜索。我在Faiss前面加了一层BM25的召回通道,然后把两路结果合并。做法很简单:两路各自召回Top50,按分数归一化后求和,得到新的排序。这样做的好处是,对专有名词(比如产品型号、地址信息)比较大的查询,关键词匹配往往比语义匹配更准——语义模型对生僻词的理解能力没有BM25那么可靠。在搜索场景,关键词和语义双通道互补是真实的必答题。

第二个方向是query改写和扩展。用户的搜索词往往很短、很模糊,比如“丢了怎么办”,而知识库里存的是“卡片遗失后的处理方式”。如果直接把短query扩写成几种不同的句式,比如“银行卡丢失后如何挂失补办”“丢失银行卡的处理流程”等等,再把扩写后的查询分别检索,合并结果,召回率会更好。我用语言模型生成扩写,实测对短查询的提升非常明显,但对长查询作用不大,因为长查询已经足够表达语义了。

这两个扩展加进去后,系统看起来才更像一个实际能用的搜索服务,而不是纸面上的Demo。如果你要往生产环境推,往上再加一层用户反馈日志的收集,用真实点击数据微调重排权重,就完整很多了。

6. 最后再列一份实战避坑清单

  • 不要混用不同模型的向量:一切检索效果归零的最大原因,没有之一。
  • 不要跳过分词直接暴力拼接长文本:先看看tokenizer对长文本的截断行为。
  • 不要忽略查询预处理:低质量query直接进模型,效果差得离谱,至少要做一下基本的清理。
  • 检索阶段追求“查全”,所以TopK可以稍微放宽,最后靠精排拉精度;别试图用召回阶段的能力把精度也一并解决掉,不是一次能办成的事。
  • 索引保存和加载时确认版本一致:Faiss版本如果有较大差异,老索引文件可能加载报错,不一定能自动迁移。

我个人在实际操作中的体会是,语义搜索项目最大的门槛并不是算法本身,而是工程上的“对齐”意识:向量对齐、数据对齐、召回与精排的分工对齐。只要这几个环节控住了,剩下的调参只是时间问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询