GEOZ

语义搜索

2026/8/4
语义搜索
本文深入浅出地讲解语义检索的基本原理,从文本向量化到相似度匹配,对比传统关键词检索,并结合实际案例说明语义检索如何解决多义词、意图理解等痛点,适合对AI检索感兴趣的技术读者。

核心洞察

这篇文章最有意思的点是,语义检索听起来高深,拆开看就是给文字找语义上的邻居。传统搜索像查字典,语义检索像找人聊天,话没说完对方已经懂你意思了。不过别高兴太早,这种聪明是用钱和算力堆出来的,后面几篇会讲到它贵在哪、坑在哪。

核心结论

  1. 语义检索通过把文本编码为高维向量并按语义相似度匹配,能够结合上下文区分多义词(如“苹果”指水果还是公司)并召回同义表达(如“薪资”与“工资”),而传统关键词检索做不到这一点。
  2. 分布式词嵌入将语义映射到低维连续向量(通常100到768维),使得语义相近的词在向量空间中距离更近;在STS-B语义相似度基准上,Sentence-BERT的Pearson相关系数达到0.85,明显高于平均词向量的0.75。
  3. 实际语义检索系统普遍采用两阶段架构:先用Bi-Encoder从百万级文档中快速粗选出Top 1000候选,再用Cross-Encoder精排产生Top 10结果,兼顾响应速度与排序精度。
  4. 语义检索的规模化依赖向量数据库:FAISS适合本地百万级数据的快速近似检索;当数据量到达千万级且需要分布式管理、元数据过滤时,需使用Milvus、Pinecone等专用向量数据库。
  5. 语义检索存在明确代价:需要模型编码和向量存储开销,结果是概率性输出、可解释性差,因此生产系统常将传统关键词检索与语义检索混合使用,以保留前者的可解释性和效率优势。

语义检索技术:让AI更懂自然语言

一、引言:从“关键询匹配”到“语义理解”的革命

1.1 一个真实的痛点场景

先还原一个场景。你在搜索框里敲下「苹果的价格走势」,传统搜索引擎会返回什么?大概率是这几类结果:

  • 苹果公司的股价波动
  • iPhone 新机降价的消息
  • 水果市场批发报价,占比还特别小

问题出在哪?传统检索靠关键词匹配,分不清「苹果」指的是公司、手机还是水果,也理解不了「价格走势」背后的真实意图。你可能只是想知道水果摊上的苹果贵不贵,搜出来全是股票。

换成语义检索,系统会先分析「苹果」在这个语境里更接近水果,再搞懂「价格走势」是在问时间序列上的变化,最后把「今年夏季苹果批发价预测」这类结果推到最前面。

1.2 什么是语义检索?

语义检索(Semantic Retrieval)是一种基于自然语言语义理解的信息检索技术。它把文本变成机器能算的语义表示,最常见的形式是向量,然后按语义相似度去捞信息,不死磕关键词。

整个流程一句话就能讲完:你的查询先被编码成向量,再去向量数据库里找语义上最接近的文档,最后按相关度排序返回。

1.3 语义检索 vs 传统检索:本质区别

把两种检索方式摆在一起,差别不小。

匹配逻辑不一样。传统检索做字面匹配,你搜什么词,它就找包含这些词的文档。语义检索做意思匹配。你说「怎么让电脑不卡」,它返回的是「优化系统性能的方法」。

多义词的处理是另一个分水岭。传统检索遇到「苹果」这种词,只能靠词频和权重猜,经常猜错。语义检索会结合上下文判断。同一个词放在不同句子里,生成的向量不同,准确率自然高出一截。

同义表达同样不用怕。传统检索里「薪资」和「工资」是两个词,搜一个漏一个。语义检索里这俩向量距离很近,都能被召回。

代价也有。语义检索需要模型做编码,需要向量库做存储和检索,成本比关键词检索高。而且它是个概率系统,结果不太好解释,出错了不容易说清原因。

两种技术各有各的用处。传统检索快、省、可解释。语义检索聪明、灵活,能处理复杂说法。实际产品里,很多系统是混着用的。

这篇先聊到这,下一篇我们讲文本到底怎么变成向量。

这套差异具体落在四个维度上。

