1 RAG简介

1.1 基本定义

RAG(Retrieval-Augment Generation,检索增强生成)是一种融合信息检索文本生成的技术范式。其核心内容包括三点:

  • 通过检索机制从外部知识库中动态获取相关信息;
  • 将检索结果融入大模型的输入;
  • 基于大型语言模型(LLM)基于原始输入结果与检索结果生成文本输出。

最终,通过向大型语言模型提供补充的外部信息,提升大型语言模型输出的准确性和实时性

大型语言模型的幻觉问题(Hallucination)始终是困扰基于大型语言模型应用的挑战,并且直至目前也没有完全解决,近期的研究也仅是对其产生机理进行了初步研究[1],[2]

针对这个幻觉问题,RAG技术出现,其核心还是在与基于外部知识来为大型语言模型提供信息增强,以降低其幻觉。

通过RAG提升大型语言模型输出的实时性这一点实际上是在讲信息的实时性保障,而不是计算的高时敏性保证。

1.2 技术原理

如基础定义部分所述,RAG过程的核心包括三个部分,分别是检索信息、合并输入、生成输出。这三个部分可以进一步形成一个两阶段架构,即:

  • 检索阶段:基于用户查询,调用检索模块查询外部知识库,获取检索得到的文档、片段;

  • 生成阶段:将检索所得的文档、片段作为上下文,调用生成模块,输入到LLM,生成自然语言输出。

这两个阶段中包含三个关键组件:

  • 索引(Indexing):将非结构化文档(如PDF、Word等)分割为片段,通过嵌入模型转换为向量数据。
  • 检索(Retrieval):基于查询语义,从向量数据库召回最相关的文档片段(Context)。
  • 生成(Generation):将检索结果作为上下文输入LLM,生成自然语言输出。

1.3 RAG技术演进

RAG技术按照复杂度可以划分为:初级RAG、高级RAG和模块化RAG,三种模式的对比如下图所示。

RAG技术演进

  • 初级RAG:

    • 基础“索引-检索-生成”流程

    • 简单文档分块

    • 基本向量检索机制

  • 高级RAG:

    • 增加数据清洗流程

    • 元数据优化

    • 多轮检索策略

    • 提升准确性和效率

高级RAG主要聚焦于优化检索过程,例如,查询重写(Query re-writing)[4]用于使查询更清晰和具体,从而提高检索的准确性;检索结果重排(Re-ranking)[5]被用来增强LLM识别和利用关键信息的能力,如下图所示。

在这里插入图片描述

  • 模块化RAG:

    • 灵活集成搜索引擎

    • 强化学习优化

    • 知识图谱增强

    • 支持复杂业务场景

在流程设计方面,当前的RAG系统已经超越了传统的线性检索-生成范式。研究人员使用迭代检索[6]来获取更丰富的上下文,递归检索[7]处理复杂的查询,自适应检索[8]来提供整体的自主性和灵活性。

模块化RAG[9]在此基础上,通过多个独立但紧密协调的模块组成,每个模块负责处理特定功能或任务,实现应对:复杂数据源集成、系统可解释性/可控性与可维护性新要求、组件选择与优化、工作流编排与组织等挑战。
在这里插入图片描述

1.4 关键优势[3]

RAG的核心优势包括以下几点:

  1. 准确性提升
  • 知识基础扩展:补充LLM预训练知识的不足,增强对专业领域的理解
  • 降低幻觉现象:通过提供具体参考材料,减少无中生有的情况
  • 可溯源引用:支持引用原始文档,提高输出内容的可信度和说服力
  1. 实时性保障
  • 动态知识更新:知识库内容可以独立于模型进行实时更新和维护
  • 减少时滞性:规避LLM预训练数据截止日期带来的知识时效性问题
  1. 成本效益
  • 避免频繁微调:相比反复微调LLM,维护知识库成本更低
  • 降低推理成本:针对特定领域问题,可使用更小的基础模型配合知识库
  • 资源消耗优化:减少存储完整知识在模型权重中的计算资源需求
  • 快速适应变化:新信息或政策更新只需更新知识库,无需重训练模型
  1. 可扩展性
  • 多源集成:支持从不同来源和格式的数据中构建统一知识库
  • 模块化设计:检索组件可独立优化,不影响生成组件

其中,笔者个人认为准确性提升可扩展性实时性保障是其较为突出的特点。

2 RAG构建及运行

2.1 四步构建最小可行系统(MVP)

  1. 数据准备
  • 格式支持:PDF、Word、网页文本等
  • 分块策略:按语义(如段落)或固定长度切分,避免信息碎片化
  1. 索引构建
  • 嵌入模型:选取开源模型(如text-embedding-ada-002)或微调领域专用模型
  • 向量化:将文本分块转换为向量存入数据库
  1. 检索优化
  • 混合检索:结合关键词(BM25)与语义搜索(向量相似度)提升召回率
  • 重排序(Rerank):用小模型筛选Top-K相关片段(如Cohere Reranker)
  1. 生成集成
  • 提示工程:设计模板引导LLM融合检索内容
  • LLM选型:GPT、Claude、Ollama等(按成本/性能权衡)

上述四步法还可以进一步调优,包括:

(1)评估指标

检索质量:上下文相关性(Context Relevance)
生成质量:答案忠实度(Faithfulness)、事实准确性

(2)性能优化

索引分层:对高频数据启用缓存机制
多模态扩展:支持图像/表格检索

接下来,我们将依据上述四步,构建RAG。

2.2 准备工作

准备工作包括:

  • 大模型API配置(已有网上大模型服务或者本地大模型服务均可)

  • 代码运行环境配置

  • 项目代码拉取(基于Git)

2.2.1 大模型API配置

本文此处采用DeepSeek API作为大模型访问API,通过在DeepSeek开放平台[10]注册、登录并创建API,获得API应用Key,如下图所示。
在这里插入图片描述
在这里插入图片描述

2.2.2 代码运行环境配置

截至目前,本文暂不考虑本地运行大模型,因此,本文以Windows为系统平台,搭建代码运行环境。

(1)Anaconda/Miniconda软件安装

