Appearance
嵌入与检索
大模型一次请求能带的文字有限。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 都有 id、question、answer。id 后面用来定位是哪条资料被命中,question 和 answer 是要拿去做 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 存索引就够了;资料变多时,索引文件会换成专门的向量库或数据库扩展。