维度 传统检索 语义检索
理解方式 关键词匹配,看字面意思 语义表示,抓深层意图
多义性处理 分不清"苹果"是水果还是手机 结合上下文消歧
相似性衡量 词频统计,比如TF-IDF 向量空间相似性,比如余弦相似度
效果依赖 用户输入得准不准 模型对语义理解得深不深

四行看完,问题就来了:文字在计算机里到底怎么存,机器才能直接比较"意思"?

二、语义检索的核心原理:从"文本"到"语义向量"的魔法

2.1 基础:语义表示,语言的"数学指纹"

语义检索的第一步,是把词、句子、文档统一变成高维向量,也就是Embedding。这类向量有两个特点。

语义相近的文本,向量距离就近。"猫"和"狗"的向量,一定比"猫"和"汽车"近得多。

语义相关的文本,向量方向就一致。"吃苹果"和"啃水果",两个句子的向量夹角很小。

2.1.1 词嵌入:从one-hot到分布式表示

最早的词表示是one-hot向量。"猫"是[1,0,0,0],"狗"是[0,1,0,0]。这种表示法有个问题:任意两个词的余弦相似度都是0。猫和狗明明都是动物,在计算机眼里却是两个孤岛。

分布式表示把每个词映射到一个低维连续向量,通常是100到768维。每一维承载一种语义特征,比如"有没有生命""是不是动物"。有了这些维度,"猫"和"狗"的向量自然就靠拢了。

Word2Vec是2013年Google发布的词嵌入模型,核心思想一句话:词的语义由上下文决定。具体有两种做法,CBOW用上下文预测目标词,Skip-gram反过来,用目标词预测上下文。

拿CBOW举例。输入是上下文词的one-hot向量,"我""喜欢""吃"三个词,过一层权重矩阵W,取平均得到隐藏向量h,再过一层权重矩阵W',输出一个词表大小的概率分布。概率最高的那个词,就是模型猜的"苹果"。训练用交叉熵损失,一轮一轮跑下来,W矩阵的每一行就沉淀为对应词的嵌入向量。

训练完的向量能看出语义关系。"猫"的嵌入可能是[0.8, -0.2, 0.5, ...],"狗"是[0.7, -0.1, 0.6, ...],两者的余弦相似度能到0.9左右。

2.1.2 句/文档嵌入:从词向量到文本向量

词嵌入只管单个词。语义检索要处理的是句子、段落、整篇文档,得把一句话压成一个固定长度的向量,还得保住整体语义。

BERT是2019年Google发布的Transformer预训练模型,抓上下文语义的能力很强。但直接拿BERT的[CLS]向量当句嵌入用,效果并不理想。BERT本来是为分类、问答设计的,句子相似度不是它的专长。

Sentence-BERT是2020年提出的改进,思路换成对比学习。训练的时候喂成对的句子,语义相近的当正例,"我喜欢吃苹果"和"苹果是我最喜欢的水果";语义无关的当负例,"我喜欢吃苹果"和"今天天气很好"。损失函数用三元组损失,目标很直白:正例对的距离尽量小,负例对的距离尽量大。

效果提升直接反映在数字上。语义相似度基准STS-B上,平均词向量的Pearson相关系数是0.75,Sentence-BERT直接拉到0.85。

2.2 关键:语义匹配,怎么衡量相似性

向量都有了,接下来就是算距离、做排序。

2.2.1 常用相似性度量

常用的有三种。

余弦相似度,算两个向量的夹角余弦,范围[-1, 1],越大越相似。公式是cosine(u,v) = (u·v) / (||u|| ||v||)。查询"如何学习Python"和文档"Python入门教程"算出来0.9,基本就是同一话题。

点积,直接算u·v,范围从负无穷到正无穷,也是越大越相似。省了模长计算,速度更快。但向量长度差异大的时候,结果容易失真。

欧氏距离,算两个向量的直线距离,范围[0, +∞],反过来,越小越相似。对异常值敏感,实际用得不多。

2.2.2 两种匹配策略:Bi-Encoder vs Cross-Encoder

Bi-Encoder的思路是各算各的。查询和文档分别过一遍编码器,得到两个向量,再用余弦相似度打分。优点快,文档向量可以提前算好存起来,查询来了只做一次向量计算。缺点也在这一步,查询和文档没有直接交互,精度上不去。Sentence-BERT、LaBSE都是这个路子。