待运行代码均为Python代码,考虑采用AnacondaMiniconda进行Python运行环境创建及管理。两个软件的下载可在Anaconda下载页面进行下载安装。安装程序为exe直接运行,跟着导航进行安装即可。

关于Anaconda和Miniconda的区别,二者基本上是相同的,安装哪一个都可以。二者的主要区别在于base环境中自带的库的数量不同。Anaconda的base环境所带的库相对丰富一些,相应地软件的大小也更大。Miniconda的base环境除了基础的setuptools、pip、wheel等库以外,没有额外的与应用相关的库。相应地,其大小也小一些。

基于上述特点与区别,建议熟悉conda操作的读者安装使用Miniconda,否则可选用Anaconda进行Python运行环境管理。

(2)虚拟环境创建及库安装

安装完成Anaconda或Miniconda后,在开始菜单打开Anaconda Prompt命令行窗口,如下图
在这里插入图片描述
在这里插入图片描述

在命令行窗口输入

conda create --name your_env_name python=3.12.7

创建虚拟环境,其中your_env_name为所创建的虚拟环境的名称。然后,在显示出的问题中,输入y,等待虚拟环境创建。

虚拟环境创建好后,输入

conda activate your_env_name

激活虚拟环境。激活虚拟环境后,从工程路径https://github.com/datawhalechina/all-in-rag/code下下载requirements.txt文件。切换到该文件存储路径,并运行

pip install -r requirements.txt

命令运行将看到类似如下过程

在这里插入图片描述

安装项目所需依赖库。由于依赖较多,安装过程比较久。待安装完成后,可通过输入

pip list

检查所需依赖库是否完全安装。

关于API_KEY环境变量

由于代码运行中需要获取前面创建生成的API Key,可以考虑将其写入环境变量中。这里有两种做法:

  • 做法一:在Windows系统环境变量中创建名为DEEPSEEK_API_KEY的环境变量。

  • 做法二:在程序运行时动态设置环境变量。

考虑到系统环境变量尽量简洁,本文采用做法二进行实现,即动态设置程序运行环境变量。

2.2.3 项目代码拉取

(1)安装Git

访问Git 官方网站,下载并运行安装程序,按照默认设置完成安装。

(2)克隆项目代码

  1. 选择存放项目的目录 打开终端(或 Windows 中的 Git Bash),导航到你想存放项目的目录:
cd [你希望存放项目的路径]
  1. 克隆仓库 使用以下命令拉取 all-in-rag 仓库:
git clone https://github.com/datawhalechina/all-in-rag.git

等待下载完成,项目代码将存放在当前目录下的 all-in-rag 文件夹中。

  1. 进入项目目录 拉取代码后,进入项目目录:
cd all-in-rag

2.3 四步构建RAG

2.3.1 运行RAG示例代码

本节拟对示例代码01_langchain_example.py进行运行。

(1)代码工程搭建

为清晰化代码工程,笔者对拉取项目的代码工程进行处理,新建工程,将code路径、data路径加入到工程路径中,并将下载的BAAI/bge-small-zh-v1.5模型文件存放于models路径。调整后的代码工程结构为

.
├── code
│   ├── C1
│   ├── C2
│   ├── C3
│   ├── C4
│   ├── C5
│   ├── C6
│   ├── C8
│   ├── C9
│   ├── docker-compose.yml
│   └── requirements.txt
├── data
│   ├── C1
│   ├── C2
│   ├── C3
│   ├── C4
│   ├── C8
│   ├── C9
│   └── nltk_data
└── models
    └── BAAI

该树目录结构使用tree工具结合Git工具,使用如下命令生成

tree -A -L 2

tree工具的具体安装及使用可参考[13],该命令的参数及其含义如下所示

