07_综合实战项目与工程化

07 综合实战项目与工程化

面向人群:AI 初学者 / 大模型应用开发岗位求职者

核心问题:一个生产级的 RAG 系统是如何从零搭建起来的?大模型应用工程化有哪些最佳实践?


目录

  1. [RAG 综合实战项目(rag_demo)架构解读](#RAG 综合实战项目(rag_demo)架构解读)
  2. 共享工具模块(utils/)
  3. 配置管理最佳实践
  4. [Embedding 模块设计](#Embedding 模块设计)
  5. 文本处理工具
  6. 数据库初始化流程
  7. 核心检索实现
  8. [FastAPI 服务部署](#FastAPI 服务部署)
  9. 大模型应用开发工程师必备技能总结
  10. 工程化最佳实践总结

一、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 存储高质量的问答对,支持直接匹配

为什么需要两张表?

在实际业务中,用户的问题可能有以下两种情况:

  1. 问题在文档中有原文 :比如问"诸葛亮北伐失败的原因是什么?",需要在 document_chunks 中检索原文片段,让大模型基于原文总结回答。
  2. 问题已经被问过且有标准答案 :比如"桃园三结义是哪三个人?",在 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 有两种方式:

  1. Docker 部署 :在 Windows 上安装 Docker Desktop,然后拉取 Milvus 镜像运行。项目配置文件中的默认 MILVUS_URI=http://localhost:19530 就适用于 Docker 部署。
  2. 远程服务器 :如果有 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 来使用。这样做有几个重要理由:

  1. 避免重复 API 调用:Embedding 是通过调用阿里云 API 实现的,每次调用都涉及网络请求。如果每个模块都独立创建客户端,不仅浪费资源,还可能超出 API 调用频率限制。

  2. 保证维度一致 :项目中统一使用 text-embedding-v4 模型的 1024 维 向量。如果 Embedding 逻辑分散在各模块,很容易出现某个模块用了不同模型(比如 768 维),导致向量插入失败。

  3. 方便切换 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 个地方都定义了维度常量:

  1. rag_examples/milvus_config.pyDEFAULT_DIMENSION = 1024
  2. rag_demo/util/embedding.pyDEFAULT_EMBEDDING_DIMENSION = 1024
  3. rag_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 库提取段落。如果不封装,这些依赖和逻辑会散落在各个模块中。

设计亮点

  • 懒加载依赖pypdfdocx 在函数内部按需导入,而不是在文件顶部 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=Trueenable_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:项目中如何保证向量维度一致?

这是项目中最容易出问题的点。通过三层保障:

  1. 常量定义DEFAULT_EMBEDDING_DIMENSION = 1024 定义在共享模块中。
  2. 集合定义 :Milvus schema 中的 dim=DEFAULT_EMBEDDING_DIMENSION
  3. 生成向量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 个月)

  1. Python 核心:类型标注、面向对象、异常处理、模块化
  2. Git 基础:commit、branch、merge、pull request
  3. 计算机网络:HTTP、RESTful API、JSON
  4. Linux 基础:命令行、文件系统、进程管理

第二阶段:大模型入门(1 个月)

  1. 理解大模型基本原理:Transformer、注意力机制
  2. 学会 Prompt Engineering:System Prompt、Few-Shot、CoT
  3. 掌握 OpenAI API 调用:非流式、流式、多轮对话
  4. 了解主流模型:GPT、Claude、Qwen、DeepSeek、Llama

第三阶段:RAG 专项(1-2 个月)

  1. 学习向量化原理:Embedding、语义相似度
  2. 掌握向量数据库:Milvus 安装、建表、检索
  3. 实践 RAG 流程:文档解析 -> 切片 -> 向量化 -> 检索 -> 生成
  4. 理解高级检索:混合检索、RRF 排序、Rerank

第四阶段:Agent 与工程化(1 个月)

  1. LangChain / LangGraph:Chain、Agent、Tool
  2. Web 框架:FastAPI 路由、中间件、部署
  3. 测试:pytest、Mock、集成测试
  4. 部署:Docker、环境变量管理、CI/CD

9.5 常见面试题整理

技术题

  1. 请画一下 RAG 系统的架构图,说明数据流向。
  2. 为什么要用向量数据库?它和关系型数据库有什么区别?
  3. 什么是 Embedding?常用的 Embedding 模型有哪些?
  4. 文本切片有哪些策略?各有什么优缺点?
  5. 什么是混合检索?为什么要做混合检索?
  6. 什么是 RRF 排序?它解决了什么问题?
  7. 大模型的 temperature 参数怎么设置?为什么 RAG 中要用低温度?
  8. FastAPI 和 Flask 有什么区别?为什么选 FastAPI?
  9. .env 和 .env.example 分别有什么作用?
  10. pytest 的 fixture 和 mock 怎么用?

场景题

  1. 如果用户问的问题在知识库中找不到相关文档,应该怎么办?

    • 检测检索结果的分数阈值,低于阈值时告知用户"未找到相关信息"
    • 可以让大模型说"抱歉,我目前的知识库中没有相关信息"
    • 不要硬编答案,这是企业级应用的大忌
  2. 知识库更新了,怎么让 RAG 系统及时反映变化?

    • 重新切片新文档,生成向量,插入 Milvus
    • 旧的过期文档可以通过时间戳或版本号过滤
    • 对于问答对集合,可以人工审核后删除旧 QA 对再插入新的
  3. 如何评估 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 依赖 utilconfigutil 不依赖 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 系统从架构设计到工程化落地的完整知识体系。