Cross-Encoder反过来,把查询和文档拼成一个序列,中间加[SEP]分隔,整个丢进Transformer,直接输出0到1的匹配概率。精度确实高,查询里的每个词都能和文档里的每个词做注意力交互。代价是慢,每条查询都得跟所有文档拼接算一遍,文档一多,算力扛不住。

实际项目里通常混合着来。Bi-Encoder先从百万级文档里粗筛出Top 1000,Cross-Encoder再精排,从1000篇里挑出Top 10。速度不慢,精度不丢。

2.3 支撑:向量数据库,语义检索的索引引擎

文档量到百万甚至亿级,挨个算查询向量和所有文档向量的相似度,复杂度O(N),谁也等不起。向量数据库靠索引技术把复杂度降下来。

2.3.1 向量数据库的核心功能

向量数据库管四件事:

  • 存向量,把文档的嵌入向量持久化存储。
  • 建索引,用KD-Tree、IVF、HNSW这类算法,把向量组织成高效的检索结构。
  • 相似性查询,给定查询向量,快速返回Top K个最相似的文档。
  • 元数据过滤,支持带上文档类型、时间、作者这些条件一起筛,比如"只要2024年发布的Python教程"。

2.3.2 主流向量数据库对比

市面上的选择大致分三类。Milvus、Weaviate、Qdrant、Pinecone这类专门的向量数据库,把存储、索引、查询做成开箱即用的服务,功能齐全,部署和运维成本也高。Elasticsearch的向量检索插件、PostgreSQL的pgvector扩展,算是给老系统加装向量能力的折中方案,迁移成本低,但检索性能和专门的向量数据库有差距。FAISS这种开源相似性搜索库,严格说不算数据库,只负责高效算最近邻,适合技术团队自己搭检索服务。

选型看数据量。几百万条以内,pgvector或ES插件够用。到了千万级,对延迟又有要求,就得看Milvus这类专门方案了。

数据库 类型 优点 缺点
Faiss 本地库(C++/Python) 速度极快(支持GPU加速),内存占用低 无分布式支持,无元数据管理
Milvus 分布式(Go/Python) 支持分布式存储,元数据管理,多语言SDK 部署复杂,资源占用高
Pinecone 云服务 完全托管,弹性扩展,支持实时检索 成本高(按向量数量收费)
Weaviate 开源(Go) 支持知识图谱融合,多模态检索 社区较小,文档不够完善

选型的时候可以这么考虑:数据量小,本地折腾着玩,Faiss 够了。数据上了百万级,要长期维护,Milvus 虽然部署麻烦点,但值得。要是团队没有运维人手,直接买 Pinecone 的服务,省心,就是烧钱。Weaviate 适合需要把知识图谱和向量检索揉在一起的场景,不过得忍受它的文档和社区现状。

三、语义检索系统架构:从“数据”到“结果”的全流程

前面聊了原理,现在看实际跑起来长什么样。整个系统就是一条流水线,左边是文档入库,右边是用户查询,中间全靠向量把两边串起来。

3.1 系统架构图(Mermaid)

graph TD
    A[数据采集] --> B[数据预处理]
    B --> C[语义嵌入生成]
    C --> D[向量数据库存储]
    E[用户查询] --> F[查询预处理]
    F --> G[查询嵌入生成]
    G --> H[向量数据库检索(粗排)]
    H --> I[Cross-Encoder精排]
    I --> J[结果返回]

看到这个图,脑子里要有个印象:数据准备和查询处理是两条并行的线,最后在向量检索那里汇合。粗排负责快,精排负责准。

3.2 各模块详细说明

3.2.1 数据采集

来源有几种:网页爬取,比如用 Scrapy 抓站点内容;数据库导出,MySQL 里存的老数据直接拉出来;还有文件上传,PDF 和 Word 文档丢进去。举个例子,你要采集 Wikipedia 上所有跟 Python 相关的文档,大概十万条,这量级用本地 Faiss 也能扛住。

3.2.2 数据预处理

采集到的原始数据不能直接用,得先过三关:

第一步,清洗。HTML 标签剥掉,特殊字符删掉,重复内容去重。爬下来的网页经常一堆导航栏、广告位,这些留着只会让向量变得更脏。

第二步,分词。中文用 jieba,比如“如何学习Python”切出来是“如何/学习/Python”。英文用 NLTK,“Learn Python”切成“Learn/Python”。分词结果直接影响后续嵌入质量,不过用像 Sentence-BERT 这类模型时,它内部自带 tokenizer,通常你只需要把文本喂进去就行,不一定非要单独分成词。