usage: tree [-adfghilnpqrstuvxACDFNS] [-H baseHREF] [-T title ] [-L level [-R]]
        [-P pattern] [-I pattern] [-o filename] [--version] [--help] [--inodes]
        [--device] [--noreport] [--nolinks] [--dirsfirst] [--charset charset]
        [--filelimit #] [<directory list>]
  -a            All files are listed.
  -d            List directories only.
  -l            Follow symbolic links like directories.
  -f            Print the full path prefix for each file.
  -i            Don't print indentation lines.
  -q            Print non-printable characters as '?'.
  -N            Print non-printable characters as is.
  -p            Print the protections for each file.
  -u            Displays file owner or UID number.
  -g            Displays file group owner or GID number.
  -s            Print the size in bytes of each file.
  -h            Print the size in a more human readable way.
  -D            Print the date of last modification.
  -F            Appends '/', '=', '*', or '|' as per ls -F.
  -v            Sort files alphanumerically by version.
  -r            Sort files in reverse alphanumeric order.
  -t            Sort files by last modification time.
  -x            Stay on current filesystem only.
  -L level      Descend only level directories deep.
  -A            Print ANSI lines graphic indentation lines.
  -S            Print with ASCII graphics indentation lines.
  -n            Turn colorization off always (-C overrides).
  -C            Turn colorization on always.
  -P pattern    List only those files that match the pattern given.
  -I pattern    Do not list files that match the given pattern.
  -H baseHREF   Prints out HTML format with baseHREF as top directory.
  -T string     Replace the default HTML title and H1 header with string.
  -R            Rerun tree when max dir level reached.
  -o file       Output to file instead of stdout.
  --inodes      Print inode number of each file.
  --device      Print device ID number to which each file belongs.
  --noreport    Turn off file/directory count at end of tree listing.
  --nolinks     Turn off hyperlinks in HTML output.
  --dirsfirst   List directories before files.
  --charset X   Use charset X for HTML and indentation line output.
  --filelimit # Do not descend dirs with more than # files in them.
(2)准备工作

代码运行前,需要下载nltk_data数据,并存放于本地。笔者在下载nltk_data数据后,将其中的packages包解压缩后,放于与code同级的data路径下的nltk_data路径下。

基于上述处理,需在示例代码01_langchain_example.py开头添加

import nltk
import os
new_path = [
    os.path.join('..', '..', 'data', 'nltk_data')
]
nltk.data.path += new_path

用于指定nltk数据路径[12]

(3)代码调整

根据自己代码工程结构,将示例代码01_langchain_example.py调整为

import os
# hugging face镜像设置,如果国内环境无法使用启用该设置
# os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'

os.envir['DEEPSEEK_API_KEY'] = 'xxxxxxxx'  # 替换为申请的API Key

import nltk
new_path = [
    os.path.join('..', '..', 'data', 'nltk_data')
]
nltk.data.path += new_path


from dotenv import load_dotenv
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_core.prompts import ChatPromptTemplate
from langchain_deepseek import ChatDeepSeek

# 加载环境变量
load_dotenv()

markdown_path = os.path.join('..', '..', 'data', 'C1', 'markdown', 'easy-rl-chapter1.md')

# 加载本地markdown文件
loader = UnstructuredMarkdownLoader(markdown_path)
docs = loader.load()

# 文本分块
text_splitter = RecursiveCharacterTextSplitter()
chunks = text_splitter.split_documents(docs)

# 中文嵌入模型
embeddings = HuggingFaceEmbeddings(
    model_name="../../models/BAAI/bge-small-zh-v1.5",
    # cache_folder='../../models/BAAI/bge-small-zh-v1.5/',
    model_kwargs={'device': 'cpu'},
    encode_kwargs={'normalize_embeddings': True}
)

# 构建向量存储
vectorstore = InMemoryVectorStore(embeddings)
vectorstore.add_documents(chunks)

# 提示词模板
prompt = ChatPromptTemplate.from_template("""请根据下面提供的上下文信息来回答问题。
请确保你的回答完全基于这些上下文。
如果上下文中没有足够的信息来回答问题,请直接告知:“抱歉,我无法根据提供的上下文找到相关信息来回答此问题。”

上下文:
{context}

问题: {question}

回答:"""                            )

# 配置大语言模型
llm = ChatDeepSeek(
    model="deepseek-chat",
    temperature=0.7,
    max_tokens=2048,
    api_key=os.getenv("DEEPSEEK_API_KEY")
)

# 用户查询
question = "文中举了哪些例子?"

# 在向量存储中查询相关文档
retrieved_docs = vectorstore.similarity_search(question, k=3)
docs_content = "\n\n".join(doc.page_content for doc in retrieved_docs)

lang_model_input = prompt.format(question=question, context=docs_content)
answer = llm.invoke(lang_model_input)
print(answer.content)
(4)运行结果

通过激活新创建的虚拟环境,并运行上述代码,得到如下输出:

回答:

根据提供的上下文信息,文中举了以下例子:

  1. 自然界中的强化学习
  • 羚羊出生后通过试错学习站立和奔跑,最终适应环境。
  1. 金融领域的强化学习
  • 股票交易:通过不断买卖股票,根据市场反馈学习最大化奖励的策略。
  1. 游戏场景的强化学习
  • 雅达利游戏(如Breakout和Pong):智能体通过试错学习通关策略。
    • Pong游戏:强化学习智能体通过策略网络输出动作概率(如向上/向下移动),与人类玩家不同(如智能体会持续无意义振动)。
  1. 探索与利用的权衡示例
  • 选择餐馆
    • 利用:去已知喜爱的餐馆。
    • 探索:尝试新餐馆(可能获得更好体验或失望)。
  • 广告策略
    • 利用:沿用已知最优广告策略。
    • 探索:测试新策略(可能效果更好或更差)。
  • 石油勘探
    • 利用:在已知油田开采。
    • 探索:在新地点勘探(可能发现大油田或一无所获)。
  • 格斗游戏(如《街头霸王》)
    • 利用:重复使用固定策略(如蹲角落出脚)。
    • 探索:尝试新招式(可能失败或发现更强战术)。
  1. 有模型 vs. 免模型学习的对比
  • 免模型学习:以雅达利《太空侵略者》为例,需约两亿帧画面训练。
  • 有模型学习:通过环境建模在虚拟世界训练(如已知规则的游戏)。

这些例子均用于说明强化学习的核心概念(如试错探索、延迟奖励、探索与利用的权衡)及其与监督学习的区别。:

2.3.2 关键代码分析

本节对上述代码中的关键部分进行分析。

(1)导入环境变量

代码中导入环境变量使用了dotenv。Dotenv 是一个零依赖模块,可将环境变量从 .env 文件加载到 process.env 中。基于十二要素应用方法,项目工程应将配置存储在与代码分开的环境中。而Dotenv则为上述设计提供了便捷的实现支持。

12要素应用程序是一套原则,一种软件工程的方法论,产出的代码,能够可靠地发布,快速的扩展,并以一致和可预知的方式维护。

为了使应用程序能够真正利用现代云基础设施和工具,并在云中蓬勃发展,而不是仅仅在云中生存,Kevin Hoffman 修订了最初的12个因素,并增加了三个额外的因素——15因素应用程序方法论:超越12因素应用程序[15]

12因素:

  1. One codebase, one application - 一个代码库,一个应用程序

One codebase tracked in revision control, many deploys

一份基准代码记录在版本控制中,多份部署

  1. Dependency management - 依赖管理

Explicitly declare and isolate dependencies

显式声明和隔离依赖关系

  1. Design, build, release, and run - 设计、构建、发布和运行

Strictly separate build and run stages

严格区分构建和运行阶段

  1. Configuration, credentials, and code - 配置、证书和代码

Store configuration in the environment

在环境中存储配置。

配置原则:配置信息以环境变量或独立配置文件中定义的设置,注入到各种运行环境中。

  1. Logs - 日志

  2. Disposability - 易处理

  3. Backing services - 后端服务

Treat backing services as attached resources

将后端服务视为附加资源。

后端服务原则:鼓励架构师将外部组件,如数据库、电子邮件服务器、消息代理以及可由系统人员提供和维护的独立服务作为附加资源。将资源视为后端服务可以提高软件开发生命周期(SDLC)中的灵活性和效率。

  1. Environment parity - 环境等价

  2. Administrative processes - 管理进程

  3. Port binding - 端口绑定

Export services via port binding

通过端口绑定发布服务

  1. Stateless processes - 无状态进程

Execute the app as one or more stateless processes

将应用程序作为一个或多个无状态进程执行

  1. Concurrency - 并发性

Scale-out via the process model

通过进程模型横向扩展

为了云原生应用程序而新增加3个因素

  1. API first - API 优先
  2. Telemetry - 遥测
  3. Authentication and authorization - 认证和授权

.env是默认的环境配置文件,其中包含通用的配置项。在开发和生产环境下都可以使用这个文件。一个示例的.env文件可能如下所示:

DB_HOST=localhost
DB_PORT=3000
DB_USER=username
DB_PASS=password

在本项目中,我们使用了如下代码对Deepseek的API Key进行配置

import os

os.environ['DEEPSEEK_API_KEY'] = 'xxxxxxxx'  # 替换为申请的API Key

并通过

os.getenv('DEEPSEEK_API_KEY')

的方式获取该环境变量。当我们使用Dotenv时,我们首先编写.env文件为如下内容,并将其放在示例代码01_langchain_example.py同级目录下

DEEPSEEK_API_KEY=xxxxxxxx

然后,我们可以采用如下代码对该环境变量进行获取

import os
from dotenv import load_dotenv

load_dotenv()
api = os.getenv('DEEPSEEK_API_KEY')
(2)加载本地Markdown文件[16]

在加载本地Markdown文件的操作中,我们采用langchaindocument_loaders中的UnstructuredMarkdownLoader对其进行加载。该对象由LangChain 实现,需要Unstructured包,该包在nltk中,我们在前面已经安装。

在底层,Unstructured为不同的文本块创建不同的“元素”。默认情况下,我们将它们组合在一起,但您可以通过指定 mode="elements" 轻松保留这种分离。

该对象用法如下

loader = UnstructuredMarkdownLoader(
    "./example_data/example.md",
    mode="single",
    strategy="fast",
)

例如,对于如下文档进行加载和解析

🦜️🔗 LangChain

⚡ Build context-aware reasoning applications ⚡

Looking for the JS/TS library? Check out LangChain.js.

To help you ship LangChain apps to production faster, check out LangSmith. 
LangSmith is a unified developer platform for building,

使用如下代码

loader = UnstructuredMarkdownLoader(markdown_path, mode="elements")

data = loader.load()
print(f"Number of documents: {len(data)}\n")

for document in data[:2]:
    print(f"{document}\n")

可得到如下结果

Number of documents: 66

page_content='🦜️🔗 LangChain' metadata={'source': '../../../README.md', 'category_depth': 0, 'last_modified': '2024-06-28T15:20:01', 'languages': ['eng'], 'filetype': 'text/markdown', 'file_directory': '../../..', 'filename': 'README.md', 'category': 'Title'}

page_content='⚡ Build context-aware reasoning applications ⚡' metadata={'source': '../../../README.md', 'last_modified': '2024-06-28T15:20:01', 'languages': ['eng'], 'parent_id': '200b8a7d0dd03f66e4f13456566d2b3a', 'filetype': 'text/markdown', 'file_directory': '../../..', 'filename': 'README.md', 'category': 'NarrativeText'}

请注意,在这种情况下,我们恢复了三种不同的元素类型

print(set(document.metadata["category"] for document in data))
{'ListItem', 'NarrativeText', 'Title'}
(3)文本分块

为了便于后续的嵌入和检索,长文档被分割成较小的、可管理的文本块(chunks)。这里采用了递归字符分割策略,使用其默认参数进行分块,采用如下代码

# 文本分块
text_splitter = RecursiveCharacterTextSplitter()
chunks = text_splitter.split_documents(docs)

得到分割后的文本chunks。对于RecursiveCharacterTextSplitter类,当不指定参数初始化时,其默认行为旨在最大程度保留文本的语义结构:

  • 默认分隔符与语义保留: 按顺序尝试使用一系列预设的分隔符 ["\n\n" (段落), "\n" (行), " " (空格), "" (字符)] 来递归分割文本。这种策略的目的是尽可能保持段落、句子和单词的完整性,因为它们通常是语义上最相关的文本单元,直到文本块达到目标大小。
  • 保留分隔符: 默认情况下 (keep_separator=True),分隔符本身会被保留在分割后的文本块中。
  • 默认块大小与重叠: 使用其基类 TextSplitter 中定义的默认参数 chunk_size=4000(块大小)和 chunk_overlap=200(块重叠)。这些参数确保文本块符合预定的大小限制,并通过重叠来减少上下文信息的丢失。

RecursiveCharacterTextSplitterTextSplitter的初始化参数如下

class RecursiveCharacterTextSplitter(TextSplitter):
    """Splitting text by recursively look at characters.

    Recursively tries to split by different characters to find one
    that works.
    """

    def __init__(
        self,
        separators: Optional[List[str]] = None,
        keep_separator: Union[bool, Literal["start", "end"]] = True,
        is_separator_regex: bool = False,
        **kwargs: Any, 
   ) -> None:
        ...


class TextSplitter(BaseDocumentTransformer, ABC):
    """Interface for splitting text into chunks."""

    def __init__(
        self,
        chunk_size: int = 4000,
        chunk_overlap: int = 200,
        length_function: Callable[[str], int] = len,
        keep_separator: Union[bool, Literal["start", "end"]] = False,
        add_start_index: bool = False,
        strip_whitespace: bool = True,
    ) -> None:
        """Create a new TextSplitter.

        Args:
            chunk_size: Maximum size of chunks to return
            chunk_overlap: Overlap in characters between chunks
            length_function: Function that measures the length of given chunks
            keep_separator: Whether to keep the separator and where to place it
                            in each corresponding chunk (True='start')
            add_start_index: If `True`, includes chunk's start index in metadata
            strip_whitespace: If `True`, strips whitespace from the start and end of
                              every document
        """
        ...

经由上述文本分块,我们得到如下文本块(chunks)

Chunk 0:  page_content='第1章 强化学习基础

1.1 强化学习概述

强化学习(reinforcement learning,RL)讨论的问题是智能体(agent)怎么在复杂、不确定的环境(environment)中最大化它能获得的奖励。如图 1.1 所示,强化学习由两部分组成:智能体和环境。在强化学习过程中,智能体与环境一直在交互。智能体在环境中获取某个状态后,它会利用该状态输出一个动作 (action),这个动作也称为决策(decision)。然后这个动作会在环境中被执行,环境会根据智能体采取的动作,输出下一个状态以及当前这个动作带来的奖励。智能体的目的就是尽可能多地从环境中获取奖励。



图 1.1 强化学习示意

1.1.1 强化学习与监督学习

我们可以把强化学习与监督学习做一个对比。以图片分类为例,如图 1.2 所示,监督学习(supervised learning)假设我们有大量被标注的数据,比如汽车、飞机、椅子这些被标注的图片,这些图片都要满足独立同分布,即它们之间是没有关联关系的。假设我们训练一个分类器,比如神经网络。为了分辨输入的 图片中是汽车还是飞机,在训练过程中,需要把正确的标签信息传递给神经网络。 当神经网络做出错误的预测时,比如输入汽车的图片,它预测出来是飞机,我们就会直接告诉它,该预测是错误的,正确的标签应该是汽车。最后我们根据类似错误写出一个损失函数(loss function),通过反向传播(back propagation)来训练神经网络。



图 1.2 监督学习

所以在监督学习过程中,有两个假设: * 输入的数据(标注的数据)都应是没有关联的。因为如果输入的数据有关联,学习器(learner)是不好学习的。 * 需要告诉学习器正确的标签是什么,这样它可以通过正确的标签来修正自己的预测。

通常假设样本空间中全体样本服从一个未知分布,我们获得的每个样本都是独立地从这个分布上采样获得的,即独立同分布(independent and identically distributed,简称 i.i.d.)。

在强化学习中,监督学习的两个假设其实都不能得到满足。以雅达利(Atari) 游戏 Breakout 为例,如图 1.3 所示,这是一个打砖块的游戏,控制木板左右移动从而把球反弹到上面来消除砖块。在玩游戏的过程中,我们可以发现智能体得到的观测(observation)不是独立同分布的,上一帧与下一帧间其实有非常强的连续性。我们得到的数据是相关的时间序列数据,不满足独立同分布。另外,我们并没有立刻获得反馈,游戏没有告诉我们哪个动作是正确动作。比如现在把木板往右移,这只会使得球往上或者往左一点儿,我们并不会得到即时的反馈。因此,强化学习之所以困难,是因为智能体不能得到即时的反馈,然而我们依然希望智能体在这个环境中学习。



图 1.3 雅达利游戏Breakout

如图 1.4 所示,强化学习的训练数据就是一个玩游戏的过程。我们从第 1 步开始,采取一个动作,比如我们把木板往右移,接到球。第 2 步我们又做出动作,得到的训练数据是一个玩游戏的序列。比如现在是在第 3 步,我们把这个序列放进网络,希望网络可以输出一个动作,即在当前的状态应该输出往右移或 者往左移。这里有个问题,我们没有标签来说明现在这个动作是正确还是错误的,必须等到游戏结束才可能知道,这个游戏可能 10s 后才结束。现在这个动作到底对最后游戏是否能赢有无帮助,我们其实是不清楚的。这里我们就面临延迟奖励(delayed reward)的问题,延迟奖励使得训练网络非常困难。



图 1.4 强化学习:玩Breakout

强化学习和监督学习的区别如下。

(1)强化学习输入的样本是序列数据,而不像监督学习里面样本都是独立的。

(2)学习器并没有告诉我们每一步正确的动作应该是什么,学习器需要自己去发现哪些动作可以带来 最多的奖励,只能通过不停地尝试来发现最有利的动作。

(3)智能体获得自己能力的过程,其实是不断地试错探索(trial-and-error exploration)的过程。探索 (exploration)和利用(exploitation)是强化学习里面非常核心的问题。其中,探索指尝试一些新的动作, 这些新的动作有可能会使我们得到更多的奖励,也有可能使我们“一无所有”;利用指采取已知的可以获得最多奖励的动作,重复执行这个动作,因为我们知道这样做可以获得一定的奖励。因此,我们需要在探索和利用之间进行权衡,这也是在监督学习里面没有的情况。

(4)在强化学习过程中,没有非常强的监督者(supervisor),只有奖励信号(reward signal),并且奖励信号是延迟的,即环境会在很久以后告诉我们之前我们采取的动作到底是不是有效的。因为我们没有得 到即时反馈,所以智能体使用强化学习来学习就非常困难。当我们采取一个动作后,如果我们使用监督学习,我们就可以立刻获得一个指导,比如,我们现在采取了一个错误的动作,正确的动作应该是什么。而在强化学习里面,环境可能会告诉我们这个动作是错误的,但是它并没有告诉我们正确的动作是什么。而且更困难的是,它可能是在一两分钟过后告诉我们这个动作是错误的。所以这也是强化学习和监督学习不同的地方。

通过与监督学习的比较,我们可以总结出强化学习的一些特征。

(1)强化学习会试错探索,它通过探索环境来获取对环境的理解。

(2)强化学习智能体会从环境里面获得延迟的奖励。

(3)在强化学习的训练过程中,时间非常重要。因为我们得到的是有时间关联的数据(sequential data), 而不是独立同分布的数据。在机器学习中,如果观测数据有非常强的关联,会使得训练非常不稳定。这也是为什么在监督学习中,我们希望数据尽量满足独立同分布,这样就可以消除数据之间的相关性。

(4)智能体的动作会影响它随后得到的数据,这一点是非常重要的。在训练智能体的过程中,很多时 候我们也是通过正在学习的智能体与环境交互来得到数据的。所以如果在训练过程中,智能体不能保持稳定,就会使我们采集到的数据非常糟糕。我们通过数据来训练智能体,如果数据有问题,整个训练过程就会失败。所以在强化学习里面一个非常重要的问题就是,怎么让智能体的动作一直稳定地提升。

1.1.2 强化学习的例子

为什么我们关注强化学习,其中非常重要的一个原因就是强化学习得到的模型可以有超人类的表现。 监督学习获取的监督数据,其实是人来标注的,比如 ImageNet 的图片的标签都是人类标注的。因此我们 可以确定监督学习算法的上限(upper bound)就是人类的表现,标注结果决定了它的表现永远不可能超越人类。但是对于强化学习,它在环境里面自己探索,有非常大的潜力,它可以获得超越人类的能力的表现,比如 DeepMind 的 AlphaGo 这样一个强化学习的算法可以把人类顶尖的棋手打败。

这里给大家举一些在现实生活中强化学习的例子。

(1)在自然界中,羚羊其实也在做强化学习。它刚刚出生的时候,可能都不知道怎么站立,然后它通过试错,一段时间后就可以跑得很快,可以适应环境。

(2)我们也可以把股票交易看成强化学习的过程。我们可以不断地买卖股票,然后根据市场给出的反馈来学会怎么去买卖可以让我们的奖励最大化。

(3)玩雅达利游戏或者其他电脑游戏,也是一个强化学习的过程,我们可以通过不断试错来知道怎么 玩才可以通关。

图 1.5 所示为强化学习的一个经典例子,即雅达利的 Pong 游戏。游戏中右边的选手把球拍到左边, 然后左边的选手需要把球拍到右边。训练好的强化学习智能体和正常的选手有区别:强化学习的智能体会一直做无意义的振动,而正常的选手不会做出这样的动作。



图 1.5 Pong游戏

在 Pong 游戏里面,其实只有两个动作:往上或者往下。如图 1.6 所示,如果强化学习通过学习一个策略网络来进行分类,那么策略网络会输入当前帧的图片,输出所有决策的可能性,比如往上移动的概率。



图 1.6 强化学习玩 Pong

如图 1.7 所示,对于监督学习,我们可以直接告诉智能体正确动作的标签是什么。但在 Pong 游戏中, 我们并不知道它的正确动作的标签是什么。



图 1.7 监督学习玩 Pong

在强化学习里面,我们让智能体尝试玩 Pong 游戏,对动作进行采样,直到游戏结束,然后对每个动作进行惩罚。图 1.8 所示为预演(rollout)的一个过程。预演是指我们从当前帧对动作进行采样,生成很多局游戏。我们将当前的智能体与环境交互,会得到一系列观测。每一个观测可看成一个轨迹(trajectory)。 轨迹就是当前帧以及它采取的策略,即状态和动作的序列: $$ \tau=\left(s_{0}, a_{0}, s_{1}, a_{1}, \ldots\right) $$ 最后结束时,我们会知道到底有没有把这个球拍到对方区域,对方有没有接住,我们是赢了还是输了。我们可以通过观测序列以及最终奖励(eventual reward)来训练智能体,使它尽可能地采取可以获得最终奖励的动作。一场游戏称为一个回合(episode)或者试验(trial)。



图 1.8 可能的预演序列

1.1.3 强化学习的历史' metadata={'source': '..\\..\\data\\C1\\markdown\\easy-rl-chapter1.md'}
Chunk 1:  page_content='图 1.8 可能的预演序列

1.1.3 强化学习的历史

强化学习是有一定的历史的,早期的强化学习,我们称其为标准强化学习。最近业界把强化学习与深度学习结合起来,就形成了深度强化学习(deep reinforcement learning),因此,深度强化学习 = 深度学习 + 强化学习。我们可将标准强化学习和深度强化学习类比于传统的计算机视觉和深度计算机视觉。

如图 1.9a 所示,传统的计算机视觉由两个过程组成。

(1)给定一张图片,我们先要提取它的特征,使用一些设计好的特征,比如方向梯度直方图(histogram of oriental gradient,HOG)、可变现的组件模型(deformable part model,DPM)。

(2)提取这些特征后,我们再单独训练一个分类器。这个分类器可以是支持向量机(support vector machine,SVM)或 Boosting,然后就可以辨别这张图片是狗还是猫。



(a)传统的计算机视觉



(b)深度计算机视觉

图 1.9 传统的计算机视觉与深度计算机视觉的区别

2012年,Krizhevsky等人提出了AlexNet,AlexNet在ImageNet分类比赛中取得冠军,迅速引起了人们对于卷积神经网络的广泛关注。 大家就把特征提取以及分类两者合到一块儿去了,就是训练一个神经网络。这个神经网络既可以做特征提取,也可以做分类,它可以实现端到端训练,如图 1.9b 所示,它的参数可以在每一个阶段都得到极大的优化,这是一个非常重要的突破。

我们可以把神经网络放到强化学习里面。

标准强化学习:比如 TD-Gammon 玩 Backgammon 游戏的过程,其实就是设计特征,然后训练价值函数的过程,如图 1.10a 所示。标准强化学习先设计很多特征,这些特征可以描述现在整个状态。 得到这些特征后,我们就可以通过训练一个分类网络或者分别训练一个价值估计函数来采取动作。

深度强化学习:自从我们有了深度学习,有了神经网络,就可以把智能体玩游戏的过程改进成一个端到端训练(end-to-end training)的过程,如图 1.10b 所示。我们不需要设计特征,直接输入状态就可以输出动作。我们可以用一个神经网络来拟合价值函数或策略网络,省去特征工程(feature engineering)的过程。



(a)标准强化学习



(b)深度强化学习

图 1.10 标准强化学习与深度强化学习的区别

1.1.4 强化学习的应用

为什么强化学习在这几年有很多的应用,比如玩游戏以及机器人的一些应用,并且可以击败人类的顶尖棋手呢?这有如下几点原因。首先,我们有了更多的算力(computation power),有了更多的 GPU,可 以更快地做更多的试错尝试。其次,通过不同尝试,智能体在环境里面获得了很多信息,然后可以在环境里面取得很大的奖励。最后,我们通过端到端训练把特征提取和价值估计或者决策一起优化,这样就可以 得到一个更强的决策网络。

接下来介绍一些强化学习里面比较有意思的例子,如图 1.11 所示。

(1)DeepMind 研发的走路的智能体。这个智能体往前走一步,就会得到一个奖励。这个智能体有不同的形态,可以学到很多有意思的功能。比如,像人一样的智能体学习怎么在曲折的道路上往前走。结果 非常有意思,这个智能体会把手举得非常高,因为举手可以让它的身体保持平衡,它就可以更快地在环境里面往前走。而且我们也可以增加环境的难度,加入一些扰动,智能体就会变得更鲁棒。

(2)机械臂抓取。因为我们把强化学习应用到机械臂自动抓取需要大量的预演,所以我们可以使用多个机械臂进行训练。分布式系统可以让机械臂尝试抓取不同的物体,盘子里面物体的形状是不同的,这样 就可以让机械臂学到一个统一的动作,然后针对不同的抓取物都可以使用最优的抓取算法。因为抓取的物 体形状的差别很大,所以使用一些传统的抓取算法不能把所有物体都抓起来。传统的抓取算法对每一个物 体都需要建模,这样是非常费时的。但通过强化学习,我们可以学到一个统一的抓取算法,其适用于不同 的物体。

(3)OpenAI 的机械臂翻魔方。OpenAI 在 2018 年的时候设计了一款带有“手指”的机械臂,它可以通过翻动手指使得手中的木块达到预期的设定。人的手指其实非常灵活,怎么使得机械臂的手指也具有这 样灵活的能力一直是个问题。OpenAI 先在一个虚拟环境里面使用强化学习对智能体进行训练,再把它应 用到真实的机械臂上。这在强化学习里面是一种比较常用的做法,即我们先在虚拟环境里面得到一个很好 的智能体,然后把它应用到真实的机器人中。这是因为真实的机械臂通常非常容易坏,而且非常贵,一般 情况下没办法大批量地购买。OpenAI 在 2019 年对其机械臂进行了进一步的改进,这个机械臂在改进后 可以玩魔方了。

(4)穿衣服的智能体。很多时候我们要在电影或者一些动画中实现人穿衣服的场景,通过手写执行命令让机器人穿衣服非常困难,穿衣服也是一种非常精细的操作。我们可以训练强化学习智能体来实现穿衣 服功能。我们还可以在里面加入一些扰动,智能体可以抵抗扰动。可能会有失败的情况(failure case)出 现,这样智能体就穿不进去衣服。



图 1.11 强化学习例子

1.2 序列决策

1.2.1 智能体与环境

接下来我们介绍序列决策(sequential decision making)过程。强化学习研究的问题是智能体与环境交互的问题,图 1.12 左边的智能体一直在与图 1.12 右边的环境进行交互。智能体把它的动作输出给环境,环境取得这个动作后会进行下一步,把下一步的观测与这个动作带来的奖励返还给智能体。这样的交互会产生很多观测,智能体的目的是从这些观测之中学到能最大化奖励的策略。



图 1.12 智能体和环境

1.2.2 奖励

奖励是由环境给的一种标量的反馈信号(scalar feedback signal),这种信号可显示智能体在某一步采取某个策略的表现如何。强化学习的目的就是最大化智能体可以获得的奖励,智能体在环境里面存在的目 的就是最大化它的期望的累积奖励(expected cumulative reward)。不同的环境中,奖励也是不同的。这里给大家举一些奖励的例子。

(1)比如一个象棋选手,他的目的是赢棋,在最后棋局结束的时候,他就会得到一个正奖励(赢)或者负奖励(输)。

(2)在股票管理里面,奖励由股票获取的奖励与损失决定。

(3)在玩雅达利游戏的时候,奖励就是增加或减少的游戏的分数,奖励本身的稀疏程度决定了游戏的难度。

1.2.3 序列决策

在一个强化学习环境里面,智能体的目的就是选取一系列的动作来最大化奖励,所以这些选取的动作 必须有长期的影响。但在这个过程里面,智能体的奖励其实是被延迟了的,就是我们现在选取的某一步动作,可能要等到很久后才知道这一步到底产生了什么样的影响。如图 1.13 所示,在玩雅达利的 Pong 游戏时,我们可能只有到最后游戏结束时,才知道球到底有没有被击打过去。过程中我们采取的上升(up)或 下降(down)动作,并不会直接产生奖励。强化学习里面一个重要的课题就是近期奖励和远期奖励的权衡 (trade-off),研究怎么让智能体取得更多的远期奖励。

在与环境的交互过程中,智能体会获得很多观测。针对每一个观测,智能体会采取一个动作,也会得到一个奖励。所以历史是观测、动作、奖励的序列: $$ H_{t}=o_{1}, a_{1}, r_{1}, \ldots, o_{t}, a_{t}, r_{t} $$

智能体在采取当前动作的时候会依赖于它之前得到的历史,所以我们可以把整个游戏的状态看成关于这个历史的函数:

$$ S_{t}=f\left(H_{t}\right) $$



图 1.13 玩Pong游戏

Q:状态和观测有什么关系?

A:状态是对世界的完整描述,不会隐藏世界的信息。观测是对状态的部分描述,可能会遗漏一些信息。在深度强化学习中,我们几乎总是用实值的向量、矩阵或者更高阶的张量来表示状态和观测。例如, 我们可以用 RGB 像素值的矩阵来表示一个视觉的观测,可以用机器人关节的角度和速度来表示一个机器 人的状态。

环境有自己的函数$s_{t}^{e}=f^{e}\left(H_{t}\right)$ 来更新状态,在智能体的内部也有一个函数$s_{t}^{a}=f^{a}\left(H_{t}\right)$来更新状 态。当智能体的状态与环境的状态等价的时候,即当智能体能够观察到环境的所有状态时,我们称这个环境是完全可观测的(fully observed)。在这种情况下面,强化学习通常被建模成一个马尔可夫决策过程 (Markov decision process,MDP)的问题。在马尔可夫决策过程中,$o_{t}=s_{t}^{e}=s_{t}^{a}$。' metadata={'source': '..\\..\\data\\C1\\markdown\\easy-rl-chapter1.md'}

可以看到上述特征得到了保留。

(4)索引构建

索引构建主要包括两个方面:一是嵌入模型加载,二是向量存储构建。

在嵌入模型加载方面,这里使用HuggingFaceEmbeddings加载之前在初始化设置中下载的中文嵌入模型。配置模型在CPU上运行,并启用嵌入归一化 (normalize_embeddings: True)。

embeddings = HuggingFaceEmbeddings(
    model_name="../../models/BAAI/bge-small-zh-v1.5",
    model_kwargs={'device': 'cpu'},
    encode_kwargs={'normalize_embeddings': True}
)

在向量存储构建方面,将分割后的文本块通过初始化好的嵌入模型转换为向量表示,然后使用InMemoryVectorStore将这些向量及其对应的原始文本内容添加进去,从而在内存中构建出一个向量索引。

vectorstore = InMemoryVectorStore(embeddings)
vectorstore.add_documents(texts)

这个过程实际上就是将上述划分得到的文本块逐一采用嵌入模型进行嵌入,再将嵌入所得的 “向量-文本块” 存入内存库中。这个过程完成后,便构建了一个可供查询的知识索引。

该过程通过查看上述add_documents方法的过程可知。

def add_documents(
        self,
        documents: list[Document],
        ids: Optional[list[str]] = None,
        **kwargs: Any,
) -> list[str]:
    """Add documents to the store."""
    texts = [doc.page_content for doc in documents]
    vectors = self.embedding.embed_documents(texts)

    if ids and len(ids) != len(texts):
        msg = (
            f"ids must be the same length as texts. "
            f"Got {len(ids)} ids and {len(texts)} texts."
        )
        raise ValueError(msg)

    id_iterator: Iterator[Optional[str]] = (
        iter(ids) if ids else iter(doc.id for doc in documents)
    )

    ids_ = []

    for doc, vector in zip(documents, vectors):
        doc_id = next(id_iterator)
        doc_id_ = doc_id or str(uuid.uuid4())
        ids_.append(doc_id_)
        self.store[doc_id_] = {
            "id": doc_id_,
            "vector": vector,
            "text": doc.page_content,
            "metadata": doc.metadata,
        }
    return ids_
(5)查询与检索

索引构建完毕后,便可以针对用户问题进行查询与检索:

  • 定义用户查询: 设置一个具体的用户问题字符串。

    question = "文中举了哪些例子?"
    
  • 在向量存储中查询相关文档: 使用向量存储的similarity_search方法,根据用户问题在索引中查找最相关的 k (此处示例中 k=3) 个文本块。

    retrieved_docs = vectorstore.similarity_search(question, k=3)
    
  • 准备上下文: 将检索到的多个文本块的页面内容 (doc.page_content) 合并成一个单一的字符串,并使用双换行符 ("\n\n") 分隔各个块,形成最终的上下文信息 (docs_content) 供大语言模型参考。

    docs_content = "\n\n".join(doc.page_content for doc in retrieved_docs)
    

    使用 "\n\n" (双换行符) 而不是 "\n" (单换行符) 来连接不同的检索文档块,主要是为了在传递给大型语言模型(LLM)时,能够更清晰地在语义上区分这些独立的文本片段。双换行符通常代表段落的结束和新段落的开始,这种格式有助于LLM将每个块视为一个独立的上下文来源,从而更好地理解和利用这些信息来生成回答。

(6)生成集成

最后一步是将检索到的上下文与用户问题结合,利用大语言模型(LLM)生成答案:

  • 构建提示词模板: 使用ChatPromptTemplate.from_template创建一个结构化的提示模板。此模板指导LLM根据提供的上下文 (context) 回答用户的问题 (question),并明确指出在信息不足时应如何回应。

    prompt = ChatPromptTemplate.from_template(
    """请根据下面提供的上下文信息来回答问题。请确保你的回答完全基于这些上下文。
    如果上下文中没有足够的信息来回答问题,请直接告知:“抱歉,我无法根据提供的上下文找到相关信息来回答此问题。
    ”上下文:{context}问题: {question}回答:"""
    )
    
  • 配置大语言模型: 初始化ChatDeepSeek客户端,配置所用模型 (deepseek-chat)、生成答案的温度参数 (temperature=0.7)、最大Token数 (max_tokens=2048) 以及API密钥 (从环境变量加载)。

    llm = ChatDeepSeek(
        model="deepseek-chat",
        temperature=0.7,
        max_tokens=2048,
        api_key=os.getenv("DEEPSEEK_API_KEY")
    )
    

    这里我们使用的时DeepSeek客户端,用于访问使用DeepSeek的API服务,其需要使用DEEPSEEK_API_KEY,且仅能访问和使用DeepSeek的服务。

    如果想要更灵活的服务访问,可以使用ChatOpenAI,其可以支持更为灵活的调用方式(例如可以支持其他base_url):

    from langchain_openai import ChatOpenAI
    
    llm = ChatOpenAI(
        model="your_model",
        temperature=0.7,
        max_tokens=2048,
        timeout=None,
        max_retries=2,
        api_key="your_api_key",
        base_url="your_url",
        # organization="...",
        # other params...
    )
    
  • 调用LLM生成答案并输出: 将用户问题 (question) 和先前准备好的上下文 (docs_content) 格式化到提示模板中,然后调用ChatDeepSeek的invoke方法获取生成的答案。

    answer = llm.invoke(prompt.format(question=question, context=docs_content))
    print(answer)
    

参考资料

[1] [2509.04664] Why Language Models Hallucinate

[2] 用微分几何理论破解AI幻觉

[3] GitHub - datawhalechina/all-in-rag: 🔍大模型应用开发实战一:RAG技术全栈指南,在线阅读地址:https://datawhalechina.github.io/all-in-rag/

[4] [2311.03758v3] Large Language Model based Long-tail Query Rewriting in Taobao Search

[5] [2211.09303] A Bird’s-eye View of Reranking: from List Level to Page Level

[6] [2310.05149] Retrieval-Generation Synergy Augmented Large Language Models

[7] Tree of Clarifications: Answering Ambiguous Questions with Retrieval-Augmented Large Language Models

[8] [2305.06983v2] Active Retrieval Augmented Generation

[9] [2407.21059] Modular RAG: Transforming RAG Systems into LEGO-like Reconfigurable Frameworks

[10] DeepSeek开放平台

[11] Download Anaconda Distribution | Anaconda

[12] Python 将nltk.download()的默认路径更改为~/ntlk_data|极客教程

[13] 有什么好的生成文本树形目录的工具? - 知乎

[14] dotenv库(环境变量和模式) - 知乎

[15] 云原生架构设计方法论——12因素应用程序图解,12 Factor - 知乎

[16] 如何加载 Markdown | 🦜️🔗 LangChain 框架

Logo

更多推荐