用 Sentence Transformers + FAISS + DeepSeek 构建无框架问答系统
本文将带你抛开 LangChain 等封装框架,用最基础的库从零构建一个检索增强生成(RAG)系统。我们将使用
sentence-transformers生成嵌入向量,faiss进行向量相似度检索,OpenAI Python SDK调用 DeepSeek 大模型。每一步都会用通俗的语言解释"为什么这么做",让 RAG 的核心原理一览无遗。如果你曾困惑于框架的"黑盒",这篇教程会让你真正掌握 RAG 的骨架。
1. 前言:为什么需要"从零手写" RAG?
LangChain、LlamaIndex 等框架极大地降低了 RAG 的入门门槛,但它们也像一层毛玻璃:开发者知道怎么做,却不一定清楚为什么这么做。
从零手写一个 RAG 系统,你将会透彻理解:
- 文档嵌入(Embedding)的本质是什么?
- 向量数据库如何工作?
- 检索结果如何拼接到提示词(Prompt)中?
- 大模型是如何利用检索到的上下文生成答案的?
一旦掌握了这些核心概念,无论未来框架如何变迁,你都能快速适应、高效排错,并针对自己的业务场景做深度优化。
本教程选用的技术栈非常轻量:
sentence-transformers:本地加载嵌入模型,将文本转为向量。faiss-cpu:Facebook 开源的高效向量相似度搜索库。openaiSDK:兼容 OpenAI 接口的客户端,可以无缝调用 DeepSeek API。
整套代码不到 70 行,却能完成一个完整的 RAG 流程。
2. 环境准备
2.1 安装依赖
在 Python 3.9+ 环境中执行:
bash
pip install sentence-transformers faiss-cpu numpy
pip install openai python-dotenv
sentence-transformers:用于生成文本嵌入向量。faiss-cpu:用于向量索引和快速相似度搜索。numpy:处理向量和数组。openai:兼容 OpenAI 接口的 Python SDK,用于调用 DeepSeek。python-dotenv:从.env文件加载环境变量(管理 API 密钥)。
2.2 准备嵌入模型
本示例使用 all-MiniLM-L6-v2,一个轻量级的英文嵌入模型(384 维)。如果你的文档主要是中文,建议替换为 BAAI/bge-small-zh-v1.5 等中文优化模型。
提前下载模型到本地文件夹 ./all-MiniLM-L6-v2:
bash
# 安装 huggingface-cli(如果没有)
pip install huggingface_hub
# 下载模型
huggingface-cli download sentence-transformers/all-MiniLM-L6-v2 --local-dir ./all-MiniLM-L6-v2
如果你的网络受限,可以使用 HuggingFace 镜像:
bash
export HF_ENDPOINT=https://hf-mirror.com
huggingface-cli download sentence-transformers/all-MiniLM-L6-v2 --local-dir ./all-MiniLM-L6-v2
2.3 获取 DeepSeek API Key
-
前往 DeepSeek 开放平台 注册并获取 API Key。
-
在项目根目录创建
.env文件,内容如下:DEEPSEEK_API_KEY=你的密钥
代码中会通过 dotenv 加载这个环境变量。
3. 分步详解
下面我们逐段分析代码,并穿插 RAG 的核心原理。
3.1 准备文档数据
python
import os
from dotenv import load_dotenv
load_dotenv() # 加载 .env 中的环境变量
docs = [
"黑神话悟空的战斗如同武侠小说活过来一般...",
"72变神通不只是变化形态...",
"每场BOSS战都是一场惊心动魄的较量...",
"驾着筋斗云翱翔在这片神话世界...",
"这不是你熟悉的西游记...",
"作为齐天大圣,悟空的神通不止于金箍棒...",
"世界的每个角落都藏着故事...",
"故事发生在大唐之前的蛮荒世界...",
"游戏的音乐如同一首跨越千年的史诗..."
]
load_dotenv():读取.env文件中的环境变量,让os.getenv("DEEPSEEK_API_KEY")能够正确获取密钥。这是保护敏感信息的好习惯,避免将 API Key 硬编码在代码中。docs:我们的"知识库",是一个字符串列表,每个元素代表一篇短文。在实际项目中,这些文档可能来自 PDF、网页、数据库等,但最终都会被清洗为纯文本。
💡 关键思想:RAG 需要一个外部知识库,这个知识库的内容不进入模型参数,而是通过检索的方式临时注入到提示词中。因此,文档的质量和相关性直接决定答案的优劣。
3.2 设置嵌入模型,生成文档向量
python
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('./all-MiniLM-L6-v2')
doc_embeddings = model.encode(docs)
print(f"文档向量维度: {doc_embeddings.shape}")
SentenceTransformer:加载本地嵌入模型。all-MiniLM-L6-v2是一个基于 Transformer 的预训练模型,它能将任意长度的文本映射为一个 384 维的稠密向量(Dense Vector)。model.encode(docs):对文档列表进行批量编码,返回一个形状为(文档数量, 维度)的 NumPy 数组。这里 9 篇文档会生成一个(9, 384)的矩阵。- 为什么要转为向量? 因为向量可以方便地计算相似度(如欧氏距离、余弦相似度)。语义相近的句子,其向量在空间中的位置也接近。这就是"语义搜索"的数学基础。
💡 模型选择 :
all-MiniLM-L6-v2速度极快,适合英文。如果你的文档是中文,建议改用BAAI/bge-small-zh-v1.5(512 维),在中文语义相似度上表现更优。只需更换模型路径并调整维度即可。
3.3 创建向量索引(FAISS)
python
import faiss
import numpy as np
dimension = doc_embeddings.shape[1] # 384
index = faiss.IndexFlatL2(dimension) # 使用 L2 距离的平坦索引
index.add(doc_embeddings.astype('float32'))
print(f"向量数据库中的文档数量: {index.ntotal}")
faiss.IndexFlatL2:创建一个基于 L2 欧氏距离的"暴力搜索"索引。它会存储所有向量,并在查询时逐一计算距离,找出最近的 k 个向量。这种索引精确度最高,适合数据量较小(< 10 万条)的场景。index.add(...):将文档向量添加到索引中。FAISS 要求输入为float32类型,所以需转换。- 理解 FAISS 的角色:它就是一个"高速向量搜索引擎",底层用 C++ 实现,支持 GPU 加速和多种近似搜索算法。我们这里只是使用了它最基础的功能。
💡 生产环境建议 :当文档量达到百万级时,可使用
IndexIVFFlat或IndexHNSWFlat等近似索引,以牺牲少量精度换取极快的搜索速度。
3.4 执行相似度检索
python
question = "黑神话悟空的战斗系统有什么特点?"
query_embedding = model.encode([question])[0] # 将问题也转为向量
distances, indices = index.search(
np.array([query_embedding]).astype('float32'),
k=3
)
context = [docs[idx] for idx in indices[0]]
print("\n检索到的相关文档:")
for i, doc in enumerate(context, 1):
print(f"[{i}] {doc}")
- 查询向量化:将用户问题同样使用嵌入模型转为向量,这样就能与文档向量在同一空间下进行距离计算。
index.search:传入查询向量(需要是二维数组,形状(1, 384)),设置k=3,即返回最相似的 3 篇文档。返回的distances是 L2 距离(越小越相似),indices是文档在原始列表中的位置。- 获取原始文本:通过索引找回文档内容,这些内容将作为"上下文"喂给大模型。
💡 为什么用向量相似度而不是关键词匹配?
关键词匹配(如 BM25)只能匹配表面词汇,无法理解"战斗系统"和"招式行云流水"之间的语义关系。嵌入向量通过大规模预训练学到了语义表示,能够跨同义词、不同表述进行匹配,这正是 RAG 超越传统搜索引擎的核心能力。
3.5 构建提示词(Prompt)
python
prompt = f"""根据以下参考信息回答问题,并给出信息源编号。
如果无法从参考信息中找到答案,请说明无法回答。
参考信息:
{chr(10).join(f"[{i+1}] {doc}" for i, doc in enumerate(context))}
问题: {question}
答案:"""
chr(10):换行符的 ASCII 码,等效于\n,这里只是展示一种替代写法。- 提示词结构 :
- 系统指令:要求模型基于参考信息回答,并注明出处。这可以约束模型不瞎编,提高可信度。
- 参考信息:将检索到的 3 段文档以编号列表形式拼接。
- 用户问题:原始问题。
- 答案标记:提示模型从此处开始生成回答。
💡 提示词工程(Prompt Engineering) 是 RAG 的最后一公里。一个好的提示词应该清晰定义任务、提供充足上下文、并设置边界(如"不知道就说不知道")。你可以根据业务需求自由定制,而不必依赖框架的预设模板。
3.6 调用 DeepSeek 大模型生成答案
python
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com/v1"
)
response = client.chat.completions.create(
model="deepseek-chat",
messages=[{
"role": "user",
"content": prompt
}],
max_tokens=1024
)
print(f"\n生成的答案: {response.choices[0].message.content}")
- OpenAI 兼容接口 :DeepSeek 提供的 API 完全兼容 OpenAI 的格式,所以我们直接使用
openai库,只需将base_url指向 DeepSeek 的端点,并传入自己的 API Key。 chat.completions.create:发送聊天请求。这里将整个提示词放入一条用户消息中。模型会基于内部的训练知识和提供的上下文生成答案。max_tokens:限制生成内容的最大长度,防止答案冗长或超出 API 调用限制。- 输出解析 :从返回的 JSON 中提取
response.choices[0].message.content,即纯文本答案。
💡 为什么不用 LangChain 等框架封装?
直接用
openaiSDK 调用大模型,代码完全透明,没有任何隐藏的抽象。你能清晰地看到提示词是如何构造的,参数是如何传递的。这对于理解 RAG 的"生成"环节至关重要。当然,如果你需要管理对话历史、流式输出等复杂功能,框架会带来便利,但核心原理始终不变。
4. 完整代码
将上述步骤整合为一个完整的脚本 rag_from_scratch.py:
python
# rag_from_scratch.py
import os
import numpy as np
import faiss
from dotenv import load_dotenv
from sentence_transformers import SentenceTransformer
from openai import OpenAI
# 1. 准备文档数据
load_dotenv()
docs = [
"黑神话悟空的战斗如同武侠小说活过来一般,当金箍棒与妖魔碰撞时,火星四溅,招式行云流水。悟空可随心切换狂猛或灵动的战斗风格,一棒横扫千军,或是腾挪如蝴蝶戏花。",
"72变神通不只是变化形态,更是开启新世界的钥匙。化身飞鼠可以潜入妖魔巢穴打探军情,变作金鱼能够探索深海遗迹的秘密,每一种变化都是一段独特的冒险。",
"每场BOSS战都是一场惊心动魄的较量。或是与身躯庞大的九头蟒激战于瀑布之巅,或是在雷电交织的云海中与雷公电母比拼法术,招招险象环生。",
"驾着筋斗云翱翔在这片神话世界,瑰丽的场景令人屏息。云雾缭绕的仙山若隐若现,古老的妖兽巢穴中藏着千年宝物,月光下的古寺钟声回荡在山谷。",
"这不是你熟悉的西游记。当悟空踏上寻找身世之谜的旅程,他将遇见各路神仙妖魔。有的是旧识,如同样桀骜不驯的哪吒;有的是劲敌,如手持三尖两刃刀的二郎神。",
"作为齐天大圣,悟空的神通不止于金箍棒。火眼金睛可洞察妖魔真身,一个筋斗便是十万八千里。而这些能力还可以通过收集天外陨铁、悟道石等材料来强化升级。",
"世界的每个角落都藏着故事。你可能在山洞中发现上古大能的遗迹,云端天宫里寻得昔日天兵的宝库,或是在凡间集市偶遇卖人参果的狐妖。",
"故事发生在大唐之前的蛮荒世界,那时天庭还未定鼎三界,各路妖王割据称雄。这是一个神魔混战、群雄逐鹿的动荡年代,也是悟空寻找真相的起点。",
"游戏的音乐如同一首跨越千年的史诗。古琴与管弦交织出战斗的激昂,笛萧与木鱼谱写禅意空灵。而当悟空踏入重要场景时,古风配乐更是让人仿佛穿越回那个神话的年代。"
]
# 2. 设置嵌入模型并生成文档向量
model = SentenceTransformer('./all-MiniLM-L6-v2')
doc_embeddings = model.encode(docs)
print(f"文档向量维度: {doc_embeddings.shape}")
# 3. 创建 FAISS 索引
dimension = doc_embeddings.shape[1]
index = faiss.IndexFlatL2(dimension)
index.add(doc_embeddings.astype('float32'))
print(f"向量数据库中的文档数量: {index.ntotal}")
# 4. 执行相似度检索
question = "黑神话悟空的战斗系统有什么特点?"
query_embedding = model.encode([question])[0]
distances, indices = index.search(
np.array([query_embedding]).astype('float32'),
k=3
)
context = [docs[idx] for idx in indices[0]]
print("\n检索到的相关文档:")
for i, doc in enumerate(context, 1):
print(f"[{i}] {doc}")
# 5. 构建提示词
prompt = f"""根据以下参考信息回答问题,并给出信息源编号。
如果无法从参考信息中找到答案,请说明无法回答。
参考信息:
{chr(10).join(f"[{i+1}] {doc}" for i, doc in enumerate(context))}
问题: {question}
答案:"""
# 6. 调用 DeepSeek 生成答案
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com/v1"
)
response = client.chat.completions.create(
model="deepseek-chat",
messages=[{
"role": "user",
"content": prompt
}],
max_tokens=1024
)
print(f"\n生成的答案: {response.choices[0].message.content}")
运行结果示例:
文档向量维度: (9, 384)
向量数据库中的文档数量: 9
检索到的相关文档:
[1] 黑神话悟空的战斗如同武侠小说活过来一般,当金箍棒与妖魔碰撞时,火星四溅,招式行云流水。悟空可随心切换狂猛或灵动的战斗风格,一棒横扫千军,或是腾挪如蝴蝶戏花。
[2] 作为齐天大圣,悟空的神通不止于金箍棒。火眼金睛可洞察妖魔真身,一个筋斗便是十万八千里。而这些能力还可以通过收集天外陨铁、悟道石等材料来强化升级。
[3] 每场BOSS战都是一场惊心动魄的较量。或是与身躯庞大的九头蟒激战于瀑布之巅,或是在雷电交织的云海中与雷公电母比拼法术,招招险象环生。
生成的答案:
根据参考信息,黑神话悟空的战斗系统有以下几个特点:
1. 招式华丽流畅,类似武侠小说的打斗风格,金箍棒碰撞时火星四溅,招式行云流水。[1]
2. 战斗风格灵活多变,可在狂猛与灵动之间自由切换,既能大范围横扫,也能敏捷腾挪。[1]
3. 包含紧张刺激的BOSS战,面对巨型妖怪或神话角色,战斗惊心动魄、招招险象环生。[3]
4. 角色可通过收集材料强化自身能力,如火眼金睛、筋斗云等神通均可升级。[2]
5. 常见问题与调优
5.1 检索结果不相关怎么办?
- 更换嵌入模型 :若文档主要为中文,请一定使用中文优化模型,如
BAAI/bge-small-zh-v1.5或shibing624/text2vec-base-chinese。 - 调整
k值 :增加k(例如 5 或 6)能召回更多上下文,但可能引入噪声。 - 检查文档分块:如果单篇文档太长,建议先分割成几百字的小块,避免信息被稀释。
5.2 生成的答案出现幻觉(编造事实)怎么办?
- 优化提示词:明确要求"只根据参考信息回答,不得引入外部知识"。
- 降低
temperature:可将 API 调用的temperature设为 0.1,使模型更确定、更忠于输入。 - 增加引用验证:要求模型在答案中标注信息源编号,便于人工核对。
5.3 FAISS 索引如何处理大规模数据?
- 使用
IndexIVFFlat需要先对数据进行 K-Means 聚类,搜索时只比较最近的几个聚类中心,极大提高速度。 - 或者使用
IndexHNSWFlat,构建图结构来加速检索。 - 实际生产中,FAISS 索引可持久化到磁盘:
faiss.write_index(index, "vector.index"),下次直接加载使用,避免重复编码。
5.4 如何使用本地大模型替代 DeepSeek?
只需将 OpenAI 客户端更换为兼容本地模型的服务框架(如 Ollama、vLLM 等)。例如,使用 Ollama 时,可通过 http://localhost:11434/v1 作为 base_url,无需 api_key,模型名改为本地部署的模型即可。
6. 总结
通过这篇文章,我们完全抛弃了 RAG 框架,用最基础的 Python 库搭建了一个端到端的问答系统。这个过程中,你亲手体验了:
- 如何用
SentenceTransformer将文本转为语义向量; - 如何用
FAISS构建向量索引并进行高速相似度搜索; - 如何将检索到的文档片段巧妙地嵌入到提示词中;
- 如何通过
openaiSDK 调用兼容接口的大模型生成答案。
RAG 的本质,不是某个框架的 API,而是"检索 + 增强 + 生成"三个环节的组合。 无论技术栈如何变化,这个逻辑始终不变。希望这篇教程能帮你打下坚实的基础,在未来面对更复杂的 RAG 应用(如多轮对话、混合检索、Agentic RAG)时,能够胸有成竹、游刃有余。
赶紧复制代码跑起来吧!如果你有任何疑问或改进建议,欢迎在评论区与我交流。
参考资源
- Sentence Transformers 官方文档:https://www.sbert.net
- FAISS 官方仓库:https://github.com/facebookresearch/faiss
- DeepSeek API 文档:https://platform.deepseek.com/api-docs
- OpenAI Python SDK:https://github.com/openai/openai-python
7. 使用不同的模型
- 使用 Claude 生成答案
python
# 6. 使用Claude生成答案
from anthropic import Anthropic # pip install anthropic
claude = Anthropic(api_key=os.getenv("CLAUDE_API_KEY"))
response = claude.messages.create(
model="claude-3-5-sonnet-20241022",
messages=[{
"role": "user",
"content": prompt
}],
max_tokens=1024
)
print(f"\n生成的答案: {response.content[0].text}")
- 使用 deepseek 生成答案
python
# 6. 使用DeepSeek生成答案
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com/v1"
)
response = client.chat.completions.create(
model="deepseek-chat",
messages=[{
"role": "user",
"content": prompt
}],
max_tokens=1024
)
print(f"\n生成的答案: {response.choices[0].message.content}")
- 使用 ollama 生成答案
python
# 6. 使用Ollama生成答案
from ollama import chat
response = chat(
model=os.getenv("OLLAMA_MODEL"),
messages=[{
"role": "user",
"content": prompt
}],
)
print(f"\n生成的答案: {response.message.content}")