第三步,截断和分段。模型对输入长度有限制,比如 512 tokens。文档一长就得切。常见做法是滑动窗口,每段 512 tokens,相邻段重叠 100 tokens。这样能避免把一句话拦腰截断,也能让前后文信息不丢太多。

3.2.3 语义嵌入生成

用 Hugging Face 的 Transformers 加载预训练模型就行。代码长这样:

from sentence_transformers import SentenceTransformer

# 加载预训练模型(支持多语言)
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

# 输入文本(文档列表)
documents = [
    "Python是一种解释型、面向对象、动态数据类型的高级程序设计语言。",
    "Python的设计哲学强调代码的可读性和简洁性。",
    "学习Python的最佳方式是动手做项目。"
]

# 生成嵌入向量(shape: [3, 768])
embeddings = model.encode(documents, convert_to_tensor=True)

# 打印第一个文档的嵌入向量(前5维)
print(embeddings[0][:5])

输出长这样:

tensor([-0.0321,  0.0456, -0.0123,  0.0789, -0.0234])

768 维的向量,每个维度值都代表文本在某个语义方向上的响应。人没法直接看懂,但计算机拿它算距离就很好使。

3.2.4 向量数据库存储

嵌入向量得存起来。数据量不大就用 Faiss,本地文件直接写盘。代码示例:

import faiss
import numpy as np

# 将Tensor转换为numpy数组(Faiss要求输入为float32)
embeddings_np = embeddings.cpu().numpy().astype('float32')

# 构建Faiss索引(IVF_FLAT,适合百万级数据)
index = faiss.IndexIVFFlat(
    d=embeddings_np.shape[1],  # 向量维度(768)
    nlist=100,                 # 聚类中心数量(根据数据量调整)
    metric=faiss.METRIC_COSINE  # 相似性度量(余弦相似度)
)

# 训练索引(需要随机样本)
index.train(embeddings_np)

# 添加向量到索引
index.add(embeddings_np)

# 保存索引到本地
faiss.write_index(index, 'python_docs.index')

IVF_FLAT 这种索引是给百万级数据准备的。它先做聚类,检索时只在一个小范围内找,速度就上来了。nlist 设 100,意思是把数据先分成 100 个簇,具体数值要根据数据量调。

3.2.5 查询处理与检索

用户输入一句查询,系统走四步:

查询预处理,跟文档预处理一样,清洗、分词、截断。

查询嵌入生成,用同一个 Sentence-BERT 模型把查询转成向量。注意必须跟文档用的是同一套模型,不然向量空间都不一样,算相似度没意义。

粗检索,拿查询向量去 Faiss 索引里快速搜出 Top 1000 篇候选文档。这一步非常快,代价是精度不够。

精排序,用 Cross-Encoder 把查询和每篇候选文档拼在一起,算一个匹配分。这个模型比 Sentence-BERT 慢很多,但只在 1000 篇候选中跑,可以接受。最后按分数取 Top 10。

代码长这样:

from sentence_transformers import CrossEncoder

# 加载Cross-Encoder模型(用于精排)
cross_encoder = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

# 用户查询
query = "如何学习Python"

# 生成查询嵌入
query_embedding = model.encode(query, convert_to_tensor=True).cpu().numpy().astype('float32')

# 粗检索:从Faiss索引中找到Top 1000文档(参数nprobe=10,提高检索精度)
k = 1000
distances, indices = index.search(query_embedding.reshape(1, -1), k)

# 获取粗检索的文档(假设documents列表与索引顺序一致)
candidate_documents = [documents[i] for i in indices[0]]

# 精排序:用Cross-Encoder计算查询与候选文档的匹配得分
cross_encoder_input = [[query, doc] for doc in candidate_documents]
scores = cross_encoder.predict(cross_encoder_input)

# 按得分降序排序,取Top 10
top_10_indices = scores.argsort()[::-1][:10]
top_10_documents = [candidate_documents[i] for i in top_10_indices]

# 打印结果
print("Top 10相关文档:")
for i, doc in enumerate(top_10_documents):
    print(f"{i+1}. {doc}")

3.2.6 结果返回

最后把结果打包成 JSON,带上文档内容、相似度得分和元数据。元数据可以放来源、时间、作者这些信息,方便前端展示和后续过滤。

