Skip to content

嵌入与检索

大模型一次请求能带的文字有限。FAQ、文章、说明文档一多,全塞进 input 里很快就乱了,费用也不好看。

检索要解决的就是“先找资料,再交给模型”。Embedding 是其中常用的一步:把一段文字变成一串数字。数字本身人看不懂,但程序可以拿它们算相似度,找出意思接近的内容。

比如 FAQ 里写的是:

text
忘记密码怎么办?

用户问的是:

text
我登录不上了,密码好像记错了

这两句话没有几个完全相同的词,但意思很接近。普通关键词搜索可能搜不到,embedding 检索就有机会把它们排到前面。

一、向量是什么

Embedding 返回的是一个列表,里面是一堆浮点数。

text
[0.0123, -0.0431, 0.0876, ...]

可以近似理解成:模型把一段文字在“语义空间”里的位置记了下来。两段文字意思越接近,它们的位置越近;意思差得越远,位置就越远。

这个比喻不完全精确,但够写脚本了。Python 不关心每个数字单独是什么意思,只关心两组数字放在一起算出来像不像。

二、模型选择

Embedding 这块很容易误会成“随便找个大模型,把文字丢进去,让它吐一串向量”。真正麻烦的地方不在“有没有数字”,而在这些数字能不能稳定表示语义相似。

聊天模型主要干的是生成文字,比如根据上文继续写、回答问题、整理材料。Embedding 模型训练时会更在意另一件事:意思接近的句子,向量位置也接近;意思差得远的句子,向量位置就拉开。后面用余弦相似度算“像不像”,靠的就是这个训练方向。

所以实际做检索时,一般用专门的 embedding 模型或 embedding 接口。这个“专门”不等于模型一定很小,也不等于它和大语言模型完全没关系。有些 embedding 模型底层也可能来自大模型,只是已经为检索、相似度、排序这些任务做过训练或微调。直接从普通聊天模型的内部表示里抠一段向量来凑,理论上也能得到数字,但分数经常不好解释:两个问题明明意思接近,算出来不一定近;有些只是词面接近的内容,反而被排到前面。

还有一个坑要单独记一下:建索引用的模型,和查询时用的模型要一致。比如 outputs/faq-index.json 是用 A 模型生成的,搜索时环境变量换成 B 模型,向量空间就不一样了,分数没有可比性。轻则排序变怪,重则向量维度都对不上。FAQ 内容换了要重建索引,embedding 模型换了也要重建索引。

起步时用现成 embedding 接口更省事。中文或多语种资料可以留意 BGE、Qwen Embedding、E5 这类模型;云厂商和大模型平台也会提供自己的 embedding 模型。榜单可以参考,但真正要换模型时,还是拿自己的 FAQ 和真实问题测几轮命中结果。比如“密码记错了怎么登录”能不能排到“忘记密码怎么办”前面,“发票抬头能不能改”会不会误命中“发票在哪里下载”,这种结果比模型名字更有用。

三、准备 FAQ

先用一个很小的 FAQ 文件。

新增文件:faq.json

json
[
  {
    "id": "faq-001",
    "question": "忘记密码怎么办?",
    "answer": "可以在登录页点击忘记密码,通过邮箱验证码重置密码。"
  },
  {
    "id": "faq-002",
    "question": "如何修改绑定邮箱?",
    "answer": "登录后进入账号设置,在安全信息里修改邮箱。"
  },
  {
    "id": "faq-003",
    "question": "发票在哪里下载?",
    "answer": "进入订单中心,打开对应订单后可以下载发票。"
  },
  {
    "id": "faq-004",
    "question": "如何关闭消息通知?",
    "answer": "在个人设置的通知管理里关闭邮件或站内消息提醒。"
  }
]

每条 FAQ 都有 idquestionanswerid 后面用来定位是哪条资料被命中,questionanswer 是要拿去做 embedding 的文字。

四、第一次生成向量

这里单独用一个环境变量放 embedding 模型名,不和前面聊天模型用的 OPENAI_MODEL 混在一起。后面生成索引、搜索问题,都读取同一个变量。

PowerShell:

powershell
$env:OPENAI_EMBEDDING_MODEL = "<embedding-model-name>"

Linux / macOS:

bash
export OPENAI_EMBEDDING_MODEL="<embedding-model-name>"

<embedding-model-name> 换成账号里可用的 embedding 模型名,尖括号删掉。

新增文件:scripts/first_embedding.py

python
#!/usr/bin/env python3
"""生成一段文字的 embedding。"""

import os
import sys

from openai import OpenAI


