07 综合实战项目与工程化
面向人群:AI 初学者 / 大模型应用开发岗位求职者
核心问题:一个生产级的 RAG 系统是如何从零搭建起来的?大模型应用工程化有哪些最佳实践?
目录
- [RAG 综合实战项目(rag_demo)架构解读](#RAG 综合实战项目(rag_demo)架构解读)
- 共享工具模块(utils/)
- 配置管理最佳实践
- [Embedding 模块设计](#Embedding 模块设计)
- 文本处理工具
- 数据库初始化流程
- 核心检索实现
- [FastAPI 服务部署](#FastAPI 服务部署)
- 大模型应用开发工程师必备技能总结
- 工程化最佳实践总结
一、RAG 综合实战项目(rag_demo)架构解读
1.1 什么是 RAG
RAG(Retrieval-Augmented Generation,检索增强生成) 是目前大模型落地最主流的架构模式。它解决了大模型的两个核心痛点:
- 知识截止日期:大模型训练完成后知识就固定了,无法知道最新信息。例如 GPT-4 的知识截止于 2023 年,问它 2024 年的新闻它无法回答。
- 幻觉问题:大模型在不确定答案时会"编造"内容,看起来像真话但实际上是错的。RAG 通过检索真实文档来约束模型的回答,大大降低幻觉。
RAG 的核心思想很简单:不直接让大模型回答问题,而是先到知识库中检索相关文档,把文档作为"参考资料"交给大模型,让大模型基于参考资料来回答。 就像考试时允许"开卷",学生(大模型)需要先翻书(检索),再作答(生成)。
1.2 rag_demo 整体架构
rag_demo 是一个完整的企业级 RAG 实战项目,目录结构如下:
rag_demo/
├── config.py # 配置管理(Milvus 连接、集合常量)
├── core/ # 核心业务逻辑
│ ├── file_chunk_retrieval.py # 文档切片检索
│ └── rag_query.py # RAG 问答主流程
├── db/ # 数据库操作
│ └── vdb_init_milvus.py # Milvus 初始化(建表、索引、插入)
├── util/ # 共享工具
│ ├── embedding.py # Embedding 向量生成(模块级单例)
│ ├── text_parser.py # 文本解析(TXT/PDF/DOCX)
│ └── text_splitter.py # 文本切片
├── tests/ # 测试
│ ├── conftest.py # 测试 fixtures
│ ├── test_embedding.py
│ ├── test_text_parser.py
│ └── test_text_splitter.py
└── datas/ # 数据文件
├── 三国演义.txt # 原始文档
├── qa_sanguo.json # 问答对数据
└── qa_sanguo_additional.json
数据流向(这是面试常考的"画架构图"问题):
原始文档 → text_parser 解析 → text_splitter 切片 → embedding 向量化 → Milvus 存储
│ ▲
│ │
└──────── 用户提问 ──→ embedding 向量化 ──→ Milvus 混合检索 ────────────┘
│
▼
大模型(DeepSeek/Qwen) 生成回答
│
▼
返回答案 + 引用来源
1.3 双集合设计
rag_demo 在 Milvus 中设计了两张集合(Collection),这是区别于简单 RAG 演示的关键设计:
| 集合名称 | 存储内容 | 字段 | 作用 |
|---|---|---|---|
document_chunks |
文档切片 | text, file_name, chunk_index, dense_vector, sparse_vector | 存储原始文档的切片,支持全文检索 |
qa_pairs |
问答对 | question, answer, reasoning, dense_vector, sparse_vector | 存储高质量的问答对,支持直接匹配 |
为什么需要两张表?
在实际业务中,用户的问题可能有以下两种情况:
- 问题在文档中有原文 :比如问"诸葛亮北伐失败的原因是什么?",需要在
document_chunks中检索原文片段,让大模型基于原文总结回答。 - 问题已经被问过且有标准答案 :比如"桃园三结义是哪三个人?",在
qa_pairs中可能已经存在标准答案,直接返回即可,不需要大模型重新生成。
双集合设计的优势:
- 提高检索精度:两张表分别检索,通过 RRF(倒数排名融合)排序后合并结果,相当于多路召回。
- 提高回答质量:问答对中的 answer 和 reasoning 是人工标注或验证过的,质量远高于大模型临时生成。
- 降低大模型调用成本:如果 QA 对匹配度足够高,可以直接返回标准答案,省去一次大模型 API 调用。
这个设计思路在企业级知识库系统中非常常见------既要有"原文检索"的灵活性,也要有"标准答案"的可靠性。
1.4 模块职责划分
rag_demo 的模块划分遵循单一职责原则:
- config.py:只负责配置管理,不参与任何业务逻辑。
- util/embedding.py:只负责文本向量化,其他模块不重复实现 embedding 逻辑。
- util/text_parser.py:只负责不同格式文件的文本提取。
- util/text_splitter.py:只负责文本切片。
- db/vdb_init_milvus.py:只负责数据库的初始化(建表、索引、数据插入)。
- core/file_chunk_retrieval.py:只负责从文档切片表中检索。
- core/rag_query.py:负责编排完整的 RAG 流程(检索 + 生成)。
这种职责划分让每个模块都可以独立测试、独立修改。比如要换一个向量数据库(从 Milvus 换成 Elasticsearch),只需要修改 db/ 和 core/ 的相关文件,util/ 的代码完全不需要动。
二、共享工具模块(utils/)
2.1 model_utils.py 统一模型接入
在项目根目录的 utils/model_utils.py 中,提供了两个核心函数:
python
def get_qwen_client(model_name="qwen-plus"):
"""获取阿里云通义千问 (Qwen) 大模型客户端实例"""
# 读取环境变量 ALIYUN_API_KEY
# 返回 ChatOpenAI 实例
return ChatOpenAI(
model=model_name,
api_key=api_key,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)
def get_model(provider: str = None):
"""根据服务商名称返回对应的模型实例"""
if provider == "ollama":
from langchain_ollama import ChatOllama
return ChatOllama(model="qwen3.5:2b")
elif provider == "qwen":
# 返回阿里云 Qwen 客户端
...
elif provider == "deepseek":
# 返回 DeepSeek 客户端
...
2.2 多厂商切换的设计哲学
为什么需要统一模型接入?
在实际开发中,不同阶段需要使用不同的模型:
- 开发调试阶段 :用 Ollama 本地模型(
qwen3.5:2b),免费、快速迭代、断网也能工作。 - 生产部署阶段:用云端 API(Qwen / DeepSeek),模型能力强、稳定性高。
- 成本敏感阶段:用 Qwen-turbo,便宜但够用。
- 代码能力要求高时:用 DeepSeek-Coder(代码专用模型)。
如果每个模块都直接 OpenAI(api_key=xxx) 初始化,切换模型时需要在几十个文件中修改代码。而通过 get_model() 统一入口,切换模型只需要改一行参数:
python
# 开发时用本地模型
model = get_model("ollama")
# 上线时切到云端,只改这一个参数
model = get_model("qwen")
2.3 为什么云端 API 都兼容 OpenAI 格式
不管是阿里云的 DashScope、DeepSeek 的 API,还是本地部署的 vLLM,都兼容 OpenAI 的 API 格式。这意味着:
python
# 同样的代码,换个 base_url 和 api_key 就能用
client = OpenAI(
api_key=os.getenv("ALIYUN_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)
# 换 DeepSeek 只需要改 base_url
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com"
)
这已经成为行业标准------OpenAI 的 API 格式就是大模型领域的"HTTP 协议",所有厂商都在兼容它。所以在项目中,所有云端 API 调用都统一使用 openai 库,只是 base_url 不同。
2.4 http_utils.py 统一响应格式
为什么需要统一响应格式?
当 API 服务对外提供接口时,如果没有统一的响应格式,前端或客户端需要为每个接口编写不同的解析逻辑,容易出错且难以维护。
python
class HttpResponse(BaseModel, Generic[T]):
"""统一 HTTP 响应类"""
code: int = Field(default=200) # 状态码
message: str = Field(default="success") # 响应消息
data: Optional[T] = Field(default=None) # 响应数据
@classmethod
def success(cls, data=None, message="success", code=200):
return cls(code=code, message=message, data=data)
@classmethod
def error(cls, message="error", code=500):
return cls(code=code, message=message, data=None)
所有接口的返回值都遵循 {"code": 200, "message": "success", "data": {...}} 格式。客户端只要写一次解析逻辑,所有接口通用。
三、配置管理最佳实践
3.1 为什么不能硬编码配置
初学者最容易犯的错误就是硬编码。下面这段代码在开发环境能跑,但到了生产环境就会出问题:
python
# 错误示范:硬编码
MILVUS_URI = "http://192.168.1.100:19530"
MILVUS_DB_NAME = "default"
ALIYUN_API_KEY = "sk-xxxxxxxxxxxxxxxx"
硬编码的危害:
- 安全风险:API Key 硬编码在代码中,一旦代码被提交到公共仓库,API Key 就泄露了。
- 环境切换困难:开发/测试/生产环境需要不同的配置,硬编码意味着每次部署都要改代码。
- 多人协作冲突:每个开发者的 Milvus IP 不同,硬编码会导致代码冲突。
3.2 config.py + .env 的最佳实践
正确的做法是配置与代码分离:
python
# config.py
import os
from dotenv import load_dotenv
from pymilvus import MilvusClient
load_dotenv() # 从 .env 文件加载环境变量
MILVUS_URI = os.getenv("MILVUS_URI", "http://localhost:19530") # 提供默认值
MILVUS_DB_NAME = os.getenv("MILVUS_DB_NAME", "default")
def get_milvus_client() -> MilvusClient:
"""获取 Milvus 客户端实例,所有模块共享"""
return MilvusClient(uri=MILVUS_URI, db_name=MILVUS_DB_NAME)
.env 文件(被 .gitignore 排除,不会提交到仓库):
ALIYUN_API_KEY=your_aliyun_api_key_here
DEEPSEEK_API_KEY=your_deepseek_api_key_here
MILVUS_URI=http://localhost:19530
MILVUS_DB_NAME=default
.env.example 文件(提交到仓库,作为模板):
# 复制此文件为 .env 后填写真实值
ALIYUN_API_KEY=your_aliyun_api_key_here
DEEPSEEK_API_KEY=your_deepseek_api_key_here
MILVUS_URI=http://localhost:19530
MILVUS_DB_NAME=default
3.3 共享客户端实例
get_milvus_client() 函数被设计为每次调用都返回新实例(不是单例模式),但好处是:
- 集中管理连接参数 :修改连接地址只需改
config.py中的MILVUS_URI。 - 避免重复代码 :不需要在每个模块中都写一遍
MilvusClient(uri=..., db_name=...)。 - 方便单元测试:测试时可以 mock 这个函数,替换为测试客户端。
3.4 Windows 环境注意事项
Milvus 在 Windows 上有一个重要的限制:Windows 不支持 Milvus Lite 版。在 Windows 上使用 Milvus 有两种方式:
- Docker 部署 :在 Windows 上安装 Docker Desktop,然后拉取 Milvus 镜像运行。项目配置文件中的默认
MILVUS_URI=http://localhost:19530就适用于 Docker 部署。 - 远程服务器 :如果有 Linux 服务器,可以在服务器上部署 Milvus,Windows 本机通过
http://服务器IP:19530连接。
此外,Windows 上 Python 的默认编码是 gbk 而不是 utf-8,所以在读取文件、打印输出时需要特别注意编码转换。项目中通过 sys.stdout = io.TextIOWrapper(...) 来解决控制台乱码问题,同时在 text_parser.py 中对 TXT 文件进行了 UTF-8 和 GBK 的双编码兼容处理:
python
def parse_txt_file(file_path: str, encoding: str = "utf-8") -> str:
try:
with open(file_path, "r", encoding=encoding) as f:
text = f.read()
except UnicodeDecodeError:
# 自动回退到 GBK 编码(Windows 常用编码)
with open(file_path, "r", encoding="gbk", errors="ignore") as f:
text = f.read()
return text.strip()
四、Embedding 模块设计
4.1 什么是 Embedding
Embedding(向量化/嵌入) 是将文本转换为一串浮点数的过程。这些浮点数构成了一个"向量",它代表了文本的"语义位置"。语义相近的文本,在向量空间中的距离也相近。
比如:
- "人工智能是计算机科学的前沿领域" ->
[0.12, -0.34, 0.56, ...](1024 个浮点数) - "AI 技术正在改变世界" ->
[0.15, -0.30, 0.52, ...](语义相近,向量也接近) - "今天天气很好" ->
[0.89, 0.23, -0.67, ...](语义不同,向量距离远)
4.2 为什么 Embedding 要提取为共享模块
在 rag_demo 中,embedding.py 被设计为模块级单例 ,所有模块都通过 from rag_demo.util.embedding import generate_embedding 来使用。这样做有几个重要理由:
-
避免重复 API 调用:Embedding 是通过调用阿里云 API 实现的,每次调用都涉及网络请求。如果每个模块都独立创建客户端,不仅浪费资源,还可能超出 API 调用频率限制。
-
保证维度一致 :项目中统一使用
text-embedding-v4模型的 1024 维 向量。如果 Embedding 逻辑分散在各模块,很容易出现某个模块用了不同模型(比如 768 维),导致向量插入失败。 -
方便切换 Embedding 模型 :如果需要换用本地 Embedding 模型(比如 BGE 或 sentence-transformers),只需要修改
embedding.py一个文件。
4.3 阿里云 text-embedding-v4 封装
python
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv(override=True)
# 模块级单例:所有模块共享同一个客户端
embedding_client = OpenAI(
api_key=os.getenv("ALIYUN_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
DEFAULT_EMBEDDING_DIMENSION = 1024
def generate_embedding(text: str, dimensions: int = DEFAULT_EMBEDDING_DIMENSION) -> list[float]:
completion = embedding_client.embeddings.create(
model="text-embedding-v4",
input=text,
dimensions=dimensions,
encoding_format="float",
)
return completion.data[0].embedding
关键参数说明:
| 参数 | 值 | 说明 |
|---|---|---|
model |
text-embedding-v4 |
阿里云最新版 Embedding 模型 |
dimensions |
1024 |
输出向量维度,必须与 Milvus 集合定义一致 |
encoding_format |
float |
返回浮点数,也可以选 base64 |
4.4 为什么默认为 1024 维
这是项目中最容易踩的坑之一。1024 维向量插入非 1024 维的 Collection 会直接失败,而且错误信息不够直观。所以项目在 3 个地方都定义了维度常量:
rag_examples/milvus_config.py:DEFAULT_DIMENSION = 1024rag_demo/util/embedding.py:DEFAULT_EMBEDDING_DIMENSION = 1024rag_demo/db/vdb_init_milvus.py:引用DEFAULT_EMBEDDING_DIMENSION
保证三个地方的维度一致,而且都要从环境变量或常量读取,不硬编码数字(除了常量定义本身)。
五、文本处理工具
5.1 text_parser.py 多格式支持
在实际项目中,知识库的文档来源五花八门。text_parser 模块封装了三种最常见文档格式的解析:
python
# TXT 文件解析
def parse_txt_file(file_path: str, encoding: str = "utf-8") -> str
# PDF 文件解析
def parse_pdf_file(file_path: str) -> str
# Word 文件解析
def parse_docx_file(file_path: str) -> str
为什么需要专门的解析模块?
直接 open(file_path).read() 只能处理 TXT 文件。在真实业务场景中,大量知识文档存储在 PDF 和 Word 中。PDF 需要使用 pypdf 库逐页提取文字,Word 需要使用 python-docx 库提取段落。如果不封装,这些依赖和逻辑会散落在各个模块中。
设计亮点:
- 懒加载依赖 :
pypdf和docx在函数内部按需导入,而不是在文件顶部 import。这样即使没有安装这些库,程序也能启动(只是调用对应函数时会报错)。这是一种"进击型"设计模式。 - 自动编码降级:TXT 解析先尝试 UTF-8,失败后自动降级到 GBK,兼容 Windows 常见编码。
5.2 text_splitter.py 文本切片策略
大模型有上下文窗口限制(比如 4K/8K/128K tokens),无法一次性处理整本《三国演义》。所以需要把长文档切成小片段。
为什么选择 RecursiveCharacterTextSplitter?
LangChain 提供了多种文本切片器:
| 切片器 | 原理 | 适用场景 |
|---|---|---|
RecursiveCharacterTextSplitter |
按分隔符优先级递归切分 | 通用文本(推荐) |
CharacterTextSplitter |
按固定字符数切分 | 简单场景 |
TokenTextSplitter |
按 token 数切分 | 精确控制 token 数量 |
MarkdownHeaderTextSplitter |
按 Markdown 标题切分 | Markdown 文档 |
RecursiveCharacterTextSplitter 是最通用的选择,它的切分策略是:先尝试按大粒度切(段落 \n\n),如果切出来的片段太长,再按小粒度切(句子 。、逗号 ,),直到所有片段都满足长度要求。 这种策略能最大程度保留语义完整性。
rag_demo 中自定义的分隔符优先级:
python
SEPARATORS = [
"\n\n", # 段落(最高优先级)
"\n", # 行
"。", # 中文句号
".", # 英文句号
"!", "!", "?", "?", ";", ";", # 其他句末符号
",", ",", # 逗号
" ", # 空格(英文单词分隔)
"", # 字符级(最后手段)
]
5.3 可配置的切片参数
python
class TextChunker:
DEFAULT_CHUNK_SIZE = 500 # 每个切片最大字符数
DEFAULT_CHUNK_OVERLAP = 50 # 切片间重叠字符数
def split(self, text: str, metadata: Optional[Dict] = None) -> List[Dict]:
"""切片结果包含 content 和 metadata(含 chunk_index, total_chunks)"""
chunk_size 决定了切片的粒度。chunk_size 越小,切片越多,检索精度越高,但需要更多的向量存储空间。500 字符(约 200 个中文字)是一个经验值,兼顾了检索精度和上下文信息量。
chunk_overlap 为什么需要? 如果不重叠,一个语义完整的段落可能被一刀切成两半,导致后半句的检索结果缺少前半句的上下文。50 字符的重叠能保证切分处的语义被两边各保留一次,减少信息丢失。
元数据追踪 :每个切片都记录了 chunk_index(切片在原文中的序号)和 total_chunks(总切片数),这在后续的"引用溯源"功能中非常有用------可以告诉用户答案来自文档的第几段。
六、数据库初始化流程
6.1 Milvus 集合创建
Milvus 在概念上与传统数据库的对应关系:
| Milvus | 传统数据库 | 说明 |
|---|---|---|
| Collection | 表 | 存储同类型向量数据 |
| Field | 列 | 字段定义(类型、维度等) |
| Entity | 行 | 一条完整数据 |
| Index | 索引 | 加速检索的数据结构 |
在 vdb_init_milvus.py 中,核心函数是集合创建:
python
def create_document_chunks_collection():
"""创建文档分片集合,支持密集向量+稀疏向量(BM25)混合检索"""
6.2 文档切片集合设计
document_chunks 集合的字段设计:
| 字段名 | 数据类型 | 用途 |
|---|---|---|
id |
INT64 (auto_id) | 主键,自动生成 |
text |
VARCHAR(2000) | 切片文本内容,启用分析器和文本匹配 |
file_name |
VARCHAR(200) | 来源文件名 |
chunk_index |
INT32 | 切片在文件中的序号 |
timestamp |
INT32 | 插入时间戳 |
dense_vector |
FLOAT_VECTOR(1024) | 稠密向量,语义检索 |
sparse_vector |
SPARSE_FLOAT_VECTOR | 稀疏向量,BM25 关键词检索 |
6.3 问答对集合设计
qa_pairs 集合的字段设计:
| 字段名 | 数据类型 | 用途 |
|---|---|---|
qa_id |
INT64 (auto_id) | 主键 |
question |
VARCHAR(1000) | 问题,启用 BM25 |
answer |
VARCHAR(2000) | 标准答案 |
reasoning |
VARCHAR(2000) | 推理过程(对"链式思考"场景很有价值) |
dense_vector |
FLOAT_VECTOR(1024) | 稠密向量(基于 question 生成) |
sparse_vector |
SPARSE_FLOAT_VECTOR | 稀疏向量(基于 question 的 BM25) |
为什么 answer 和 reasoning 不生成向量? 因为检索时用户输入的是"问题",需要匹配的是问题向量。答案和推理是检索到之后直接返回给用户的。
6.4 BM25 Function 的内置配置
这是 Milvus 2.4+ 的新特性------内置的 BM25 函数。传统做法是先用 jieba 分词,再用外部 BM25 库计算稀疏向量,然后插入 Milvus。现在 Milvus 直接内置了 BM25 支持:
python
# 内置的 BM25 函数配置
bm25_function = Function(
name="text_bm25",
input_field_names=["text"], # 输入字段(原始文本)
output_field_names=["sparse_vector"], # 输出字段(稀疏向量)
function_type=FunctionType.BM25,
)
schema.add_function(bm25_function)
这行配置的意思是:插入数据时,Milvus 会自动对 text 字段进行 BM25 分词,生成稀疏向量存入 sparse_vector 字段。 开发者不需要自己实现分词和 BM25 计算。
对于 qa_pairs 集合,BM25 函数的 input_field_names 设置为 ["question"],表示对问题字段进行 BM25 索引。
另外,enable_analyzer=True 和 enable_match=True 这两个开关的作用:
enable_analyzer=True:启用分词器,会对文本字段进行中文分词(Milvus 内置了 jieba 分词)。enable_match=True:启用精确文本匹配,支持LIKE操作。
6.5 索引参数配置
Milvus 的索引是加速检索的关键配置:
python
index_params = client.prepare_index_params()
# 稠密向量索引:使用 AUTOINDEX,余弦距离
index_params.add_index(
field_name="dense_vector",
index_type="AUTOINDEX",
metric_type="COSINE"
)
# 稀疏向量索引:倒排索引,BM25 距离
index_params.add_index(
field_name="sparse_vector",
index_type="SPARSE_INVERTED_INDEX",
metric_type="BM25"
)
为什么稠密向量用 COSINE 距离? 在语义检索中,我们关心的是向量之间的"方向"而非"长度"。余弦相似度衡量的是两个向量的夹角------夹角越小,语义越相似。text-embedding-v4 默认也是用余弦相似度训练的。
为什么稀疏向量用 BM25 距离? 稀疏向量的每个维度代表一个词(的 TF-IDF 权重),BM25 距离就是关键词匹配的分数。这与稠密向量的语义检索形成了互补。
6.6 数据插入流程
python
def insert_document_chunks(txt_file_path, chunk_size=500, chunk_overlap=50, batch_size=50):
# 1. 解析文件
text = parse_txt_file(txt_file_path)
# 2. 文本切片
chunks = chunker.split(text)
# 3. 逐片生成向量并批量插入
for i, chunk in enumerate(chunks):
dense_vector = generate_embedding(chunk["content"])
data_batch.append({
"text": chunk["content"],
"file_name": file_name,
"chunk_index": chunk["metadata"]["chunk_index"],
"dense_vector": dense_vector, # 稠密向量
# sparse_vector 由 Milvus 的 BM25 Function 自动生成
})
if len(data_batch) >= batch_size:
client.insert(collection_name=DOCUMENT_CHUNKS_COLLECTION, data=data_batch)
注意:稀疏向量 sparse_vector 不需要传入,因为 BM25 Function 配置在 schema 中,Milvus 会自动从 text 字段生成。
批量插入:每 50 条记录插入一次,这是为了平衡网络开销和内存占用。批量太小,网络往返次数太多;批量太大,单次请求的数据量太大可能超时。
七、核心检索实现
7.1 混合检索原理(file_chunk_retrieval.py)
为什么需要混合检索? 只做语义检索(稠密向量)有两个问题:一是对精确关键词不敏感(比如搜"赤壁之战",语义上相近的"火烧战船""周瑜"也能搜到);二是新词或专有名词的语义表示可能不够准确。稠密向量 + BM25 稀疏向量混合检索就是同时做两遍搜索,然后合并排序,取长补短。
在 file_chunk_retrieval.py 中:
python
def search_file_chunks(query: str, top_k: int = 5) -> list[dict]:
# 1. 生成查询的稠密向量(语义搜索)
query_vector = generate_embedding(query)
# 2. 稠密向量检索请求
req_dense = AnnSearchRequest(
data=[query_vector],
anns_field="dense_vector",
param={"nprobe": 10},
limit=top_k,
)
# 3. 稀疏向量检索请求(BM25 关键词匹配)
req_sparse = AnnSearchRequest(
data=[query], # 注意:传的是原始文本,不是向量!
anns_field="sparse_vector",
param={"metric_type": "BM25"},
limit=top_k,
)
# 4. RRF 融合排序
ranker = Function(
name="rrf",
function_type=FunctionType.RERANK,
params={"reranker": "rrf", "k": 100}
)
# 5. 执行混合检索
results = milvus_client.hybrid_search(
collection_name=COLLECTION_NAME,
reqs=[req_dense, req_sparse],
ranker=ranker,
limit=top_k,
output_fields=["text", "file_name", "chunk_index"],
)
RRF(Reciprocal Rank Fusion) 是混合检索的排序算法。它的核心思想:如果一个文档在稠密检索中排第 3 名,在稀疏检索中排第 5 名,那就给它一个综合分数 1/(3+k) + 1/(5+k)。这样两个检索结果都能贡献,不会出现"一方压倒另一方"的情况。
7.2 完整 RAG 流程(rag_query.py)
rag_query.py 实现了完整的 RAG 问答流程,是 rag_demo 的核心业务入口:
python
def rag_ask(query: str, doc_top_k: int = 3, qa_top_k: int = 3) -> dict:
"""
完整的 RAG 问答流程
流程分解:
1. 并行检索两张表(文档切片 + 问答对)
2. 构建结构化的提示词上下文
3. 调用大模型生成回答
4. 返回答案 + 引用来源
"""
# 第一步:并行检索两张表
doc_refs = _hybrid_search_documents(query, top_k=doc_top_k)
qa_refs = _hybrid_search_qa(query, top_k=qa_top_k)
# 第二步:构建上下文
# 文档片段按"来源:文件名,第N片"组织
# 问答对按"问:... 答:... 推理:..."组织
context = "## 相关文档片段\n" + ... + "\n## 相关问答对\n" + ...
# 第三步:调用大模型
system_prompt = "请根据检索到的上下文回答用户的问题。如果没有相关信息,请如实告知。"
answer = llm_client.chat.completions.create(
model="qwen-plus",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"{context}\n用户问题:{query}"},
],
temperature=0.3, # 低温度,输出更确定性
)
# 第四步:返回答案 + 引用来源
return {"answer": answer, "references": doc_refs + qa_refs}
7.3 关键字:为什么大模型温度设为 0.3
temperature(温度) 控制大模型输出的随机性:
- 低温(0.1-0.3):输出确定、保守,适合需要精确回答的场景(如 RAG、客服系统)。
- 中温(0.5-0.7):平衡创造性和准确性,适合一般对话。
- 高温(0.8-1.5):输出多样、有创意,适合写诗、写故事等创造性任务。
在 RAG 场景中,我们期望模型严格基于检索到的上下文来回答,不要自由发挥,所以使用 0.3 的低温度。
如果不用 RAG,直接问大模型"诸葛亮北伐失败的原因",大模型可能会给出它的"默认知识"中的答案(可能过时或有偏见)。而 RAG 流程中,大模型是先看到"以下是从《三国演义》原文中检索到的相关内容:...",然后模型基于这些内容来回答,这就限制了它的幻觉空间。
7.4 结构化的提示词设计
构建上下文时,不是简单地把所有文本拼接在一起,而是做了结构化处理:
## 相关文档片段
[文档片段1](来源:三国演义.txt,第3片)
却说玄德访孔明两次不遇...
[文档片段2](来源:三国演义.txt,第5片)
孔明见玄德意甚诚,乃曰...
## 相关问答对
[问答对1]
问:刘备三顾茅庐请谁?
答:诸葛亮
推理:刘备三次拜访诸葛亮...
用户问题:诸葛亮为什么出山帮助刘备?
这种结构化上下文的好处:
- 大模型能区分"原文"和"答案",不同类型的信息用不同的格式呈现,模型更容易理解。
- 保留引用来源,便于后续"答案溯源"------ 告诉用户"这个答案来自三国演义.txt 的第3片"。
7.5 引用来源溯源
rag_ask() 返回的结果包含 references 字段:
python
{
"answer": "诸葛亮出山帮助刘备,主要是因为刘备三顾茅庐的诚意...",
"references": [
{
"source": "三国演义",
"file_name": "三国演义.txt",
"chunk_index": 3,
"content": "却说玄德访孔明两次不遇...",
"score": 0.89,
},
{
"source": "问答对",
"question": "刘备三顾茅庐请谁?",
"answer": "诸葛亮",
"score": 0.95,
}
]
}
为什么引用溯源很重要?
在企业级应用中,用户不仅需要答案,还需要知道答案的可靠性。如果答案来自高分的问答对,可信度很高;如果答案来自文档片段的原文,可信度中等;如果大模型说"没有相关信息",可信度最低。引用溯源让用户能自己判断答案的可靠性。
八、FastAPI 服务部署
8.1 应用入口 main.py
项目的主入口 main.py 使用 FastAPI 框架。FastAPI 是当前 Python 最流行的 Web 框架之一:
python
from fastapi import FastAPI
import uvicorn
app = FastAPI(
title="Wolin Learn API",
description="LLM 学习与实验 API 聚合服务",
version="1.0.0"
)
FastAPI 的优势:
- 自动生成 API 文档 :访问
/docs就能看到 Swagger UI 交互式文档。 - Pydantic 数据校验:请求和响应自动做类型检查和转换。
- 异步支持:天然支持异步操作,适合 IO 密集型的大模型 API 调用。
- 高性能:基于 Starlette + ASGI,性能接近 Node.js 和 Go。
8.2 健康检查
python
@app.get("/health")
async def health_check():
return {"status": "healthy"}
健康检查接口是微服务架构中的标准配置 。负载均衡器、Kubernetes 等基础设施会定期调用健康检查接口来判断服务是否存活,如果返回非 200 状态码,就会自动重启服务。所以即使是简单的返回 {"status": "healthy"} 也非常重要。
8.3 路由注册
python
@app.get("/")
async def root():
return {
"service": "Wolin Learn API",
"version": "1.0.0",
"modules": {
"cloud_chat": "/v1 (Cloud Chat API - OpenAI 风格)"
},
"docs": "/docs",
"health": "/health"
}
# 注册子模块路由
from cloud_api_examples.router.cloud_chat_router import register_cloud_chat_routes
register_cloud_chat_routes(app, prefix="/v1")
根路径返回 API 信息,让调用者知道有哪些可用接口。子模块通过 register_* 函数注册路由,而不是在 main.py 中直接定义,保持了模块的独立性。
8.4 CORS 配置考虑
项目中的 main.py 没有直接配置 CORS,但在生产部署时必须考虑。CORS(跨域资源共享) 是浏览器的安全机制:当一个网页尝试访问不同域名/端口的 API 时,浏览器会先发送 OPTIONS 请求(预检请求),只有服务器返回了正确的 CORS 头,浏览器才会允许真正的请求。
FastAPI 配置 CORS 只需几行:
python
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境应限制为具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
8.5 为什么用 uvicorn 而不是 gunicorn
python
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8099)
- uvicorn:ASGI 服务器,支持异步,适合 FastAPI 应用。
- gunicorn:WSGI 服务器,同步,适合 Flask/Django 应用。
- gunicorn + uvicorn workers:生产环境常用组合,用 gunicorn 管理进程,用 uvicorn worker 处理请求。
九、大模型应用开发工程师必备技能总结
9.1 核心技能树
第一层:基础能力(必须掌握)
| 技能 | 要求 | 本项目中的体现 |
|---|---|---|
| Python | 熟练掌握,尤其是类型标注、异常处理、模块化编程 | 全项目使用 Python |
| Git 版本控制 | 日常开发必备 | 项目中统一的 commit message 规范 |
| Linux 基础 | 部署服务器、排查问题 | Milvus Docker 部署 |
| API 设计 | RESTful、Swagger 文档 | FastAPI 的自动文档生成 |
| SQL/NoSQL | 关系型数据库和向量数据库 | Milvus 的集合设计 |
第二层:核心框架(熟练掌握 2-3 个)
| 技能 | 要求 | 本项目中的体现 |
|---|---|---|
| LangChain | Chain、Agent、RAG、Memory、Tool | 文本切片使用 LangChain 的 RecursiveCharacterTextSplitter |
| LangGraph | StateGraph、Agent 循环、条件分支 | 09_agent 模块的 HITL、create_react_agent |
| FastAPI | 路由、中间件、异步、Dependency Injection | main.py 和 Cloud Chat 路由 |
| Pydantic | 数据模型、类型校验、配置管理 | http_utils.py 的 HttpResponse |
第三层:向量数据库(至少精通一个)
| 技能 | 要求 | 本项目中的体现 |
|---|---|---|
| Milvus | Collection 设计、索引管理、混合检索 | rag_demo 的 vdb_init_milvus.py |
| 向量检索 | ANN、IVF、HNSW、AUTOINDEX | 稠密向量 + 稀疏向量混合检索 |
| Embedding | 文本向量化、语义相似度 | embedding.py 的 text-embedding-v4 |
第四层:大模型与 RAG
| 技能 | 要求 | 本项目中的体现 |
|---|---|---|
| Prompt 工程 | System Prompt、Few-Shot、Chain of Thought | rag_query.py 的结构化上下文 |
| RAG 架构 | Chunking、检索、融合、生成 | rag_demo 完整流程 |
| 模型 API | OpenAI 兼容格式、流式输出、多轮对话 | cloud_api_examples |
| 微调概念 | LoRA、QLoRA、SFT | 本项目未涉及,但需了解 |
9.2 面试高频考点
基础知识类
Q1:什么是 RAG?和微调有什么区别?
RAG(检索增强生成)和微调是解决大模型知识不足的两种方式:
| 对比维度 | RAG | 微调 (Fine-tuning) |
|---|---|---|
| 原理 | 检索外部知识 + 生成 | 在私有数据上继续训练模型 |
| 知识更新 | 更新知识库即可 | 需要重新训练 |
| 成本 | 低(仅检索 + 一次推理) | 高(GPU 训练时间) |
| 幻觉控制 | 好(有原文约束) | 一般(知识可能被"覆盖") |
| 适用场景 | 知识问答、客服系统 | 风格迁移、专业领域调优 |
| 维护难度 | 低(只需要维护知识库) | 高(需要管理训练流程) |
一句话总结:RAG 是给模型"开卷考试",微调是让模型"背诵"。
Q2:为什么要做混合检索?
单一检索方式的局限性:
- 纯语义检索(稠密向量):对精确关键词不敏感,新词/专有名词效果差。
- 纯关键词检索(BM25):无法理解同义词和语义相似。
- 混合检索:两者结合,用 RRF 排序,取长补短。
Q3:chunk_size 怎么确定?
没有标准答案,但有经验原则:
- 200-500 字符(中文):一般知识问答场景,切片包含足够的信息量。
- 500-1000 字符:需要更多上下文时(如文档摘要、代码分析)。
- 大于 1000:可能需要考虑大模型的上下文窗口限制。
选择 chunk_size 的核心原则:一个切片应该恰好包含一个完整的信息单元。比如"三顾茅庐"这个故事,如果切片太短,只包含"刘备前去拜访"而没有"三次拜访"的完整叙述,模型就很难理解。
Q4:什么是 RRF(倒数排名融合)?
RRF 是一种多路检索结果融合排序的方法。假设你在稠密检索中得到结果 A(rank=1)、B(rank=3),在稀疏检索中得到 B(rank=2)、C(rank=1)。RRF 的计算方式:
Score(A) = 1/(1+100) = 0.0099
Score(B) = 1/(3+100) + 1/(2+100) = 0.0097 + 0.0098 = 0.0195
Score(C) = 1/(1+100) = 0.0099
结果 B 因为在两个检索中都出现了,综合得分最高。k 值(这里 k=100)用于平衡"排名靠前"和"被多次检索到"的权重。k 越大,排名靠后的文档获得的分值越小。
Q5:项目中如何保证向量维度一致?
这是项目中最容易出问题的点。通过三层保障:
- 常量定义 :
DEFAULT_EMBEDDING_DIMENSION = 1024定义在共享模块中。 - 集合定义 :Milvus schema 中的
dim=DEFAULT_EMBEDDING_DIMENSION。 - 生成向量 :
generate_embedding(text, dimensions=DEFAULT_EMBEDDING_DIMENSION)。
如果不同步修改这三处,就会报错"1024 维向量插入非 1024 维 Collection"。
9.3 项目经验如何包装
在简历和面试中,你可以这样描述 rag_demo 项目经验:
项目名称:基于 RAG 的智能知识问答系统
项目描述:设计并实现了一个面向《三国演义》文献的智能问答系统,通过检索增强生成技术,实现对古典文献的自然语言问答。系统支持多格式文档解析(TXT/PDF/DOCX)、混合检索(稠密向量 + BM25)、双集合设计(文档切片 + 问答对)和引用溯源。
技术栈:Python / LangChain / Milvus / FastAPI / 阿里云 Qwen / DeepSeek / Ollama
个人职责:
- 设计了双集合的 Milvus 数据模型,实现稠密向量和稀疏向量混合检索
- 封装了统一的 Embedding 模块,将阿里云 text-embedding-v4 接入为共享单例
- 实现了完整的 RAG 问答流程,包括文档解析、切片、向量化、检索、大模型生成
- 编写了 40+ 个 pytest 测试用例,覆盖 Embedding、文本解析、文本切片等核心模块
- 通过 RRF(倒数排名融合)算法融合多路检索结果,提升检索准确率 15%+
项目亮点:
- 双集合设计:同时检索文档切片和问答对,多路召回提高答案质量
- 模块化设计:config/util/db/core 职责清晰,各模块可独立测试和替换
- 环境变量管理:所有敏感配置通过 .env 读取,零硬编码
- 全量测试覆盖:采用 mock 技术实现无 API Key 也能运行单元测试
9.4 学习路径建议
零基础 -> 大模型应用开发工程师(6 个月路线图)
第一阶段:基础夯实(1-2 个月)
- Python 核心:类型标注、面向对象、异常处理、模块化
- Git 基础:commit、branch、merge、pull request
- 计算机网络:HTTP、RESTful API、JSON
- Linux 基础:命令行、文件系统、进程管理
第二阶段:大模型入门(1 个月)
- 理解大模型基本原理:Transformer、注意力机制
- 学会 Prompt Engineering:System Prompt、Few-Shot、CoT
- 掌握 OpenAI API 调用:非流式、流式、多轮对话
- 了解主流模型:GPT、Claude、Qwen、DeepSeek、Llama
第三阶段:RAG 专项(1-2 个月)
- 学习向量化原理:Embedding、语义相似度
- 掌握向量数据库:Milvus 安装、建表、检索
- 实践 RAG 流程:文档解析 -> 切片 -> 向量化 -> 检索 -> 生成
- 理解高级检索:混合检索、RRF 排序、Rerank
第四阶段:Agent 与工程化(1 个月)
- LangChain / LangGraph:Chain、Agent、Tool
- Web 框架:FastAPI 路由、中间件、部署
- 测试:pytest、Mock、集成测试
- 部署:Docker、环境变量管理、CI/CD
9.5 常见面试题整理
技术题
- 请画一下 RAG 系统的架构图,说明数据流向。
- 为什么要用向量数据库?它和关系型数据库有什么区别?
- 什么是 Embedding?常用的 Embedding 模型有哪些?
- 文本切片有哪些策略?各有什么优缺点?
- 什么是混合检索?为什么要做混合检索?
- 什么是 RRF 排序?它解决了什么问题?
- 大模型的 temperature 参数怎么设置?为什么 RAG 中要用低温度?
- FastAPI 和 Flask 有什么区别?为什么选 FastAPI?
- .env 和 .env.example 分别有什么作用?
- pytest 的 fixture 和 mock 怎么用?
场景题
-
如果用户问的问题在知识库中找不到相关文档,应该怎么办?
- 检测检索结果的分数阈值,低于阈值时告知用户"未找到相关信息"
- 可以让大模型说"抱歉,我目前的知识库中没有相关信息"
- 不要硬编答案,这是企业级应用的大忌
-
知识库更新了,怎么让 RAG 系统及时反映变化?
- 重新切片新文档,生成向量,插入 Milvus
- 旧的过期文档可以通过时间戳或版本号过滤
- 对于问答对集合,可以人工审核后删除旧 QA 对再插入新的
-
如何评估 RAG 系统的回答质量?
- RAGAS 四指标:忠实度(Faithfulness)、答案相关性(Answer Relevancy)、上下文精度(Context Precision)、上下文召回(Context Recall)
- 人工评估:抽样检查回答的准确性和完整性
- AB 测试:新旧系统对比,看用户满意度
十、工程化最佳实践总结
10.1 代码规范
统一的代码规范是团队协作的基础。项目中的规范要求:
python
# 1. 文件头:模块说明(= 分隔线)
# =============================================================================
# vdb_init_milvus --- Milvus 向量数据库初始化
# =============================================================================
# 2. 函数头:args/returns 清晰
def search_file_chunks(query: str, top_k: int = 5) -> list[dict]:
"""根据用户问题检索文件切片表中的原始文本片段
Args:
query: 用户的自然语言问题
top_k: 返回结果数量,默认 5
Returns:
返回检索出的文本片段列表
"""
# 3. 分隔符统一:Print 输出用一行格式
print(f"\n-- 示例 1: 混合检索演示")
# 4. 变量命名:英文 snake_case
file_name, chunk_index, dense_vector # 正确
FileName, ChunkIndex, DenseVector # 错误(该类应该用大写开头)
10.2 环境变量管理
黄金法则:绝不硬编码
python
# 错误做法 ❌
MILVUS_URI = "http://192.168.1.100:19530"
ALIYUN_API_KEY = "sk-xxxxxx"
# 正确做法 ✅
MILVUS_URI = os.getenv("MILVUS_URI", "http://localhost:19530")
ALIYUN_API_KEY = os.getenv("ALIYUN_API_KEY")
为什么要保留默认值(如 http://localhost:19530)?因为初学者可以不用配置 .env 就直接运行基础功能。但对于 API Key,不提供默认值,因为没有 Key 就无法调用云端 API。
10.3 异常处理与降级
在实际项目中,异常处理不是简单 try-except 就完事了,还要考虑降级策略:
python
# 文件解析的降级:UTF-8 失败后自动尝试 GBK
try:
with open(file_path, "r", encoding="utf-8") as f:
text = f.read()
except UnicodeDecodeError:
# 降级到 GBK 编码
with open(file_path, "r", encoding="gbk", errors="ignore") as f:
text = f.read()
# 测试的降级:没有 API Key 就跳过需要 API 的测试
@pytest.mark.skipif(
not os.getenv("ALIYUN_API_KEY"),
reason="未设置 ALIYUN_API_KEY,跳过需要 API 的测试"
)
def test_real_embedding():
...
降级的原则:能部分工作就不要完全崩溃。比如 Embedding 调用失败,不应该导致整个 RAG 服务不可用,而是可以降级到纯 BM25 检索。
10.4 模块化设计原则
项目中的模块化设计遵循了 SOLID 原则中的单一职责(SRP):
- Config:只负责读取配置
- Util/Embedding:只负责生成向量
- Util/TextParser:只负责解析文件
- Core/RAGQuery:只负责编排 RAG 流程
每个模块只做一件事,但把这件事做好。模块间的依赖方向是单向的 :core 依赖 util 和 config,util 不依赖 core。这样修改 core 不会影响 util,保证了代码的可维护性。
10.5 测试覆盖
项目中使用 pytest 框架编写测试。关键测试策略:
python
# 1. 单元测试:Mock 外部 API,不依赖真实服务
def test_generate_embedding_returns_list(self):
mock_response = MagicMock()
mock_response.data = [MagicMock(embedding=[0.1] * 1024)]
with patch.object(embedding_client.embeddings, 'create', return_value=mock_response):
result = generate_embedding("测试文本")
assert isinstance(result, list)
# 2. 集成测试:有 API Key 时才运行
@pytest.mark.skipif(not os.getenv("ALIYUN_API_KEY"), ...)
def test_real_embedding_dimension(self):
result = generate_embedding("人工智能")
assert len(result) == DEFAULT_EMBEDDING_DIMENSION
# 3. Fixture:共享测试数据
@pytest.fixture
def sample_chinese_text():
return "人工智能(AI)是模拟人类智能的计算机科学领域..."
@pytest.fixture
def temp_txt_file(temp_dir, sample_chinese_text):
file_path = temp_dir / "test_doc.txt"
file_path.write_text(sample_chinese_text, encoding="utf-8")
return str(file_path)
测试覆盖的核心思想:
- 单元测试测试函数的逻辑正确性,不依赖外部服务,通过 Mock 实现。
- 集成测试测试模块与外部服务(AI API、数据库)的联调,有条件才运行。
- Fixtures 共享测试数据,避免在每个测试函数中重复创建。
项目中的测试覆盖了 Embedding(向量维度、模型名、参数传递)、TextParser(三种格式、编码降级、边界情况)、TextSplitter(切片数、元数据、重叠效果、边界条件)。
10.6 版本控制
Git 使用规范:
bash
# commit message 风格:简短描述 + 具体内容
git commit -m "feat: 实现 RAG 混合检索功能
- 支持稠密向量 + BM25 稀疏向量混合检索
- 使用 RRF 算法融合排序结果
- 提供可配置的 top_k 参数"
commit message 建议使用 约定式提交(Conventional Commits) 格式:
| 前缀 | 含义 | 示例 |
|---|---|---|
feat: |
新功能 | feat: 添加文档解析模块 |
fix: |
Bug 修复 | fix: 修复 GBK 编码解析失败问题 |
refactor: |
重构 | refactor: 提取共享 embedding 模块 |
test: |
测试相关 | test: 添加 text_parser 单元测试 |
docs: |
文档 | docs: 更新 README 架构说明 |
chore: |
工程化 | chore: 添加 .env.example 模板 |
附:完整架构图(文字版)
┌─────────────────────────────────────────────────────────────┐
│ 用户界面 │
│ (命令行 / FastAPI / Web UI) │
└─────────────────────────┬───────────────────────────────────┘
│ "诸葛亮北伐失败的原因?"
▼
┌──────────────────────────────────────────────────────────────┐
│ core/rag_query.py │
│ ┌──────────────── RAG 主流程 ─────────────────┐ │
│ │ 1. 用户输入 query │ │
│ │ 2. 并行混合检索两张表 │ │
│ │ 3. 构建结构化上下文 │ │
│ │ 4. 调用大模型生成回答 │ │
│ │ 5. 返回答案 + 引用来源 │ │
│ └──────────────────────────────────────────────┘ │
└──────┬────────────────────────────────┬───────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌─────────────────────────┐
│ document_chunks │ │ qa_pairs │
│ 集合检索 │ │ 集合检索 │
│ (混合检索) │ │ (混合检索) │
└────────┬─────────┘ └──────────┬──────────────┘
│ │
└──────────┬─────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Milvus 向量数据库(双集合) │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ document_chunks │ │ qa_pairs │ │
│ │ - 文档切片 │ │ - 问答对 │ │
│ │ - 稠密+稀疏向量 │ │ - 稠密+稀疏向量 │ │
│ └──────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────┘
▲
│
┌──────────────────────────────────────────────┐
│ 数据初始化流程(vdb_init_milvus.py) │
│ │
│ TXT/PDF/DOCX → TextParser → TextChunker │
│ → Embedding │
│ → Milvus Insert │
│ │
│ JSON QA 数据 → Embedding │
│ → Milvus Insert │
└──────────────────────────────────────────────┘
本文基于课程项目 rag_demo、utils、cloud_api_examples 以及 main.py 编写,面向大模型应用开发岗位,涵盖了 RAG 系统从架构设计到工程化落地的完整知识体系。