{
    "query": "如何学习Python",
    "results": [
        {
            "document": "学习Python的最佳方式是动手做项目。",
            "score": 0.98,
            "metadata": {
                "source": "Wikipedia",
                "timestamp": "2024-05-01"
            }
        },
        {
            "document": "Python的设计哲学强调代码的可读性和简洁性。",
            "score": 0.85,
            "metadata": {
                "source": "Wikipedia",
                "timestamp": "2024-05-01"
            }
        }
    ]
}

四、项目实战:搭建一个智能知识库语义检索系统

理论说再多,不如动手搭一个。接下来全程实操,用一套完整的技术栈做个能跑的知识库系统。

4.1 项目目标

做一个基于语义检索的智能知识库,用户可以用自然语言提问,系统从产品手册、技术文档里找出最相关的内容返回。比如你放进去几份 Python SDK 文档,用户问“怎么用这个 SDK 发请求”,系统能直接把讲请求的段落捞出来。

4.2 技术栈

后端用 FastAPI,负责起 RESTful 接口。语义嵌入用 Sentence-BERT,之前已经见过,多语言支持。向量数据库用 Milvus,这里跟前面的 Faiss 不一样,因为 Milvus 支持分布式,后续数据量大了不用推倒重来。前端用 Streamlit,写个简单界面很快。数据就用 PDF 格式的产品手册,比如“Python SDK 使用指南”。

4.3 步骤1:环境搭建

先装依赖:

pip install fastapi uvicorn sentence-transformers pymilvus streamlit pdfplumber

Milvus 用 Docker 启动,直接把官方提供的 standalone 配置拉下来:

wget https://github.com/milvus-io/milvus/releases/download/v2.3.0/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker-compose up -d

等容器跑起来,Milvus 就在本地 19530 端口等着了。

4.4 步骤2:数据预处理(PDF转文本)

PDF 里面的文字得先提出来。用 pdfplumber 这个库,按页提取再拼起来:

import pdfplumber

def extract_text_from_pdf(pdf_path):
    text = ""
    with pdfplumber.open(pdf_path) as pdf:
        for page in pdf.pages:
            text += page.extract_text() + "\n"
    return text

# 提取产品手册内容
pdf_path = "python_sdk_guide.pdf"
document_text = extract_text_from_pdf(pdf_path)

# 分段(每段512 tokens)
from sentence_transformers import util

sentences = util.split_into_sentences(document_text, language='zh')
chunks = []
current_chunk = []
current_length = 0
max_length = 512

for sentence in sentences:
    sentence_length = len(sentence.split())
    if current_length + sentence_length > max_length:
        chunks.append(" ".join(current_chunk))
        current_chunk = [sentence]
        current_length = sentence_length
    else:
        current_chunk.append(sentence)
        current_length += sentence_length

if current_chunk:
    chunks.append(" ".join(current_chunk))

print(f"共提取{len(chunks)}段文档")

这里先按句子拆,再攒句子拼成不超过 512 token 的块。split_into_sentences 这个工具函数能处理中英文的句号、问号、感叹号,挺方便。分块时注意别把一个完整的技术步骤切断,尽量让每块保持独立语义。

4.5 步骤3:生成嵌入并存入Milvus

PDF 变文本,文本分块,然后就是生成向量,存入 Milvus:

from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection

# 连接Milvus
connections.connect("default", host="localhost", port="19530")

连接之后还要建 collection,定义字段。一个 id 字段,一个向量字段,再加一个原始文本字段。字段类型要提前想好,之后不好改。向量维度取 768,对应 Sentence-BERT 的输出维度。建完 collection,把前面生成的 chunks 逐个变成向量插进去。插入时注意一次性批量插入,不要一条条 insert,速度差很多。

具体插入代码和后面的查询接口,下一部分接着写。到这里整个流程已经打通:文档进来,预处理,变成向量,存进 Milvus。接下来就是用户提问,走查询链路,从库里捞结果。

好,接着上一部分继续。前面我们把 Milvus 在本地跑起来了,也理解了集合、分区这些基本概念。现在得动真格的了,把这套东西接到实际的知识库流程里。