def main():
    api_key = os.getenv("OPENAI_API_KEY")
    embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL")

    if not api_key:
        print("缺少环境变量: OPENAI_API_KEY", file=sys.stderr)
        return 2
    if not embedding_model:
        print("缺少环境变量: OPENAI_EMBEDDING_MODEL", file=sys.stderr)
        return 2

    client = OpenAI(api_key=api_key, timeout=60.0)
    response = client.embeddings.create(
        model=embedding_model,
        input="忘记密码怎么办?",
    )

    vector = response.data[0].embedding
    print(f"向量长度: {len(vector)}")
    print(vector[:5])
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

运行:

bash
uv run python scripts/first_embedding.py

输出大概长这样:

text
向量长度: 1536
[0.0123, -0.0431, 0.0876, 0.0042, -0.0198]

长度和具体数字会跟模型有关。这里不用记每个数字,只要知道 response.data[0].embedding 是 Python 列表,后面可以保存、计算相似度。

五、批量生成

FAQ 有多条时,可以一次传入多段文字。返回的 response.data 顺序和输入顺序对应。

python
texts = [
    "忘记密码怎么办? 可以在登录页点击忘记密码,通过邮箱验证码重置密码。",
    "如何修改绑定邮箱? 登录后进入账号设置,在安全信息里修改邮箱。",
]

response = client.embeddings.create(
    model=embedding_model,
    input=texts,
)

for item in response.data:
    print(item.index, len(item.embedding))

item.index 是这条向量对应的输入序号。批量处理时保留这个序号很有用,能把向量和原始 FAQ 对回去。

六、保存索引

Embedding 调一次 API 才能得到向量。FAQ 内容不变时,没必要每次搜索都重新生成所有 FAQ 的向量。更自然的做法是把 FAQ 和向量保存成一个本地索引文件。

新增文件:scripts/build_faq_index.py

python
#!/usr/bin/env python3
"""把 FAQ 转成 embedding 索引。"""

import json
import os
import sys
from pathlib import Path

from openai import OpenAI


