【学习记录】本地 RAG 问答系统实战:FAISS 检索 + DeepSeek 生成
在前两篇文章中,我们分别实现了 PDF → 向量索引 的构建和仅检索 的语义搜索。本文更进一步,将 FAISS 向量检索 与 DeepSeek 大模型生成 结合,搭建一个完整的本地 RAG(检索增强生成)问答系统。用户输入问题后,系统先从索引中检索相关文档片段,再将片段作为上下文连同问题一起提交给 DeepSeek 模型,生成高质量、可溯源的答案。代码完全独立,可直接运行。
📌 目录
- [RAG 系统概述](#RAG 系统概述)
- 环境准备与依赖
- 核心原理解析
- 3.1 检索模块:FAISS 向量索引
- 3.2 生成模块:DeepSeek 大模型
- 3.3 上下文注入的 Prompt 设计
- 完整代码实现(含详细注释)
- 运行方法与示例
- 注意事项与优化建议
- 总结与扩展
一、RAG 系统概述
RAG(Retrieval-Augmented Generation) 是一种结合信息检索和文本生成的技术架构。其核心流程为:
- 索引阶段:将文档切块、向量化,存入向量数据库(此处为 FAISS)。
- 检索阶段:用户问题向量化,从索引中召回最相关的 top‑k 文本块。
- 生成阶段:将检索到的文本块作为上下文,与用户问题一起送入大语言模型(LLM),生成最终答案。
这样做的好处:
- 知识时效性:模型可以回答私有、最新文档中的内容。
- 可解释性:答案基于具体源文档,可追溯。
- 减少幻觉:提供相关上下文后,LLM 的答案更可靠。
本文实现了一个完整的 RAG 问答系统,使用 FAISS 作为向量检索后端,DeepSeek 作为生成模型。
二、环境准备与依赖
2.1 Python 依赖
bash
pip install llama-index-core llama-index-embeddings-huggingface llama-index-vector-stores-faiss llama-index-llms-deepseek faiss-cpu python-dotenv sentence-transformers
| 库 | 用途 |
|---|---|
llama-index-core |
LlamaIndex 核心,提供索引加载、检索、Prompt 管理 |
llama-index-embeddings-huggingface |
HuggingFace 嵌入模型适配器 |
llama-index-vector-stores-faiss |
FAISS 向量存储适配器 |
llama-index-llms-deepseek |
DeepSeek LLM 适配器 |
faiss-cpu |
FAISS 向量索引库 |
python-dotenv |
从 .env 文件读取环境变量 |
sentence-transformers |
嵌入模型运行依赖 |
2.2 系统依赖
- 已通过上一篇文章的构建脚本生成了 FAISS 索引目录
./storage/faiss_index。 - DeepSeek API Key(从 DeepSeek 开放平台 获取)。
2.3 环境变量
创建 .env 文件:
env
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
代码中通过 load_dotenv() 读取,避免硬编码。
三、核心原理解析
3.1 检索模块:FAISS 向量索引
- 本系统使用的嵌入模型为 BAAI/bge-small-zh-v1.5,输出 512 维向量。
- 索引类型为
faiss.IndexFlatL2(欧氏距离精确检索)。查询时,FAISS 返回top_k个最相似向量的索引及距离。 - LlamaIndex 的
retriever会将索引映射回原始文本节点,并附上相似度分数(score 为负欧氏距离,越大表示越相似)。
3.2 生成模块:DeepSeek 大模型
- 使用 DeepSeek Chat 模型(
deepseek-chat),该模型在代码生成、逻辑推理上表现优异,且价格低廉。 - 通过 LlamaIndex 的
DeepSeek类调用,支持设置temperature(随机性)、max_tokens(输出长度)、timeout。 - 将
Settings.llm设置为该实例,后续complete或chat方法即可使用。
3.3 上下文注入的 Prompt 设计
为了让 LLM 能够基于检索到的信息回答,我们构造了如下 Prompt:
请基于以下从文档中检索到的内容回答用户的问题。如果检索内容不足以回答问题,请说明。
检索到的相关文档片段:
【片段1】(相关度: 0.85)
文本内容...
【片段2】(相关度: 0.72)
...
用户问题:{用户输入}
请给出准确、简洁的回答:
这种设计:
- 明确告知模型回答依据。
- 提供相关度分数(虽然模型不一定直接使用,但可辅助判断)。
- 要求模型在信息不足时承认未知,减少幻觉。
四、完整代码实现(含详细注释)
创建 rag_chat.py,内容如下:
python
import os
import sys
from dotenv import load_dotenv
from llama_index.core import (
StorageContext,
load_index_from_storage,
Settings
)
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.vector_stores.faiss import FaissVectorStore
from llama_index.llms.deepseek import DeepSeek
import faiss
# ==================================================
# 加载环境变量(DeepSeek API Key)
# ==================================================
load_dotenv()
# ==================================================
# 配置
# ==================================================
INDEX_DIR = "./storage/faiss_index" # FAISS 索引目录
EMBED_MODEL = "BAAI/bge-small-zh-v1.5" # 嵌入模型(必须与构建时一致)
TOP_K = 5 # 检索 top-k 片段
# ==================================================
# 初始化 Embedding 模型
# ==================================================
print("加载 Embedding 模型...")
Settings.embed_model = HuggingFaceEmbedding(
model_name=EMBED_MODEL,
device="cpu" # 可改为 "cuda" 加速
)
# ==================================================
# 初始化 DeepSeek LLM 并设置为全局
# ==================================================
print("加载 DeepSeek LLM...")
Settings.llm = DeepSeek(
model="deepseek-chat",
api_key=os.getenv("DEEPSEEK_API_KEY"), # 从环境变量读取
temperature=0.3,
max_tokens=2048,
timeout=60.0,
)
# ==================================================
# 加载 FAISS 索引
# ==================================================
faiss_path = os.path.join(INDEX_DIR, "vector_store.faiss")
if not os.path.exists(faiss_path):
print(f"错误:索引文件不存在 - {faiss_path}")
sys.exit(1)
print("读取 FAISS 索引...")
faiss_index = faiss.read_index(faiss_path)
print(f"FAISS 索引维度:{faiss_index.d}, 向量数量:{faiss_index.ntotal}")
vector_store = FaissVectorStore(faiss_index=faiss_index)
print("加载 StorageContext...")
storage_context = StorageContext.from_defaults(
persist_dir=INDEX_DIR,
vector_store=vector_store
)
print("加载索引...")
try:
index = load_index_from_storage(storage_context)
print("索引加载成功")
except Exception as e:
print(f"索引加载失败:{e}")
sys.exit(1)
doc_count = len(index.docstore.docs) if hasattr(index, 'docstore') else "未知"
print(f"索引中的文档节点数:{doc_count}")
# 创建检索器(只做检索,不生成)
retriever = index.as_retriever(similarity_top_k=TOP_K)
# ==================================================
# 辅助函数
# ==================================================
def format_context(nodes_with_scores, max_chars_per_node=800):
"""
将检索到的节点格式化为 LLM 可读的上下文字符串。
每个片段包含:编号、相关度分数(可选)、文本内容(截断)。
"""
context_parts = []
for idx, node_with_score in enumerate(nodes_with_scores, 1):
node = node_with_score.node
score = node_with_score.score if hasattr(node_with_score, 'score') else 'N/A'
text = node.text[:max_chars_per_node]
if len(node.text) > max_chars_per_node:
text += "..."
context_parts.append(f"【片段{idx}】(相关度: {score:.4f})\n{text}")
return "\n\n".join(context_parts)
def build_prompt(question, context):
"""
构建发送给 LLM 的提示词。
要求模型基于上下文回答问题,若信息不足则说明。
"""
return f"""请基于以下从文档中检索到的内容回答用户的问题。如果检索内容不足以回答问题,请说明。
检索到的相关文档片段:
{context}
用户问题:{question}
请给出准确、简洁的回答:"""
# ==================================================
# 主循环
# ==================================================
print("\n" + "="*60)
print("索引加载成功!现在进行检索 + LLM 生成回答。")
print("输入 'exit' 或 'quit' 退出程序。")
print("="*60)
while True:
query = input("\n请输入问题:").strip()
if query.lower() in ['exit', 'quit', 'q']:
print("退出程序。")
break
if not query:
continue
print("\n检索相关文档片段...")
nodes_with_scores = retriever.retrieve(query)
if not nodes_with_scores:
print("未检索到任何相关片段,无法生成回答。")
continue
context = format_context(nodes_with_scores)
prompt = build_prompt(query, context)
# 可选:打印调试信息(提示词长度及开头)
print(f"\n[调试] 提示词长度:{len(prompt)} 字符")
print(f"[调试] 提示词开头:{prompt[:200].replace(chr(10), ' ')}...")
print("调用 DeepSeek 生成回答...")
try:
response = Settings.llm.complete(prompt)
answer = str(response)
print("\n【LLM 回答】")
print(answer)
# 询问是否显示源片段
show_sources = input("\n是否显示检索到的源片段?(y/n): ").strip().lower()
if show_sources == 'y':
print("\n【检索到的文档片段】")
for idx, node_with_score in enumerate(nodes_with_scores, 1):
score = node_with_score.score if hasattr(node_with_score, 'score') else 'N/A'
node = node_with_score.node
snippet = node.text[:500] + "..." if len(node.text) > 500 else node.text
print(f"\n片段 {idx} (相关度: {score:.4f})")
print(f"文本:\n{snippet}")
if node.metadata:
print(f"元数据: {node.metadata}")
print("-" * 40)
except Exception as e:
print(f"LLM 调用失败:{e}")
import traceback
traceback.print_exc()
print("\n" + "="*60)
五、运行方法与示例
5.1 前提条件
- 已运行过索引构建脚本(前一篇文章),生成了
./storage/faiss_index目录。 .env文件中配置了有效的DEEPSEEK_API_KEY。
5.2 启动问答系统
bash
python rag_chat.py
5.3 交互示例
加载 Embedding 模型...
加载 DeepSeek LLM...
读取 FAISS 索引...
FAISS 索引维度:512, 向量数量:126
加载 StorageContext...
加载索引...
索引加载成功
索引中的文档节点数:126
============================================================
索引加载成功!现在进行检索 + LLM 生成回答。
输入 'exit' 或 'quit' 退出程序。
============================================================
请输入问题:医疗器械风险分类有哪些?
检索相关文档片段...
[调试] 提示词长度:1245 字符
[调试] 提示词开头:请基于以下从文档中检索到的内容回答用户的问题。如果检索内容不足以回答问题,请说明。 检索到的相关文档片段: 【片段1】(相关度: -0.8234) 医疗器械按照风险程度分为三类:第一类是风险较低...
调用 DeepSeek 生成回答...
【LLM 回答】
医疗器械根据风险程度分为三类:第一类是低风险,例如普通医用口罩;第二类是中度风险,例如血压计;第三类是较高风险,例如心脏起搏器。具体分类需参考《医疗器械分类目录》。
是否显示检索到的源片段?(y/n): y
【检索到的文档片段】
...
六、注意事项与优化建议
| 问题 | 说明 | 解决方案 |
|---|---|---|
| API Key 安全 | 代码中不应硬编码 | 使用环境变量(.env 文件) |
| 模型一致性 | 嵌入模型必须与索引构建时相同 | 检查 EMBED_MODEL 配置 |
| 检索质量 | 若检索结果不相关,可能因分块策略不当 | 调整 chunk_size 和 overlap 重新构建索引 |
| LLM 超时 | DeepSeek API 可能响应较慢 | 增加 timeout 参数;或使用异步调用 |
| 上下文长度 | 如果检索到的片段总文本过长,可能超出 LLM 上下文窗口(deepseek-chat 支持 8K token) | 减少 TOP_K 或减小 max_chars_per_node |
| 分数解释 | score 是负的欧氏距离,不是余弦相似度 |
仅用于排序,无需关心绝对值 |
| 性能 | CPU 上运行嵌入模型较慢 | 设置 device="cuda" 使用 GPU 加速(需安装 faiss-gpu 和 torch) |
七、总结与扩展
通过本文,我们构建了一个完整的本地 RAG 问答系统,实现了:
- ✅ 加载预构建的 FAISS 向量索引。
- ✅ 使用中文嵌入模型进行语义检索。
- ✅ 调用 DeepSeek 大模型基于检索结果生成答案。
- ✅ 提供交互式命令行界面,可查看检索源片段。
扩展方向:
- 集成 Streamlit 或 Gradio 构建 Web UI。
- 支持多轮对话(保留历史消息)。
- 使用更高级的检索器(如重排序、混合检索 BM25+向量)。
- 将索引迁移到 Milvus、Qdrant 等向量数据库,支持大规模并发。
现在,你可以将企业内部文档、技术手册、法律法规等资料预处理为索引,然后用本系统进行智能问答,极大地提高信息检索效率。