先说一个常见误区。很多人拿到 Milvus 第一步就去调索引参数,其实顺序搞反了。最稳妥的做法是先把数据灌进去,跑通一个最简单的检索,然后再回头优化索引。索引只是加速手段,它不改变召回结果的上限。所以下面的步骤也是按这个思路来的,先建集合,再插数据,最后才是建索引。

4.5 定义集合 Schema 并写入数据

Schema 这个东西,说白了就是告诉 Milvus 你存的数据长什么样。我们用四个字段:id 是主键,embedding 存向量,text 存原始文档内容,metadata 放一些结构化信息,比如文档来源、页码这些。

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=384),
    FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=4096),
    FieldSchema(name="metadata", dtype=DataType.JSON)
]
schema = CollectionSchema(fields=fields, description="产品手册知识库")

collection_name = "product_manuals"
collection = Collection(name=collection_name, schema=schema)

注意这里的 embedding 维度设成了 384。很多人会直接照抄 768 或者 1024,但维度必须跟你选的嵌入模型输出对齐。我们用 SentenceTransformer 的 paraphrase-multilingual-MiniLM-L12-v2,它输出的就是 384 维。这个模型对中文支持还行,关键是模型小,本地 CPU 也能跑。

生成向量的时候有个细节容易踩坑。model.encode() 默认返回的是 numpy 数组,但如果你设了 convert_to_tensor=True,拿到的就是 PyTorch 张量。Milvus 只认 float32 的 numpy 数组,所以得记得转一下:

model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
embeddings = model.encode(chunks, convert_to_tensor=True).cpu().numpy().astype('float32')

astype('float32') 这步别省。有些模型默认输出 float64,直接塞给 Milvus 会报类型不匹配。

接下来是插入数据。这里有一个容易懵的点:collection.insert() 接收的是一个 list,list 里的每个元素对应一个字段的数据。注意是按字段顺序排列,不是按文档排列。

data = [
    embeddings,  # embedding字段
    chunks,      # text字段
    [{"source": "python_sdk_guide.pdf", "page": i+1} for i in range(len(chunks))]
]

collection.insert(data)

数据插入之后,需要建索引。这一步可能让你觉得奇怪,刚才不是说最后才搞索引吗?那是因为查询之前必须得有索引。Milvus 不会自动帮你建,你得手动指定。

index_params = {
    "index_type": "IVF_FLAT",
    "metric_type": "COSINE",
    "params": {"nlist": 100}
}
collection.create_index(field_name="embedding", index_params=index_params)
collection.load()

nlist 设多少合适?这个参数的意思是索引构建时把向量空间分成多少个桶。一般建议是 4 * sqrt(n),n 是数据量。但如果你只有几百条测试数据,设 100 完全够用,设太大反而浪费。

最后的 load() 是把集合加载进内存。这一步很多人会忘,忘了的话查询直接报错。而且要注意,每次程序重启之后,都需要重新执行 load()

4.6 步骤4:构建 FastAPI 接口

数据准备好了,接下来就是对外提供服务。用 FastAPI 封装一个检索接口,让前端或者其他服务通过 HTTP 调用。

from fastapi import FastAPI, Query
from pymilvus import Collection

app = FastAPI(title="智能知识库语义检索接口")

collection = Collection(name="product_manuals")
collection.load()

model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

@app.get("/search")
async def search(query: str = Query(..., description="用户查询"), top_k: int = Query(10, ge=1, le=100)):
    query_embedding = model.encode(query, convert_to_tensor=True).cpu().numpy().astype('float32')
    
    search_params = {
        "metric_type": "COSINE",
        "params": {"nprobe": 10}
    }
    results = collection.search(
        data=[query_embedding],
        anns_field="embedding",
        param=search_params,
        limit=top_k,
        output_fields=["text", "metadata"]
    )
    
    search_results = []
    for hit in results[0]:
        search_results.append({
            "text": hit.entity.get("text"),
            "score": hit.score,
            "metadata": hit.entity.get("metadata")
        })
    
    return {"query": query, "results": search_results}

这里有个细节值得说。nprobe 是查询时搜索的桶数量。它跟 nlist 是对应的,nprobe 越大,搜索越精确,但耗时也越高。一个经验值是从 nlist / 10 开始调,看效果再决定往哪个方向动。

查询结果的 score 是余弦相似度,范围在 -1 到 1 之间。实际使用的时候,你会发现一个现象:不管查什么,第一条的 score 都特别高。这大概率是数据量太少导致的。几百条数据、384 维向量,向量空间非常稀疏,最近邻的距离天然就大。这不是 bug,是维度灾难的表现。等数据量上来之后,score 的区分度会逐渐合理。