def main():
    api_key = os.getenv("OPENAI_API_KEY")
    embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL")

    if not api_key:
        print("缺少环境变量: OPENAI_API_KEY", file=sys.stderr)
        return 2
    if not embedding_model:
        print("缺少环境变量: OPENAI_EMBEDDING_MODEL", file=sys.stderr)
        return 2

    faq_path = Path("faq.json")
    if not faq_path.exists():
        print(f"FAQ 文件不存在: {faq_path}", file=sys.stderr)
        return 2

    faqs = json.loads(faq_path.read_text(encoding="utf-8"))
    texts = [
        f"{item['question']}\n{item['answer']}"
        for item in faqs
    ]

    client = OpenAI(api_key=api_key, timeout=60.0)
    response = client.embeddings.create(
        model=embedding_model,
        input=texts,
    )

    index = []
    for faq, embedding_item in zip(faqs, response.data):
        index.append({
            "id": faq["id"],
            "question": faq["question"],
            "answer": faq["answer"],
            "embedding": embedding_item.embedding,
        })

    output_path = Path("outputs/faq-index.json")
    output_path.parent.mkdir(exist_ok=True)
    output_path.write_text(
        json.dumps(index, ensure_ascii=False),
        encoding="utf-8",
    )

    print(f"索引已保存: {output_path}")
    print(f"FAQ 数量: {len(index)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

运行:

bash
uv run python scripts/build_faq_index.py

生成文件:

text
outputs/faq-index.json

这个文件里会保存每条 FAQ 的原文和 embedding。向量很长,JSON 文件会比原始 FAQ 大很多,这是正常的。

这个索引和 OPENAI_EMBEDDING_MODEL 是绑在一起的。比如建索引时用的是 text-embedding-a,搜索时又换成 text-embedding-b,文件还能读出来,但相似度分数已经不可靠。换模型时重新运行一次 build_faq_index.py,比带着旧索引继续查更稳。

七、相似度

两个向量怎么判断像不像?常用的是余弦相似度。名字有点数学,但代码不复杂。

新增文件:scripts/search_faq.py

先写相似度函数:

python
import math


def cosine_similarity(left, right):
    """计算两个向量的余弦相似度。"""
    # 维度不一致时,相似度本身就没有意义,通常是索引和查询用了不同模型。
    if len(left) != len(right):
        raise ValueError(f"向量维度不一致: {len(left)} != {len(right)}")

    dot = sum(a * b for a, b in zip(left, right))
    left_norm = math.sqrt(sum(a * a for a in left))
    right_norm = math.sqrt(sum(b * b for b in right))

    if left_norm == 0 or right_norm == 0:
        return 0.0

    return dot / (left_norm * right_norm)

返回值越接近 1,意思越接近;越接近 0,关系越弱。实际项目里不用死记某个固定阈值,先看排序结果更直观。

这里加了一个维度检查。正常情况下,同一个 embedding 模型生成出来的向量长度一致;如果搜索时报“向量维度不一致”,第一反应就是看建索引和搜索时的 OPENAI_EMBEDDING_MODEL 有没有换过。

八、搜索脚本

完整的 scripts/search_faq.py

python
#!/usr/bin/env python3
"""用 embedding 在 FAQ 里搜索相近问题。"""

import argparse
import json
import math
import os
import sys
from pathlib import Path

from openai import OpenAI


def parse_args():
    parser = argparse.ArgumentParser(description="search faq by embedding")
    parser.add_argument("query", help="用户问题")
    parser.add_argument("--index", default="outputs/faq-index.json", help="FAQ 索引文件")
    parser.add_argument("--top-k", type=int, default=3, help="返回条数")
    return parser.parse_args()


def cosine_similarity(left, right):
    # 维度不一致时,相似度本身就没有意义,通常是索引和查询用了不同模型。
    if len(left) != len(right):
        raise ValueError(f"向量维度不一致: {len(left)} != {len(right)}")

    dot = sum(a * b for a, b in zip(left, right))
    left_norm = math.sqrt(sum(a * a for a in left))
    right_norm = math.sqrt(sum(b * b for b in right))
    if left_norm == 0 or right_norm == 0:
        return 0.0
    return dot / (left_norm * right_norm)


def main():
    args = parse_args()
    api_key = os.getenv("OPENAI_API_KEY")
    embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL")

    if not api_key:
        print("缺少环境变量: OPENAI_API_KEY", file=sys.stderr)
        return 2
    if not embedding_model:
        print("缺少环境变量: OPENAI_EMBEDDING_MODEL", file=sys.stderr)
        return 2

    index_path = Path(args.index)
    if not index_path.exists():
        print(f"索引文件不存在: {index_path}", file=sys.stderr)
        return 2

    index = json.loads(index_path.read_text(encoding="utf-8"))

    client = OpenAI(api_key=api_key, timeout=60.0)
    response = client.embeddings.create(
        model=embedding_model,
        input=args.query,
    )
    query_embedding = response.data[0].embedding

    results = []
    for item in index:
        score = cosine_similarity(query_embedding, item["embedding"])
        results.append({
            "score": score,
            "id": item["id"],
            "question": item["question"],
            "answer": item["answer"],
        })

    results.sort(key=lambda item: item["score"], reverse=True)

    for item in results[:args.top_k]:
        print(f"{item['score']:.4f} {item['id']} {item['question']}")
        print(f"  {item['answer']}")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

运行:

bash
uv run python scripts/search_faq.py "密码记错了怎么登录"

输出可能类似:

text
0.8421 faq-001 忘记密码怎么办?
  可以在登录页点击忘记密码,通过邮箱验证码重置密码。
0.5120 faq-002 如何修改绑定邮箱?
  登录后进入账号设置,在安全信息里修改邮箱。
0.4018 faq-004 如何关闭消息通知?
  在个人设置的通知管理里关闭邮件或站内消息提醒。

分数只是排序参考,不是绝对真理。第一条明显命中“忘记密码”,后面几条只是相对更接近,不代表真的能回答问题。

九、关键词对比

Embedding 检索和关键词搜索的差别,可以用两个问题看出来。

用户问:

text
密码记错了怎么登录

FAQ 里写:

text
忘记密码怎么办?

关键词搜索如果只按字面匹配,“记错”和“忘记”不一样,可能排不到前面。Embedding 会看语义接近,所以更容易命中。

但 embedding 也不是万能的。比如问:

text
订单发票抬头能不能改

FAQ 里只有:

text
发票在哪里下载?

它们都提到发票,embedding 可能会把这条排上来,但这条 FAQ 并不能回答“抬头能不能改”。检索只是找候选资料,不等于已经有正确答案。

十、索引更新

FAQ 改了以后,索引也要重新生成。比如新增一条:

json
{
  "id": "faq-005",
  "question": "如何修改发票抬头?",
  "answer": "发票开具前可以在订单页修改抬头,已开具发票需要联系客服处理。"
}

这条如果只加到 faq.json,不重新运行 build_faq_index.py,搜索时还是找不到新内容,因为 outputs/faq-index.json 里没有它的 embedding。

重新生成:

bash
uv run python scripts/build_faq_index.py

这个动作可以理解成“重建索引”。资料变了,索引也要跟着变。这个小 FAQ 直接全量重建就够了,逻辑也清楚。

十一、检索结果怎么用

检索脚本当前只是把相近 FAQ 打印出来:

text
0.8421 faq-001 忘记密码怎么办?
  可以在登录页点击忘记密码,通过邮箱验证码重置密码。

如果要让模型回答问题,就把排在前面的几条 FAQ 拼进 input,让模型带着资料回答。本篇的脚本先停在检索结果上:资料转向量,问题转向量,再按相似度排序。

这时 AI 脚本已经多了一层很实用的动作:不是把所有资料都塞给模型,而是在本地先找出几条相关资料。资料少时用 JSON 存索引就够了;资料变多时,索引文件会换成专门的向量库或数据库扩展。