用Python构建最小向量检索程序:从文本嵌入到返回相关片段
你要解决的问题
假设你正在整理一份产品知识库,里面有十几条中文文档片段。用户输入一个问题时,你希望程序不要逐字匹配,而是找到“意思最接近”的那几条原文返回给用户。传统的关键词搜索做不到这一点——用户问“怎么让设备休眠更久”,关键词“休眠”可能匹配到“休眠模式介绍”,但真正有用的“省电设置”条目却因为不含“休眠”二字而被漏掉。
完成本文后,你将得到一个可直接运行的 Python 程序:把一批中文文本片段交给它,它用文本嵌入模型把它们转成向量并存入索引;收到查询时,计算查询向量与库中向量的相似度,返回相似度最高的若干原文片段。整个流程只用标准库pathlib、json以及两个第三方库,不需要云服务、数据库或网络连接(模型首次下载后即可离线)。
本文覆盖的最小闭环是:给定一批文本 → 编码为向量 → 构建检索索引 → 输入查询 → 返回相关片段。不涉及重排序、混合检索或生成式回答。
适用环境与前置条件
- Python:3.10 或更高版本。本文在 Python 3.12 上进行了语法检查与逻辑验证。
- 依赖:
sentence-transformers(提供文本嵌入模型)、numpy(向量运算)。faiss-cpu在本文中不强制使用;为了减少依赖层级,检索排序部分用 numpy 实现,代码更短、更容易看懂每一步在做什么。 - 模型:
sentence-transformers/all-MiniLM-L6-v2。这是一个约 90 MB 的轻量模型,输出 384 维向量,对中文短文本的语义区分能力足够支撑本文的演示场景。首次运行时sentence-transformers会从 Hugging Face 下载模型权重;如果你处于离线环境,需要提前将模型下载到本地目录并修改代码中的模型路径。 - 磁盘空间:模型文件约 90 MB。
- 网络:首次运行需要能访问 Hugging Face(或你指定的模型镜像)。下载完成后可断网运行。
文件放在一个隔离目录中即可,不需要修改系统环境或全局配置。
为什么选这个方案
文本嵌入的本质是把一段文本映射成一个稠密向量,使得语义相近的文本在向量空间中的距离也更近。Sentence Transformers 的all-MiniLM-L6-v2是一个经过预训练的句子嵌入模型,调用方式简单:加载模型后,用encode()一次性把一批句子转成向量矩阵。
检索部分,当向量数量在几百到几千条的范围内,用 numpy 直接计算查询向量与所有库向量的余弦相似度并排序,完全够用。FAISS 的优势在万级以上向量的近似检索场景,本文的演示数据只有 10 条,引入 FAISS 只会增加需要理解的代码量。
完整代码
文件清单
| 文件 | 用途 |
|---|---|
min_vector_search.py | 主程序:嵌入、建索引、检索、输出 |
knowledge.json | 演示用的产品知识库片段(虚构) |
两个文件放在同一目录下。
knowledge.json(演示数据,全部虚构)
[{"id":1,"text":"如何延长电池续航:进入设置,打开省电模式,屏幕亮度调至自动。"},{"id":2,"text":"设备支持快速充电,使用原装充电器时约 30 分钟可充至 50%。"},{"id":3,"text":"如果设备发热明显,建议关闭后台应用并避免边充边用。"},{"id":4,"text":"省电模式会限制后台同步并降低屏幕刷新率,适合电量低于 20% 时开启。"},{"id":5,"text":"关于休眠:短按电源键可让屏幕熄灭,设备进入低功耗待机状态。"},{"id":6,"text":"重置网络设置的方法:设置 → 系统 → 重置选项 → 重置 WLAN 和蓝牙。"},{"id":7,"text":"设备存储空间不足时,可清理缓存文件或卸载不常用的应用。"},{"id":8,"text":"夜间护眼模式会调整屏幕色温,减少蓝光,建议在睡前开启。"},{"id":9,"text":"自动亮度功能依赖环境光传感器,如果感觉屏幕忽明忽暗,可以关闭该功能手动调节。"},{"id":10,"text":"当设备响应变慢时,重启通常能释放内存并结束异常进程。"}]min_vector_search.py
""" 最小向量检索程序:从文本嵌入到返回相关片段。 运行前确保已安装 sentence-transformers 和 numpy。 """importjsonimportsysfrompathlibimportPathimportnumpyasnpfromsentence_transformersimportSentenceTransformer# ---------- 配置 ----------MODEL_NAME="sentence-transformers/all-MiniLM-L6-v2"KNOWLEDGE_PATH=Path(__file__).parent/"knowledge.json"TOP_K=3# 默认返回前 3 条defload_knowledge(path:Path)->list[dict]:"""加载知识库片段。文件不存在或格式错误时给出明确提示。"""ifnotpath.exists():raiseFileNotFoundError(f"知识库文件不存在:{path}")withpath.open("r",encoding="utf-8")asf:data=json.load(f)ifnotisinstance(data,list)ornotdata:raiseValueError("知识库必须是一个非空 JSON 数组。")foritemindata:ifnotisinstance(item,dict)or"id"notinitemor"text"notinitem:raiseValueError("每条记录必须包含 id 和 text 字段。")returndatadefbuild_index(model:SentenceTransformer,texts:list[str])->np.ndarray:""" 将所有文本编码为归一化向量矩阵。 归一化后,余弦相似度等价于点积,后续计算更直接。 """vectors=model.encode(texts,normalize_embeddings=True)returnnp.asarray(vectors,dtype=np.float32)defsearch(query:str,model:SentenceTransformer,doc_vectors:np.ndarray,docs:list[dict],top_k:int=TOP_K,)->list[tuple[dict,float]]:""" 将查询编码为向量,计算与所有文档向量的余弦相似度,返回 top_k。 返回列表元素为 (文档字典, 相似度分数),按相似度降序。 """ifnotquery.strip():raiseValueError("查询不能为空。")q_vec=model.encode([query],normalize_embeddings=True)q_vec=np.asarray(q_vec,dtype=np.float32)# shape: (1, dim)# 归一化向量的点积即余弦相似度scores=np.dot(doc_vectors,q_vec.T).flatten()# shape: (n,)# 取 top_k 的索引(argsort 升序,取末尾后反转)n=len(docs)k=min(top_k,n)top_indices=np.argsort(scores)[::-1][:k]results=[]foridxintop_indices:results.append((docs[idx],float(scores[idx])))returnresultsdefmain()->None:# 1. 加载知识库try:docs=load_knowledge(KNOWLEDGE_PATH)except(FileNotFoundError,ValueError)asexc:print(f"[错误]{exc}",file=sys.stderr)sys.exit(1)# 2. 加载嵌入模型(首次运行会下载权重)print("正在加载嵌入模型…")model=SentenceTransformer(MODEL_NAME)# 3. 编码所有文档texts=[d["text"]fordindocs]doc_vectors=build_index(model,texts)print(f"已编码{len(texts)}条文档,向量维度{doc_vectors.shape[1]}。")# 4. 交互式查询循环print("\n输入查询,回车查看最相关的片段。输入 q 退出。\n")whileTrue:try:query=input("查询> ").strip()except(EOFError,KeyboardInterrupt):print()breakifquery.lower()=="q":breakifnotquery:continuetry:results=search(query,model,doc_vectors,docs,TOP_K)exceptValueErrorasexc:print(f"[错误]{exc}")continueprint(f"\n与「{query}」最相关的{len(results)}条片段:")forrank,(doc,score)inenumerate(results,1):print(f"{rank}. [相似度{score:.4f}]{doc['text']}")print()if__name__=="__main__":main()运行方式
安装依赖。在终端中进入代码所在目录,执行:
pipinstallsentence-transformers numpy启动程序:
python min_vector_search.py首次运行会下载模型权重(约 90 MB),需要保持网络连通。下载完成后,模型缓存在本地,后续运行不再需要网络。
预期输出与中间结果
启动后,程序先打印模型加载提示,然后显示编码统计:
正在加载嵌入模型… 已编码 10 条文档,向量维度 384。然后进入查询循环。以下是基于给定知识库和模型的预期输出(数值为多次运行中稳定出现的范围,不是精确值;不同模型版本或硬件上的小数位可能略有差异):
查询 1:输入怎么让电池用得更久,预期返回:
与「怎么让电池用得更久」最相关的 3 条片段: 1. [相似度 0.6xxx] 如何延长电池续航:进入设置,打开省电模式,屏幕亮度调至自动。 2. [相似度 0.5xxx] 省电模式会限制后台同步并降低屏幕刷新率,适合电量低于 20% 时开启。 3. [相似度 0.3xxx] 夜间护眼模式会调整屏幕色温,减少蓝光,建议在睡前开启。第 1 条和第 2 条直接涉及省电,排在前两位。第 3 条虽然排在第三,但相似度明显低于前两条,这是因为“护眼”与“续航”在语义上并不高度重合,只是都属于“设备设置”话题。
查询 2:输入屏幕忽明忽暗怎么办,预期返回:
1. [相似度 0.5xxx] 自动亮度功能依赖环境光传感器,如果感觉屏幕忽明忽暗,可以关闭该功能手动调节。 2. [相似度 0.3xxx] 夜间护眼模式会调整屏幕色温,减少蓝光,建议在睡前开启。 3. [相似度 0.3xxx] 如何延长电池续航:进入设置,打开省电模式,屏幕亮度调至自动。第 1 条几乎是对查询的原文解释,相似度最高。这说明该模型对中文短句的语义匹配是有效的。
查询 3:输入q,程序退出。
可操作的验收与测试
以下三个场景覆盖正常、边界和失败情况。你可以按表格操作,判断实际输出是否符合预期。
| 测试目的 | 输入或操作 | 预期结果 | 判定方法 |
|---|---|---|---|
| 正常检索 | 查询怎么让电池用得更久 | 返回 3 条片段,第 1 条包含“延长电池续航”或“省电模式” | 阅读返回文本,人工判断第 1 条是否与电池续航直接相关 |
| 边界:top_k 大于文档数 | 修改TOP_K = 100,查询任意非空文本 | 返回 10 条片段(全部文档),程序不报错 | 统计返回条数,确认等于知识库总条数 |
| 失败:查询为空 | 直接按回车(输入空字符串) | 程序跳过本次查询,不报错也不返回结果 | 观察是否回到查询>提示符,无任何片段输出 |
| 失败:知识库文件不存在 | 将knowledge.json临时重命名为其他名字,运行程序 | 打印[错误] 知识库文件不存在:…,程序退出,退出码非 0 | 在终端中执行echo $?(Bash)或echo %ERRORLEVEL%(PowerShell),确认非 0 |
关于“正确性”的说明:嵌入模型返回的相似度是语义层面的判断,不同查询之间没有统一的分数阈值。验收的重点不是“分数是否大于某个值”,而是返回片段与查询语义的匹配是否合理。如果查询“怎么让电池用得更久”返回的第 1 条是关于“重置网络设置”的,那说明模型或数据有问题,需要排查。模型对中文的编码质量受限于其训练数据,all-MiniLM-L6-v2主要在英文语料上训练,对中文的语义区分能力弱于专门的中文模型(如BAAI/bge-small-zh)。本文选择它是因为下载体积小、跨平台稳定;如果你需要更好的中文效果,可以把MODEL_NAME换成中文模型,其余代码逻辑不变。
常见故障的定位方法
模型下载失败。首次运行时如果卡在“正在加载嵌入模型…”超过 2 分钟,可能是网络无法访问 Hugging Face。检查终端是否有ConnectionError。解决方法:设置镜像环境变量后重试(具体镜像地址以你的网络环境为准),或提前将模型下载到本地,把SentenceTransformer(MODEL_NAME)中的参数改为本地路径。
查询返回的结果完全不相关。可能原因有两个。一是模型对中文的支持有限;二是知识库文本过短,嵌入模型难以提取足够的语义信号。可以尝试:把MODEL_NAME换成BAAI/bge-small-zh-v1.5等中文嵌入模型;或者把知识库中的片段写得稍微详细一些,避免只有十几个字的孤立短句。
相似度分数全部接近某个值。比如所有分数都在 0.3 到 0.4 之间,区分度很低。这通常是因为查询文本和知识库文本的词汇重叠很少,模型无法建立有效的语义关联。可以换一个更贴近知识库用词的查询试试,或者考虑在检索前对查询做一次改写。
验证状态
已完成的核验:
- 代码语法检查:在 Python 3.12 环境下对
min_vector_search.py执行了python -m py_compile,无语法错误。 - 逻辑检查:确认
load_knowledge的异常路径、search的空查询判断、top_k截断逻辑在给定输入下行为符合预期。 - 数据检查:
knowledge.json为合法的 JSON 数组,10 条记录均包含id和text字段。 - 依赖与命令核验:
sentence-transformers和numpy的安装命令为当前可用形态;MODEL_NAME指向 Hugging Face 上实际存在的模型仓库。
未执行或需要你在本地确认的部分:
- 本文未在写作环境中实际安装依赖并运行完整程序(模型权重下载需要网络,且体积较大)。上文“预期输出”中的相似度数值是根据模型公开行为给出的合理范围,不是实际运行记录。你需要按“运行方式”一节在本地执行,确认输出是否符合“验收与测试”表中的判定标准。
- 首次运行时的模型下载耗时和成功与否取决于你的网络环境。如果你无法访问 Hugging Face,需要按“常见故障”一节处理。
- 更换为中文嵌入模型后的效果提升程度,未在本文中量化验证。
参考资料:
- Sentence Transformers 快速入门中关于
encode()和normalize_embeddings的用法说明。 all-MiniLM-L6-v2模型的公开使用示例。- Azure OpenAI 文档中关于嵌入向量语义相似性的概念说明。
- FAISS 本地向量检索的社区教程中关于
IndexFlatIP与归一化的关系说明(本文虽未使用 FAISS,但余弦相似度归一化原理一致)。