4.7 步骤5:构建 Streamlit 前端

接口有了,但总不能让人直接拿 curl 去敲吧。最后一步用 Streamlit 搭一个简单的可视化界面,让非技术的同事也能用。

import streamlit as st
import requests

st.title("智能知识库语义检索系统")

query = st.text_input("请输入查询")
top_k = st.slider("返回结果数量", min_value=1, max_value=10, value=5)

if st.button("检索"):
    if not query:
        st.warning("请输入查询内容")
    else:
        response = requests.get(
            "http://localhost:8000/search",
            params={"query": query, "top_k": top_k}
        )
        results = response.json()["results"]
        
        for i, r in enumerate(results, 1):
            st.markdown(f"**Top {i}**")
            st.write(r["text"])
            st.caption(f"score: {r['score']:.4f}")
            st.divider()

Streamlit 的启动方式和 FastAPI 不太一样,不加 __name__ 判断,直接 streamlit run app.py 就行。如果你把 FastAPI 和 Streamlit 跑在同一台机器上,记得 FastAPI 的启动要加 --host 0.0.0.0,不然别人访问不到。Streamlit 默认绑定 localhost,要开放给局域网的话,启动时加 --server.address 0.0.0.0

到这里,一个最小可用的语义检索系统就跑起来了。接下来用户输入问题,前端发请求给 FastAPI,FastAPI 把问题编码成向量去 Milvus 里找相似的内容,拿到结果返回给前端展示。链路的每一环都有独立的出错可能,建议先从 FastAPI 接口开始测,接口通了再调前端。还有最后一部分,聊一下数据量大了之后,这个架构会遇到什么问题,以及怎么应对。

检索通了,但结果还停留在后端返回的数据里,人没法直接看。接下来把结果渲染到页面上。Streamlit 写这种小页面很快,核心代码就这几行:

st.subheader("检索结果:")
for i, result in enumerate(results):
    st.write(f"**{i+1}. 相似度:{result['score']:.2f}**")
    st.write(f"内容:{result['text'][:200]}...")  # 截断显示前200字
    st.write(f"元数据:{result['metadata']}")
    st.divider()

相似度分数保留两位小数,内容先截前 200 个字符再显示。元数据原样打出来,出问题的时候方便排查。

4.8 运行效果

后端和前端分开起。一个终端跑 uvicorn main:app --reload,另一个终端跑 streamlit run frontend.py

浏览器打开 http://localhost:8501,输入“如何使用Python SDK上传文件?”,相关的文档片段就会列出来。

五、语义检索的实际应用场景

语义检索实际用起来的场景已经不少了,挑几个典型的说说。

5.1 智能客服:快速定位知识库

用户问“我的订单为什么还没发货?”,传统客服得人工去翻知识库里的“订单发货规则”。换成语义检索,系统直接匹配到“订单发货时间:付款后48小时内”这种文档,自动回复当场就能拼出来。

5.2 推荐系统:个性化内容推荐

用户刚看完“Python数据分析教程”,基于关键词的传统推荐大概率推“Python编程入门”。语义检索理解“数据分析”和“数据处理”之间的关联,推出来的是“Pandas数据处理实战”,明显更对路。

5.3 代码检索:根据自然语言找代码

想找“用Python实现快速排序”的代码,传统代码库得搜“快速排序 Python”才行。语义检索直接理解“实现快速排序”的意图,把 GitHub 上 star 最多的那个实现给你捞出来。

5.4 学术文献检索:精准找到研究论文

研究者想找“基于Transformer的语义检索”方向的论文,传统学术搜索引擎只能做关键词匹配。语义检索能抓住这句话的语义,返回 ACL、SIGIR 这些顶会里的相关工作。

六、语义检索的挑战与未来趋势

6.1 当前挑战

嵌入模型的泛化能力是第一个坎。Sentence-BERT 这类预训练模型在通用领域表现不错,进了专业领域就露馅。“心肌梗死”在通用语料里和“心脏病”算近义词,到了医疗场景,这两个词差远了。

数据量一上来,实时性就成了问题。亿级数据量下,向量数据库的检索延迟很可能超过 1 秒。推荐、客服这类场景,超过一秒用户就明显感觉卡了。

