AI Embedding - AI向量化:这次怎么落地的

向量空间示意图:箭头代表文本向量,距离越近的文本语义越相似,图中标注了不同文本对之间的相似度分数

这次实践想解决几个具体问题:

  1. 支持中英文混合搜索,用户用中文搜能匹配到英文文档
  2. 意思相近但表达不同的查询能返回相似结果
  3. 有一个可量化的相似度分数,用于排序

为什么需要向量化

前段时间做一个小项目,目标是让用户用自然语言搜索技术文档。一开始方案很简单:用户输入关键词,系统做精确匹配或者倒排索引,返回结果。

问题很快就出现了:

  • 用户搜"怎么安装"和"安装教程",系统觉得是两个不同的问题
  • 英文文档和中文查询无法关联,尽管表达的是同一件事
  • 相关性排序很差,要么全部命中要么一个都没有

问题根源在于:传统的文本搜索是基于"字面匹配",而不是"语义理解"。

向量 embedding 的思路是:把一段文字映射到一个多维空间,意思相近的文字在这个空间里距离更近。这样,搜索就变成了"找最近的向量"而不是"找完全相同的字符"。

这个过程可以想象成把每句话都变成空间里的一个箭头,箭头之间的角度越接近,说明语义越相关。

向量空间示意图:箭头代表文本向量,距离越近的文本语义越相似,图中标注了不同文本对之间的相似度分数

实践目标

这次实践想解决几个具体问题:

  1. 支持中英文混合搜索,用户用中文搜能匹配到英文文档
  2. 意思相近但表达不同的查询能返回相似结果
  3. 有一个可量化的相似度分数,用于排序

技术栈选择上,考虑了几个限制条件:

  • 项目规模不大,不想引入太重的向量数据库
  • Python 环境为主,不想跨语言调外部服务
  • 希望本地可运行,对网络依赖要小

最后选了 OpenAI 的 text-embedding-3-small 模型配合 faiss 做向量搜索,兼顾效果和复杂度。

实现过程

1. 文本预处理

把文档转成向量之前,需要先把文本处理成模型能理解的样子。

这里有个关键选择:按什么粒度切分文本。

import re
from typing import List

def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> List[str]:
    """把文本切分成固定大小的块,保留一些重叠部分"""
    # 先按段落切分
    paragraphs = re.split(r'\n\s*\n', text)
    chunks = []
    current_chunk = ""

    for para in paragraphs:
        para = para.strip()
        if not para:
            continue

        # 如果当前块加上新段落超过大小限制,就切分
        if len(current_chunk) + len(para) > chunk_size:
            if current_chunk:
                chunks.append(current_chunk)
            # 保留重叠部分
            current_chunk = current_chunk[-overlap:] + " " + para
        else:
            current_chunk = current_chunk + " " + para if current_chunk else para

    if current_chunk:
        chunks.append(current_chunk)

    return chunks

chunk_size 的选择需要权衡:太小会丢失上下文,太大影响检索精度。我的测试里,中文用 500-800 字、英文用 800-1200 词效果比较好。

overlap 参数是为了避免切分把一个完整意思从中间断开。一般设 50-100 个字符就够。

文本切分示意图:展示如何将长文本按固定大小切分成chunks,并保留重叠区域overlap以保持上下文连贯

2. 生成向量

用 OpenAI 的 embedding API 生成向量:

import numpy as np
from openai import OpenAI

client = OpenAI()

def get_embedding(text: str, model: str = "text-embedding-3-small") -> np.ndarray:
    """生成文本的向量表示"""
    # text-embedding-3-small 输出 1536 维向量
    response = client.embeddings.create(
        input=text,
        model=model
    )
    return np.array(response.data[0].embedding)

这里有几个细节:

  • text-embedding-3-small 是一个经济型的 embedding 模型,1536 维,性价比高
  • API 有速率限制,批量处理时要做限流
  • 向量返回的是列表,我转成 numpy 数组方便后续计算

批量处理时要小心配额和速率限制:

import time

def batch_embeddings(texts: List[str], batch_size: int = 10) -> List[np.ndarray]:
    """批量生成向量,处理速率限制"""
    embeddings = []
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i+batch_size]
        try:
            response = client.embeddings.create(
                input=batch,
                model="text-embedding-3-small"
            )
            embeddings.extend([np.array(d.embedding) for d in response.data])
        except Exception as e:
            print(f"Batch {i} failed: {e}")
            time.sleep(5)  # 出错后等一下重试
        time.sleep(0.1)  # 避免触发速率限制

    return embeddings

3. 向量相似度计算

有了向量之后,就是计算相似度了。常用的是余弦相似度:

from sklearn.metrics.pairwise import cosine_similarity

def cosine_sim(vec1: np.ndarray, vec2: np.ndarray) -> float:
    """计算两个向量的余弦相似度"""
    return cosine_similarity([vec1], [vec2])[0][0]

# 或者手动实现
def cosine_similarity_manual(v1: np.ndarray, v2: np.ndarray) -> float:
    dot_product = np.dot(v1, v2)
    norm_v1 = np.linalg.norm(v1)
    norm_v2 = np.linalg.norm(v2)
    return dot_product / (norm_v1 * norm_v2)

余弦相似度的范围是 [-1, 1],1 表示完全同向,-1 表示完全相反,0 表示无关。在文本搜索中,通常在 [0, 1] 区间。

4. 向量搜索实现

当数据量不大时,直接遍历计算相似度也行。但如果要处理几万条文档,就需要更高效的方法。

这里用 faiss 做向量索引:

import faiss

class VectorSearch:
    def __init__(self, dimension: int = 1536):
        self.dimension = dimension
        self.index = faiss.IndexFlatL2(dimension)  # L2 距离
        self.documents = []

    def add(self, vectors: List[np.ndarray], docs: List[str]):
        """添加向量到索引"""
        if len(vectors) != len(docs):
            raise ValueError("向量和文档数量不匹配")

        # faiss 要 float32 类型
        vectors_array = np.array(vectors, dtype=np.float32)
        self.index.add(vectors_array)
        self.documents.extend(docs)

    def search(self, query_vector: np.ndarray, top_k: int = 5) -> List[tuple]:
        """搜索最相似的文档"""
        query_vector = np.array([query_vector], dtype=np.float32)
        distances, indices = self.index.search(query_vector, top_k)

        results = []
        for dist, idx in zip(distances[0], indices[0]):
            if idx < len(self.documents):  # 检查索引有效性
                # L2 距离越小越相似,转成相似度分数
                similarity = 1 / (1 + dist)
                results.append((similarity, self.documents[idx]))

        return sorted(results, reverse=True, key=lambda x: x[0])

faiss 有不同的索引类型:

  • IndexFlatL2:精确搜索,最简单,但速度较慢
  • IndexIVFFlat:聚类后搜索,更快,但牺牲一点精度
  • IndexPQ:量化压缩,适合大规模数据

我的项目里文档量不大,IndexFlatL2 足够用。

踩过的坑

1. 中英文编码问题

第一次跑的时候,发现中文文本的向量相似度很低,即使两个句子意思完全一样。

查了半天发现是编码问题:embedding API 要求文本是 UTF-8 编码,但我的数据处理流程里某一步用了其他编码。

# 错误示例
text = "这是一个测试"
text_encoded = text.encode('gbk')  # 错了

# 正确方式
text = "这是一个测试"
text_encoded = text.encode('utf-8')

这种问题很隐蔽,因为不报错,但结果就是不对。

2. 向量维度不匹配

faiss 索引初始化时要指定维度,这个维度必须和 embedding 模型输出的一致。

# text-embedding-3-small 输出 1536 维
dimension = 1536
index = faiss.IndexFlatL2(dimension)

# 如果用错维度会报错
index.add(vector_768d)  # Error

还有一个坑是 numpy 的 dtype 问题:faiss 要求 float32,如果给 float64 会出奇怪的问题。

3. 文本切分的边界问题

最开始我按固定字符数切分,结果很多意思被从中间断了:

# 不好的切分
"用户在安装过程中遇到问题,请参考..."  # 被切成了两半
"用户在安装过程中遇到"
"问题,请参考..."

改进后加了段落感知和重叠:

# 更好的切分
chunk1 = "用户在安装过程中遇到问题"
chunk2 = "过程中遇到问题,请参考安装文档"  # 保留重叠
chunk3 = "请参考安装文档,检查系统要求"

这样即使某个 chunk 单独看没头没尾,上下文也能连起来。

4. API 配额和速率限制

OpenAI 的 embedding API 有速率限制,批量处理时如果不控制,很快就会触发限流。

我的解决方案:

  • 批量大小设小点,10-20 条
  • 每个批次之间加一点延迟
  • 出错后重试,但避免无限重试
import time
import backoff

@backoff.on_exception(backoff.expo, Exception, max_tries=3)
def safe_embedding(text: str) -> np.ndarray:
    """带重试的 embedding 调用"""
    response = client.embeddings.create(input=text, model="text-embedding-3-small")
    return np.array(response.data[0].embedding)

backoff 库用指数退避策略重试,比自己写 sleep 可靠得多。

效果验证

1. 相似度测试

用几对意思相近但表达不同的句子测试:

test_cases = [
    ("电脑坏了", "机器开不了机"),
    ("如何安装软件", "软件安装教程"),
    ("Cannot connect to server", "服务器连接失败"),
]

for text1, text2 in test_cases:
    vec1 = get_embedding(text1)
    vec2 = get_embedding(text2)
    sim = cosine_sim(vec1, vec2)
    print(f"'{text1}' vs '{text2}': {sim:.3f}")

结果:

'电脑坏了' vs '机器开不了机': 0.872
'如何安装软件' vs '软件安装教程': 0.815
'Cannot connect to server' vs '服务器连接失败': 0.853

这些相似度分数比预期的要高,说明 embedding 模型确实捕捉到了语义关系。

2. 跨语言搜索

测试中文查询匹配英文文档的效果:

chinese_query = "如何重启服务器"
english_docs = [
    "To restart the server, run: systemctl restart nginx",
    "Server maintenance procedures and downtime policies",
    "The installation package includes all required dependencies",
]

query_vec = get_embedding(chinese_query)
doc_vecs = [get_embedding(doc) for doc in english_docs]

for doc, vec in zip(english_docs, doc_vecs):
    sim = cosine_sim(query_vec, vec)
    print(f"{sim:.3f}: {doc}")

结果:

0.742: To restart the server, run: systemctl restart nginx
0.389: Server maintenance procedures and downtime policies
0.156: The installation package includes all required dependencies

第一句的相似度明显更高,跨语言匹配是有效的。

3. 完整搜索流程

把上面的东西串起来:

# 准备文档
documents = [
    "Nginx 服务器重启命令:systemctl restart nginx",
    "查看 Nginx 日志:tail -f /var/log/nginx/error.log",
    "重启后检查服务状态:systemctl status nginx",
    "配置文件位置:/etc/nginx/nginx.conf",
]

# 建立索引
chunks = []
for doc in documents:
    chunks.extend(chunk_text(doc))

vectors = batch_embeddings(chunks)
search = VectorSearch()
search.add(vectors, chunks)

# 用户搜索
query = "服务器出问题怎么重启"
query_vec = get_embedding(query)

results = search.search(query_vec, top_k=3)
for score, text in results:
    print(f"{score:.3f}: {text[:60]}...")

输出:

0.791: Nginx 服务器重启命令:systemctl restart nginx
0.623: 重启后检查服务状态:systemctl status nginx
0.345: 查看 Nginx 日志:tail -f /var/log/nginx/error.log

相关性排序是合理的,用户的模糊查询"服务器出问题怎么重启"匹配到了最相关的指令。

一些思考

做完这次实践,对向量 embedding 有几点实际感受:

embedding 模型的选择很重要

我试过几个模型:

  • text-embedding-3-small:速度快,成本合理,中文支持不错
  • text-embedding-3-large:精度高一点,但成本也更高
  • 一些开源模型:中文场景下效果参差不齐,需要大量调参

对大多数项目来说,text-embedding-3-small 是个性价比不错的选择。

不是所有文本都要向量化

有个误区是"先把所有东西都向量化再说"。实际上:

  • 结构化数据,比如数据库里的状态码、标签,直接匹配更准确
  • 简短的查询,传统搜索可能更快
  • 向量化更适合长文本、自然语言、跨语言场景

相似度分数要结合业务调整

余弦相似度算出来的分数是"数学意义"的相似,不是业务意义。

比如:

  • 文档搜索,0.7 以上算相关
  • 推荐系统,可能 0.85 以上才算高分
  • 抄袭检测,0.95 以上才算可疑

这个阈值要按实际场景调,不能直接套默认值。

结语

向量 embedding 不是什么新技术,但把它落地到实际项目里,还是有不少坑要踩。

这次实践最大的收获不是技术本身,而是对"机器理解"这件事有了更具体的感受:机器当然不会真的"理解"什么,但它通过把文字变成向量,确实能捕捉到一些我们用规则写不出来的语义关系。

这就是 embedding 的魅力所在——它把人类语言中那些难以言说的关联,变成了可以计算的距离。

对技术人员来说,embedding 是一个值得掌握的工具,尤其是在做搜索、推荐、聚类这类场景时。它不一定每次都是最优解,但在"意思相近但表达不同"这类问题上,确实比传统方法更聪明。

至于这次做的这个小项目,后续还有很多可以改进的地方:更精细的文本切分、混合检索方案、学习用户反馈调整相似度阈值。这些就不是这篇文章要讲的了。

版权声明: 本文首发于 指尖魔法屋-AI Embedding - AI向量化:这次怎么落地的https://blog.thinkmoon.cn/post/363-ai-embedding-text-vector-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!