模态也是个限制。现在的语义检索主要处理文本,图像、语音都还没打通。用户说“找一张猫玩球的图片”,目前这套链路直接抓瞎。

隐私问题容易被忽略。用户查询本身可能带着个人信息,嵌入向量落到数据库里,等于把这些信息永久留了下来。联邦学习、同态加密这类技术还在早期阶段,离大规模使用还有距离。

6.2 未来趋势

针对专业领域的模型会越来越多。BioBERT、LegalBERT 这类模型就是先在通用语料上预训练,再用医疗、法律领域的语料继续训练,出来的嵌入比通用模型靠谱得多。

检索算法也在往快里走。HNSW 的优化版、GPU 加速的向量检索,目标都是把亿级数据上的延迟压到毫秒级。

CLIP 已经把图像和文本映射到同一个向量空间,多模态语义检索的基础算是有了。将来“拿文字找图片”“拿图片找文字”都会变成常规操作。

知识图谱融合解决的是歧义问题。“苹果”带上“水果”的语义关系,和“苹果手机”里的“苹果”是两个意思。把这类结构化知识揉进嵌入,语义理解的准确率还能再上一个台阶。

SimCSE、ELECTRA 这些自监督方法在句嵌入上的效果已经不错,相似性捕捉能力比传统训练方式更强。这个方向还在快速迭代。

七、工具与资源推荐

7.1 预训练模型库

Hugging Face Transformers 是第一站。Sentence-BERT、BERT、CLIP 都在里面,多语言支持做得也好。TensorFlow Hub 上有 LaBSE 这样的多语言句嵌入模型,也有老牌的 USE 通用句嵌入。

7.2 向量数据库

Faiss 适合本地小规模数据,检索速度基本是毫秒级。Milvus 适合分布式大规模数据,元数据过滤这类需求也能覆盖。Pinecone 走云原生路线,完全托管,省运维。

7.3 可视化工具

TensorBoard 配合 TSNE,能把 768 维的嵌入向量降维到 2 维,整个分布一眼看清。Weights & Biases 适合盯训练过程,损失函数和准确率的变化都记录得很清楚。

7.4 数据集

MS MARCO 是信息检索领域的大规模数据集,100 万条查询、10 亿条文档,训练检索模型基本都用得上它。Google 的 Natural Questions 有 8 万条真实用户查询,贴近实际场景。STS-B 规模小一些,5000 对句子带相似性标注,拿来做快速验证很方便。

7.5 书籍

李航的《深度学习自然语言处理》把 NLP 基础讲得比较透。想理解早期语义检索技术,Grigoris Antoniou 的《语义网基础》不能跳过,RDF、OWL 这些概念都覆盖到了。Milvus 团队写的《向量数据库实战》更偏实践,搭大规模系统时可以直接参照。

八、总结:语义检索——AI理解自然语言的关键一步

信息检索从关键词匹配走到语义理解,这一步跨出去,整个逻辑都不一样了。文本转成语义向量,向量数据库负责存储和召回,模型负责理解,这条链路已经能支撑实际业务。

还有不少硬骨头要啃,但方向很清楚:更快、更准、更多模态。对开发者来说,这套技术学下来不亏,知识库、推荐、客服,哪个方向都用得上。

最后用一句话收个尾。语义检索做了那么多,最终目的是让机器听懂人话。说得感性一点,懂你的心

(全文完)

常见问题(FAQ)

语义检索和传统关键词搜索有什么区别?

传统检索靠关键词匹配,理解不了多义词和意图;语义检索将文本转为向量,按语义相似度匹配,能处理同义表达和上下文消歧,但成本更高、结果不易解释。

语义检索的核心原理是什么?

核心是将文本编码为语义向量,用余弦相似度等度量比较相似性,再通过向量数据库高效检索。常用模型有Sentence-BERT,也可用Bi-Encoder粗排加Cross-Encoder精排提升效果。

语义检索有哪些实际应用场景?

适用于需要理解用户意图的搜索,如搜索“苹果价格走势”能区分水果或股票,也用于问答系统、推荐系统等。相比传统搜索更智能,但需权衡成本和可解释性。

Roger深圳
本文由 Roger 审核,最后更新于 2026年8月4日
联系编辑 →
← 返回文章列表
分享到:微博
下一篇
ChatGPT SEO

版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。

文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。

若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。