构建面向业务的生成式人工智能系统——将动态RAG集成到GenAISys中

89 阅读35分钟

一套面向业务的生成式AI系统(GenAISys)需要具备灵活性,能够应对快速变化的AI市场环境。AI控制器作为电商营销、生产、存储、分发和支持的自适应编排者,必须满足多样化任务需求,因此需要一个检索增强生成(RAG)框架。在上一章中,我们构建了一个面向指令场景的会话AI代理和相似度搜索功能(AI编排器),用于生成式AI模型。在本章中,我们将基于此基础,构建一个可扩展的RAG系统,使用Pinecone索引,将指令场景和经典数据集成起来,供生成式AI模型连接使用。

本章将明确区分两类内容:指令场景——专家设计的提示片段(或任务标签,如上一章所述),指导模型如何推理或行动;以及经典数据——RAG系统检索以支持回答的参考材料。

为什么需要在经典数据之上,构建一个动态且自适应的带有向量化指令场景的RAG框架?因为全球市场的变化会对内部和外部实体产生影响。举例来说,飓风可能导致电力短缺,危及企业供应链,企业可能需要调整供应路线、生产或分销。通用AI云平台或许能完成部分工作,但更多情况下,我们需要提供定制的领域专属功能。因此,我们需要在向量存储库中维护一套动态的指令集合,就像管理RAG数据一样。

我们将从定义基于架构的场景驱动任务执行开始,针对生成式AI模型(以GPT-4o为例)通过Pinecone索引实现。我们将仔细分析通过相似度搜索和检索为生成模型投资智能场景的成本与收益。随后介绍一个动态框架,用以打造类似ChatGPT的能力,并将在后续章节逐步展开。

架构确定后,我们首先构建一个Pinecone索引,用于对指令场景进行分块、向量嵌入和数据写入。确保GenAISys的向量存储能够对查询进行嵌入,并找到相关的指令场景。这一能力将在第四章“构建AI控制器编排界面”中发挥关键作用,届时我们将设计会话代理的接口和编排器。最后,我们将编写程序,将经典数据写入同一个Pinecone索引,与指令场景共存。场景与经典数据的区分将通过不同的命名空间实现。到本章结束时,我们将构建完成连接指令与生成式AI模型的主要组件,做好设计用户界面和AI控制器编排器的准备。

本章内容包括:

  • 架构设计:实现指令与数据的动态检索RAG框架
  • 研发相似度搜索时的收益递减规律分析
  • 混合型GenAISys链式思维(CoT)架构剖析
  • 通过分块、嵌入和写入构建Pinecone指令场景索引
  • 用经典数据增强Pinecone索引
  • 对Pinecone索引进行查询

我们的首要任务是设计一个支持动态检索的RAG框架。

动态检索的RAG架构设计

本节中,我们将定义一个Pinecone索引,用于存储指令场景和经典数据。这种结构赋予GenAISys动态且高性价比的检索能力:指令场景引导生成式AI模型(以GPT-4o为例),而经典数据则为RAG流水线提供事实上下文支持。

我们将介绍以下几个核心组件:

  • 场景驱动的任务执行:设计并写入Pinecone索引的优化指令提示(“场景”)。
  • 成本效益策略:考虑收益递减规律,避免在自动化上过度投入。
  • Pinecone命名空间分区:利用Pinecone索引的命名空间清晰区分指令场景与经典数据。
  • 混合检索框架:实现隐式向量相似度搜索,同时触发生成式AI模型的显式指令(具体见场景驱动的任务执行部分)。
  • 链式思维(CoT)循环:说明灵活的场景选择过程如何导致生成式AI功能的多轮调用,最终输出结果。
  • GenAISys框架:为本书持续构建的高级GenAISys框架奠定基础。

接下来,我们先深入探讨场景驱动的任务执行。

场景驱动的任务执行

上一章中,我们看到AI控制器有两种互补的下一步任务选择方式:

  • 隐式选择:控制器将用户提示进行嵌入,基于语义相似度在场景库中搜索并选择最匹配的场景,无需任务标签。这种方式实现了灵活、无需编码的编排(例如,自动选择“角斗士2”影评的情感分析场景)。
  • 显式选择:明确指定任务,可能通过提示中的任务标签或用户界面动作触发,如“执行网页搜索”。此时控制器跳过相似度搜索,直接调用指定工具或工作流。

本章继续沿用这一模式,但规模更大。我们管理数百乃至数千个专家设计的指令场景,存储于向量数据库中;支持多用户并发及多任务工作流。该场景驱动(隐式)方法具备三大优势:

  1. 这些高级提示/指令场景通常由专业专家打造,超越一般主流用户的专业水平。
  2. 场景可由AI专家与领域专家联合设计,涵盖企业销售、交付等多样化业务。
  3. 场景执行顺序由提示驱动,灵活且无固定次序,避免了任务硬编码,极大提升适应性。

然而,尽管隐式任务规划最大化灵活性,在构建面向业务的系统时,我们必须在灵活性和成本效益间取得平衡。因此,在某些情况下,显式指令(例如用户界面中选择触发网页搜索)可以显著降低潜在成本,如图3.1所示:

image.png

我们自动化生成式AI模型通过Pinecone索引中的向量相似度搜索选择的隐式场景越多,成本就越高。为此,我们必须认真考虑收益递减规律:

B=B0+(r×C)(d×C2)B = B_0 + (r \times C) - (d \times C^2)

如图3.1所示的公式中(单位为理论值),具体含义如下:

  • BB 表示整体收益,当成本达到50时,大约为15个收益单位。
  • B0B_0 表示将指令场景存入Pinecone索引,并让生成式AI模型通过与用户输入的向量相似度选择场景所带来的初始收益。在此情况下,成本1单位带来近1单位收益。
  • rr 是收益随成本增加开始上升的速率。
  • CC 表示成本,计量单位为理论单位(可代表货币、人力或计算资源等)。
  • dd 表示随着成本增加,收益递减的速率。

举例来说,当成本达到7个理论单位时,收益也达到7个理论单位,体现了成本1单位产生收益1单位的合理关系。但当收益达到10单位时,成本可能翻倍至14单位,表明投入与回报间出现问题。

收益递减因子dd对收益NINI有显著负面影响,其影响程度与成本的平方成正比:

NI=(d×C2)NI = - (d \times C^2)

在本书的各个使用案例中,我们将密切关注该因子dd。我们需要在通过Pinecone索引运行隐式自动化场景选择和通过提示中的预定义指令显式触发动作之间做出权衡。

接下来,我们来探讨如何在Pinecone索引中识别指令场景。

混合检索与链式思维(CoT)

我们的第一步是让GenAISys框架能够清晰区分经典数据与指令场景。为此,我们将在同一个Pinecone索引中,使用两个命名空间将指令场景和数据分开,索引名称为 genai-v1:

  • genaisys:存放指令向量信息
  • data01:存放数据向量信息

我们将在本章“创建Pinecone索引”部分,通过代码实现 genai-v1,并附加详细说明。

当Pinecone索引完成场景与数据的分区后,我们即可通过混合检索技术,将GenAISys提升到一个新的层次,如图3.2所示。

image.png

前述图示的混合检索框架将使GenAISys实现以下功能:

  • 直接使用经过处理、分块和嵌入的数据记忆运行生成式AI模型(参见图3.2中的步骤1–3),而无需通过Pinecone索引(见3B),从而降低对短暂数据的成本。
  • 在将分块和嵌入的数据作为指令场景或经典数据写入Pinecone索引后,再运行生成式AI模型。
  • 在Pinecone索引(见3B)与生成式AI模型控制器(作为编排器)之间创建链式思维(CoT)循环。例如,模型输出可作为另一个CoT循环的输入,用于从Pinecone索引检索场景或数据。类似ChatGPT的助手通常先展示输出,然后询问是否需要进一步探索,甚至提供可点击的后续提示。
  • CoT循环既可通过向量相似度搜索隐式执行,也可通过直接指令触发或任务标签(如“执行网页搜索”)显式执行。例如,类似ChatGPT的助手可通过用户界面或AI控制器中的规则直接触发网页搜索。

本章将开始构建我们的GenAISys,并在接下来的章节中不断完善。从第四章“构建AI控制器编排界面”起,我们将基于本章介绍的RAG基础,开发如图3.2所示的混合检索框架。我们构建的GenAISys将具备动态流程管理,以适应不断变化的市场环境。具体而言,GenAISys将实现:

  • 利用Pinecone向量数据库或内存中的分块嵌入信息,通过相似度搜索优化指令或数据的检索。
  • 显式触发直接指令(如网页搜索),并将其纳入用于摘要、情感分析或语义分析的CoT循环。
  • 将复杂的指令集和数据检索拆解为可管理的步骤。
  • 在输出结果前,以类似人类思考的过程反复迭代完善解决方案。
  • 通过提供优化的指令场景,充分发挥包括OpenAI推理模型(如o3)在内的生成式AI模型的潜力。

本章首要任务是构建 genai-v1 Pinecone索引,供AI控制器管理 genaisys 命名空间中的指令场景。随后,我们将演示如何对经典数据进行分块、嵌入,并写入 data01 命名空间。让我们开始吧!

构建动态的 Pinecone 索引

我们将重点创建一个用于管理指令场景和经典数据的 Pinecone 索引。在接下来的章节中,我们会开始将指令场景和经典数据写入索引。整个流程分为三个简单阶段:

  1. 设置 OpenAI 和 Pinecone 的运行环境
  2. 处理数据,对其进行分块并生成嵌入向量
  3. 初始化 Pinecone 索引

请打开 GitHub 上 Chapter03 目录中的 Pinecone_instruction_scenarios.ipynb 笔记本(地址:github.com/Denis2054/B…)。我们的第一项任务是搭建环境。

环境搭建

随着书中内容的推进,我们会不断复用第一章和第二章中实现的函数和功能,新增 Pinecone 相关内容,并将安装步骤分为两部分:

  • 使用与第一章相同的流程安装 OpenAI。如果需要,可回看第一章。
  • 安装 Pinecone,用于本章及后续章节。

首先,从 GitHub 仓库下载所需文件 grequests.py,命令如下:

!curl -L https://raw.githubusercontent.com/Denis2054/Building-Business-Ready-Generative-AI-Systems/master/commons/grequests.py --output grequests.py

安装 OpenAI 部分请按照第一章中的步骤进行。接下来我们将安装 Pinecone,后续章节中也会用到它。

安装 Pinecone

下载包含本书所用 Pinecone 版本安装指令的依赖文件。如果需要使用其他版本,只需更新此文件即可:

download("commons","requirements02.py")

该文件包含安装函数,我们通过以下命令调用它:

# 运行安装脚本以安装并导入依赖
%run requirements02

该脚本与第一章中用于安装 OpenAI 的脚本类似,但针对 Pinecone 进行了适配。
脚本先卸载现有的 Pinecone,然后安装所需版本:

import subprocess
import sys

def run_command(command):
    try:
        subprocess.check_call(command)
    except subprocess.CalledProcessError as e:
        print(f"命令执行失败: {' '.join(command)}\n错误信息: {e}")
        sys.exit(1)

# 卸载 'pinecone-client' 包
print("正在卸载 'pinecone-client'...")
run_command(
    [sys.executable, "-m", "pip", "uninstall", "-y", "pinecone-client"]
)

# 安装指定版本的 'pinecone-client'
print("正在安装 'pinecone-client' 版本 5.0.1...")
run_command(
    [
        sys.executable, "-m", "pip", "install",
        "--force-reinstall", "pinecone-client==5.0.1"
    ]
)

然后,我们验证安装是否成功:

# 验证安装
try:
    import pinecone
    print(f"'pinecone-client' 版本 {pinecone.__version__} 已成功安装。")
except ImportError:
    print("安装后无法导入 'pinecone-client' 库。")
    sys.exit(1)

输出结果显示客户端安装成功:

正在卸载 'pinecone-client'...
正在安装 'pinecone-client' 版本 5.0.1...
'pinecone-client' 版本 5.0.1 已成功安装。

接下来,我们继续初始化 Pinecone API 密钥。

初始化 Pinecone API 密钥

程序接下来会下载 pinecone_setup.py,用于初始化 Pinecone API 密钥:

download("commons","pinecone_setup.py")

该设置类似于第一章中我们为 OpenAI 使用的基于 Google Colab secrets 的方法,不过这里适配用于初始化 Pinecone API:

# 导入库
import openai
import os
from google.colab import userdata

# 初始化 Pinecone API 密钥的函数
def initialize_pinecone_api():
    # 通过名称获取密钥
    PINECONE_API_KEY = userdata.get('PINECONE_API_KEY')
  
    if not PINECONE_API_KEY:
        raise ValueError("userdata 中未设置 PINECONE_API_KEY!")
  
    # 设置环境变量
    os.environ['PINECONE_API_KEY'] = PINECONE_API_KEY
    print("PINECONE_API_KEY 初始化成功。")

如果本笔记本的 OpenAI 部分 google_secrets 设置为 True,则会调用 Pinecone 初始化函数:

if google_secrets == True:
    import pinecone_setup
    pinecone_setup.initialize_pinecone_api()

如果 google_secrets 设置为 False,则可以取消注释代码,自行实现初始化方法并填写 Pinecone API 密钥:

if google_secrets == False:  # 取消注释并选择任意方法初始化 Pinecone API 密钥
    import os
    # PINECONE_API_KEY = [你的Pinecone API密钥]
    # os.environ['PINECONE_API_KEY'] = PINECONE_API_KEY
    # openai.api_key = os.getenv("PINECONE_API_KEY")
    # print("Pinecone API密钥初始化成功。")

现在程序已准备好处理将写入 Pinecone 索引的数据。

处理数据

我们的目标是准备好场景数据,以便存储和检索,之后能对Pinecone索引进行查询。该过程的主要步骤如图3.2所示,这只是接下来章节路线图中的一层。我们将按以下步骤处理数据:

  • 数据加载与准备,将数据拆分为更小的部分。本例中,每个场景存储在场景列表的一行,为分块处理做准备。但后续在“将经典数据写入索引”部分,我们会看到不总是按行拆分文本。
  • 分块功能,将每行场景拆分成多个块。
  • 对拆分出的文本块进行向量嵌入。
  • 验证嵌入块的数量是否正确。

接下来介绍前两步:加载准备数据和分块。

数据加载与分块

我们将使用第二章中实现的场景数据,存储在一个文件里,现下载该文件:

download("Chapter03","scenario.csv")

在本书后续内容中,我们会添加更多场景,以构建完整的 GenAISys。目前主要目标是让 Pinecone 索引正常运行。程序首先初始化计时变量 start_time,然后逐行读取指令场景数据,直接作为分块:

import time
start_time = time.time()  # 开始计时

# 文件路径
file_path = 'scenario.csv'

# 读取文件,跳过表头,清洗每行作为块
chunks = []
with open(file_path, 'r') as file:
    next(file)  # 跳过表头行
    chunks = [line.strip() for line in file]  # 读取并清理每行作为块

接着,程序打印分块数量及创建耗时:

response_time = time.time() - start_time  # 计算耗时
print(f"Response Time: {response_time:.2f} seconds")  # 输出耗时

示例输出:

Total number of chunks: 3
Response Time: 0.00 seconds

程序随后验证前三个场景分块内容:

# 可选:打印前三个分块以供验证
for i, chunk in enumerate(chunks[:3], start=1):
    print(chunk)

输出显示本章将处理的三个场景示例:

['ID,SCENARIO\n',
 '100,Semantic analysis.This is not an analysis but a semantic search. Provide more information on the topic.\n',
 '200,Sentiment analysis  Read the content return a sentiment analysis nalysis on this text and provide a score with the label named : Sentiment analysis score followed by a numerical value between 0 and 1  with no + or - sign and  add an explanation to justify the score.\n',
 '300,Semantic analysis.This is not an analysis but a semantic search. Provide more information on the topic.\n']

数据分块已准备好进行向量嵌入,接下来开始嵌入步骤。

数据集向量嵌入

为了对数据集进行向量嵌入,首先需要初始化嵌入模型,然后对分块数据进行嵌入。程序首先完成嵌入模型的初始化。

嵌入模型初始化

我们将使用 OpenAI 的嵌入模型来生成数据向量。OpenAI 提供三种主要的嵌入模型可选:

  • text-embedding-3-small:速度快、资源消耗低,适合实时使用。模型较小,成本较低。但随着向量存储规模因复杂场景增长,可能在细微任务上准确度略低。
  • text-embedding-3-large:准确度高,能生成细致的嵌入,适合复杂的语义相似度搜索。资源需求和成本较高。
  • text-embedding-ada-002:性价比高,能生成质量较好的嵌入,但速度略慢于上述两款模型。

更多信息可参考 OpenAI 官方文档:platform.openai.com/docs/guides…

本章为优化速度和成本,选择使用 text-embedding-3-small 来导入有限数量的场景。程序中初始化模型时,其他模型被注释掉备用:

import openai
import time

embedding_model = "text-embedding-3-small"
# embedding_model = "text-embedding-3-large"
# embedding_model = "text-embedding-ada-002"

接着初始化 OpenAI 客户端:

# 初始化 OpenAI 客户端
client = openai.OpenAI()

然后定义一个嵌入函数,将传入的文本批量转为嵌入向量。该函数使用选定的嵌入模型(本例为 text-embedding-3-small):

def get_embedding(texts, model="text-embedding-3-small"):
    # 清理文本,替换换行符为空格
    texts = [text.replace("\n", " ") for text in texts]
    # 调用 API 生成嵌入
    response = client.embeddings.create(input=texts, model=model)
    # 从响应中提取嵌入向量
    embeddings = [res.embedding for res in response.data]
    return embeddings

至此,程序已准备好对分块文本进行向量嵌入。

分块向量嵌入

程序首先定义一个函数,用于对分块进行向量嵌入:

def embed_chunks(
    chunks, embedding_model="text-embedding-3-small",
    batch_size=1000, pause_time=3
):

函数参数说明:

  • chunks:需要嵌入的文本分块列表
  • embedding_model:使用的嵌入模型,如 text-embedding-3-small
  • batch_size:一次处理的分块数量,例如1000个分块为一批
  • pause_time:暂停时间(秒),用于控制调用速率,避免触发限流

接着初始化计时、存储嵌入的列表和批次数计数器:

start_time = time.time()  # 开始计时
embeddings = []  # 用于存储所有嵌入的列表
counter = 1  # 批次数计数器

程序准备按批处理分块:

# 按批处理分块
for i in range(0, len(chunks), batch_size):
    chunk_batch = chunks[i:i + batch_size]  # 选取当前批次分块

然后将当前批次分块发送给嵌入函数:

    # 获取当前批次的嵌入向量
    current_embeddings = get_embedding(
        chunk_batch, model=embedding_model
    )

将本批次的嵌入追加到最终结果列表:

    # 追加嵌入结果
    embeddings.extend(current_embeddings)

打印批处理进度并暂停(防止限流):

    print(f"Batch {counter} embedded.")
    counter += 1
    time.sleep(pause_time)  # 可根据需要调整或移除暂停

全部批次处理完成后,打印总耗时:

response_time = time.time() - start_time
print(f"Total Response Time: {response_time:.2f} seconds")

调用示例:

embeddings = embed_chunks(chunks)

输出示例,表示分块数据已成功嵌入:

Batch 1 embedded.
Total Response Time: 4.09 seconds

打印第一个嵌入向量以供验证:

print("First embedding:", embeddings[0])

输出示例确认嵌入生成:

First embedding: [0.017762450501322746, 0.041617266833782196, -0.024105189368128777, …]

最后检查嵌入数量是否与分块数量一致:

num_chunks = len(chunks)
print(f"Number of chunks: {num_chunks}")
print(f"Number of embeddings: {len(embeddings)}")

输出确认分块和嵌入过程基本成功:

Number of chunks: 3
Number of embeddings: 3

至此,分块和嵌入向量已准备好写入 Pinecone 索引。

创建 Pinecone 索引

我们将创建的 genai-v1 Pinecone 索引包含两个命名空间,如图3.3所示:

  • genaisys:指令场景仓库。这些提示驱动生成式AI的行为,也可触发传统功能如网页搜索。
  • data01:嵌入的经典数据,供 RAG 流水线查询使用。

image.png

我们首先导入两个类:

from pinecone import Pinecone, ServerlessSpec

其中,Pinecone 类是与 Pinecone 索引交互的主要接口,我们将用它来配置 Pinecone 的无服务器服务。

在继续之前,你需要注册一个 Pinecone 账户并获取 API 密钥。请务必在 www.pinecone.io/ 上核实相关服务费用。本章内容自成体系,你可以先阅读内容、注释和代码,再决定是否创建 Pinecone 账户。

账户设置完成后,我们需要获取并初始化 API 密钥:

# 从环境变量获取 API 密钥
api_key = os.environ.get('PINECONE_API_KEY')
if not api_key:
    raise ValueError("环境变量中未设置 PINECONE_API_KEY!")
# 初始化 Pinecone 客户端
pc = Pinecone(api_key=api_key)

接着导入规格类,定义索引名称 genai-v1,并初始化场景对应的第一个命名空间 genaisys

from pinecone import ServerlessSpec
index_name = "genai-v1"
namespace = "genaisys"

接下来需要做一个项目管理上的决策——是使用 Pinecone 云托管索引,还是选择 Amazon Web Services(AWS)?

cloud = os.environ.get('PINECONE_CLOUD') or 'aws'
region = os.environ.get('PINECONE_REGION') or 'us-east-1'
spec = ServerlessSpec(cloud=cloud, region=region)

代码会先检查环境变量 PINECONE_CLOUD 是否设置为使用 Pinecone 云。如果没有预设环境变量,则默认选择 AWS,区域默认为 us-east-1

更多信息可参考 Pinecone Python SDK 文档:docs.pinecone.io/reference/p…

选择 AWS 的原因包括:

  • 市场领导地位与可靠性:AWS 占据全球基础设施市场超过30%的份额,被大量组织视为可靠。
  • 合规与安全标准:AWS 遵循140多项数据安全与隐私标准,包括 PCI-DSS、HIPAA/HITECH、FedRAMP、GDPR、FIPS 140-2 和 NIST 800-171。
  • 可扩展性:AWS 拥有全球数据中心网络,实现无缝扩展。

你也可以在 Pinecone 控制台手动创建索引,选择嵌入模型和托管主机(如 AWS 或 Google Cloud Platform),还可以根据需求选择 pod 大小(从 x1 起),这将决定索引的最大规模。具体选择取决于你的项目和资源优化策略。

无论如何,我们都需要监控使用情况和成本。Pinecone 提供详细的使用指标,通过账户即可访问,帮助你高效管理索引。例如,你可能想删除不再需要的信息、添加目标数据或优化用户的资源使用。

Pinecone 提供三项关键指标:

  • 无服务器存储使用量:以 GB-小时计费,成本基于每小时 1GB 存储。控制存储量是 AI 项目中的重要因素。
  • 无服务器写操作单元:衡量对 Pinecone 数据库执行写操作消耗的资源。
  • 无服务器读操作单元:衡量读操作消耗的资源。

你可以登录 Pinecone 账户,进入“Usage(使用情况)”页面,点击“Download(下载)”按钮,获取详细的消费信息,界面如下:

image.png

下载的文件为 CSV 格式,详细记录了我们在 Pinecone 的使用情况,包括以下字段:

  • BillingAccountId(账户标识)
  • BillingAccountName(账户名称)
  • OrganizationName(组织名称)
  • OrganizationId(组织ID)
  • ProjectId(项目标识)
  • ProjectName(项目名称)
  • ResourceId(资源标识)
  • ResourceName(资源名称)
  • ChargePeriodStart(计费周期开始日期)
  • ChargePeriodEnd(计费周期结束日期)
  • BillingPeriodStart(账单周期开始日期)
  • BillingPeriodEnd(账单周期结束日期)
  • SkuId(SKU标识)
  • SkuPriceId(SKU价格ID)
  • ServiceName(服务名称)
  • ChargeDescription(计费详情)
  • CloudId(云服务提供商)
  • RegionId(区域)
  • Currency(货币类型)
  • PricingQuantity(使用量)
  • PricingUnit(使用单位)
  • ListCost(标价费用)
  • EffectiveCost(实际费用)
  • BilledCost(最终计费金额)
  • Metadata(附加数据)

随着 AI 逐步迈入工业时代,远离了2020年代初期的热潮,持续监控这些指标变得愈发重要。

接下来,我们检查所选索引是否存在。程序导入 pineconetime 库,在检查前插入等待时间:

import time
import pinecone

# 检查索引是否已存在(如果是首次运行,应该不存在)
if index_name not in pc.list_indexes().names():
    # 如果不存在,则创建索引
    pc.create_index(
        index_name,
        dimension=1536,  # 嵌入向量的维度
        metric='cosine',  # 使用余弦相似度作为距离度量
        spec=spec  # 之前定义的区域和无服务器规格
    )
    # 等待索引初始化完成
    time.sleep(1)

参数说明:

  • index_name:Pinecone 索引名称,此处为 genai-v1
  • dimension=1536:嵌入向量的维度
  • metric='cosine':设置相似度搜索的距离度量为余弦相似度
  • spec=spec:定义云服务区域及无服务器规格
  • time.sleep(1):暂停 1 秒,确保索引完全创建后再继续

如果索引刚创建,输出会显示索引详情,且 total_vector_count 为0(若显示非0,说明笔记本之前已运行):

{'dimension': 1536,
 'index_fullness': 0.0,
 'namespaces': {},
 'total_vector_count': 0}

如果索引已存在,统计信息将显示,包括 index_fullness(索引 pod 使用空间,范围0到1):

Index stats
{'dimension': 1536,
 'index_fullness': 0.0,
 'namespaces': {'genaisys': {'vector_count': 3}},
 'total_vector_count': 3}

此时,我们尚未填充索引。可以连接刚创建的索引,查看其统计信息:

# 连接索引
index = pc.Index(index_name)
# 查看索引统计信息
index.describe_index_stats()

输出确认已连接:

{'dimension': 1536,
 'index_fullness': 0.0,
 'namespaces': {'genaisys': {'vector_count': 0}},
 'total_vector_count': 0}

所选嵌入模型必须与 Pinecone 索引维度匹配(1536)。我们将在第五章处理具体用例时,交互式创建 Pinecone 索引参数。这里我们使用 embedding_model="text-embedding-3-small",其维度为1536,与 Pinecone 索引匹配。

另外,之前初始化的 genaisys 命名空间被考虑在内,确保我们写入的指令场景不会与同一索引中另一个命名空间里的经典数据混淆。

现在,我们已准备好将数据写入 Pinecone 索引。

写入指令场景到索引

将嵌入后的分块写入 Pinecone 索引是有成本的,如本节开头所述,因此我们必须谨慎决定写入哪些数据。如果全部写入,可能会带来以下问题:

  • 导致索引过载,检索变得困难,无论是指令场景还是经典数据
  • 推高写入和读取操作的成本
  • 产生难以控制的噪声,干扰检索功能

如果选择不写入数据,有两种方案:

  1. 实时内存查询:将分块嵌入数据加载到内存中实时查询,能缓解数据存储压力,适合无需持久存储的临时信息(如每日天气预报)。不过,需要权衡此方式与每步写入的成本/收益,特别是从第五章开始的用例。
  2. 微调数据:构建训练集需投入人力和计算资源。在快速变化的市场环境下,可能需要频繁微调,成本较高。项目管理团队需权衡微调与 RAG 的成本收益关系,相关分析将在第五章展开。

我们先初始化库并开始计时以测量脚本执行时间:

import pinecone
import time
import sys

start_time = time.time()  # 计时开始

程序计算发送给 Pinecone 的批次最大大小,设置为 4000000 字节(约4MB)以保证安全,如超过限制则返回当前批大小:

# 计算批次大小的函数
def get_batch_size(data, limit=4000000):  # 限制设为4MB以安全为主
    total_size = 0
    batch_size = 0
    for item in data:
        item_size = sum([sys.getsizeof(v) for v in item.values()])
        if total_size + item_size > limit:
            break
        total_size += item_size
        batch_size += 1
    return batch_size

接下来定义带命名空间的写入函数:

# 带命名空间的写入函数
def upsert_to_pinecone(batch, batch_size, namespace="genaisys"):
    """
    将一批数据写入指定命名空间的 Pinecone 索引。
    """
    try:
        index.upsert(vectors=batch, namespace=namespace)
        print(f"已写入 {batch_size} 个向量到命名空间 '{namespace}'。")
    except Exception as e:
        print(f"写入时出错:{e}")

在生产环境中遇错一般会退出,但为教学目的,这里仅打印错误便于观察。

注意,我们将指令场景写入 Pinecone 索引中的 genaisys 命名空间。主批量写入函数如下:

def batch_upsert(data):
    total = len(data)
    i = 0
    while i < total:
        batch_size = get_batch_size(data[i:])
        batch = data[i:i + batch_size]
        if batch:
            upsert_to_pinecone(batch, batch_size, namespace="genaisys")
            i += batch_size
            print(f"已写入 {i}/{total} 条数据...")  # 显示进度
        else:
            break
    print("写入完成。")

写入完成后,输出成功信息,表明准备好开始写入流程。Pinecone 索引需要 ID,我们先生成它们:

# 为每条数据生成唯一ID
ids = [str(i) for i in range(1, len(chunks) + 1)]

生成 ID 后,格式化数据以符合 Pinecone 索引结构:

# 准备写入数据格式
data_for_upsert = [
    {"id": str(id), "values": emb, "metadata": {"text": chunk}}
    for id, (chunk, emb) in zip(ids, zip(chunks, embeddings))
]

数据包含 ID、向量值(embeddings)和元数据(文本分块)。调用批量写入函数开始写入:

# 批量写入数据
batch_upsert(data_for_upsert)

写入完成后,显示写入数量和耗时:

Upserted 3 vectors to namespace 'genaisys'.
Upserted 3/3 items...
Upsert complete.
Upsertion response time: 0.45 seconds

我们还可以查看 Pinecone 索引的统计信息:

# 可能需等待数秒,给 Pinecone 时间更新索引信息
print("Index stats")
print(index.describe_index_stats(include_metadata=True))

注意,需等待几秒钟让 Pinecone 更新索引状态。输出示例:

Index stats
{'dimension': 1536,
 'index_fullness': 0.0,
 'namespaces': {'genaisys': {'vector_count': 3}},
 'total_vector_count': 3}

信息说明:

  • 'dimension': 1536:嵌入向量维度
  • 'index_fullness': 0.0:索引使用率,范围0~1,需监控以优化写入数据,避免扩容压力。详情见 docs.pinecone.io/guides/get-…
  • 'namespaces': {'genaisys': {'vector_count': 3}}:显示命名空间及对应向量数
  • 'total_vector_count': 3:索引中向量总数

现在,我们已准备好将经典数据写入其对应的命名空间。

写入经典数据到索引

构建 GenAISys 是一个团队协作的过程。为了让各团队能并行工作、优化生产周期,我们将把经典数据的写入放在独立的程序或笔记本中进行。一组团队负责指令场景,另一组团队则负责数据的收集和处理。

请打开 Pinecone_RAG.ipynb。我们将复用本章“构建动态 Pinecone 索引”部分中 Pinecone_instruction_scenarios.ipynb 笔记本的多个组件。环境搭建与之前的笔记本相同,Pinecone 索引依然是 genai-v1

用于写入源数据的命名空间是 data01,这是我们在前面章节中已确定的,以保证数据与指令场景分开存储。唯一的实际差异在于加载的数据内容和分块方法。

接下来,开始动手吧!

数据加载与分块

本节将使用与 Pinecone_instruction_scenarios.ipynb 中指令场景相同的流程对数据进行分块嵌入,但这次由 GPT-4o 来完成分块操作。导入指令场景时,我们希望保持每个场景的完整性,作为一个分块,方便向生成式AI模型提供完整的指令集。而这次,我们将利用生成式AI的能力,使用 GPT-4o 对原始文本进行分块。

首先下载数据文件(而非指令场景),并设置文件路径:

download("Chapter03","data01.txt")
# 读取文本文件
file_path = '/content/data01.txt'

将文本文件整体加载为一个大分块,并打印出来:

try:
    with open(file_path, 'r') as file:
        text = file.read()
    text
except FileNotFoundError:
    text = "Error: File not found. Please check the file path."
print(text)

虽然生产环境遇到文件未找到通常会退出,但此教学笔记本中打印错误有助于观察结果不中断学习流程。你也可以注释掉 print(text),或只打印部分内容。此处先验证文件导入是否正确,输出示例:

The CTO was explaining that a business-ready generative AI system (GenAISys) offers functionality similar to ChatGPT-like platforms…

文本包含我们上传至定制 RAG 数据库的公司 CTO 留言。一家公司可能有成千上万条此类内部消息,数量庞大且波动频繁,不适合用于模型微调。仅将关键分块存入 Pinecone,可实现可搜索的上下文而避免噪声泛滥。

接下来准备让 GPT-4o 对文本进行分块。第一步是创建 OpenAI 实例,并给 GPT-4o 下达分块指令:

# 导入库
from openai import OpenAI
# 初始化 OpenAI 客户端
client = OpenAI()
# 定义使用 GPT-4o 分块文本的函数
def chunk_text_with_gpt4o(text):
    # 准备 GPT-4o 消息
    messages = [
        {"role": "system", "content": "You are an assistant skilled at splitting long texts into meaningful, semantically coherent chunks of 50-100 words each."},
        {"role": "user", "content": f"Split the following text into meaningful chunks:\n\n{text}"}
    ]

接着发送请求给 GPT-4o API:

    # 调用 GPT-4o API
    response = client.chat.completions.create(
        model="gpt-4o",  # GPT-4o 模型
        messages=messages,
        temperature=0.2,  # 低随机性保证分块一致性
        max_tokens=1024  # 允许的最大响应长度
    )

需要注意 max_tokens=1024 限制:GPT-4o 达到该限制即停止生成。对于非常大的文档,可以分片发送,让 GPT-4o 逐片优化分块。也可使用现成的分块函数获得更细致准确的检索效果。不过本例中,为最大化 GPT-4o 能力,我们一次调用发送整个文件,温度较低,以观察模型对真实文档的端到端分块。

然后从响应中提取分块文本,清理后存入列表返回:

    # 提取并清理响应
    chunked_text = response.choices[0].message.content
    chunks = chunked_text.split("\n\n")  # 假设 GPT-4o 以双换行分隔分块
    return chunks

调用分块函数,不必显示分块内容,生产环境可注释掉打印,但这里先验证效果:

# 分块文本
chunks = chunk_text_with_gpt4o(text)
# 显示分块
print("Chunks:")
for i, chunk in enumerate(chunks):
    print(f"\nChunk {i+1}:")
    print(chunk)

输出示例显示分块成功:

Chunks:
Chunk 1:
The CTO was explaining that …
Chunk 2:
GenAISys relies on a generative AI model…
Chunk 3:
We defined memoryless, short-term, long-term…

后续的向量嵌入和写入步骤与 Pinecone_instruction_scenarios.ipynb 完全相同,唯一区别是写入时指定命名空间为 namespace="data01"。完成后即可查询索引,验证检索效果。

查询 Pinecone 索引

如你所知,我们的向量存储现在分为两个逻辑区域——genaisys 用于指令场景,data01 用于经典数据。本节将对这两个区域进行交互式查询,以验证检索代码的正确性,确保在第四章多用户界面集成前功能正常。我们将查询 Pinecone 索引中的这两个命名空间,如图3.5所示:

image.png

打开 Query_Pinecone.ipynb 来运行验证查询。接下来的步骤与“环境搭建”和“创建 Pinecone 索引”章节中的内容相同,只有两个小区别:

  1. 连接 Pinecone 索引时只提供索引名称 index_name = 'genai-v1',不指定命名空间,因为查询函数会管理命名空间的选择。
  2. 笔记本中的写入(Upserting)部分已被移除,因为本节只进行查询操作。

笔记本的查询(Query)部分分为两个小节:第一个包含查询函数,第二个包含查询请求。我们先从查询函数开始。

查询函数

共有四个查询函数,分别是:

  • QF1:query_vector_store(query_text, namespace)
    接收查询文本,调用 QF2 发送请求,并返回响应。同时利用 QF4 展示结果。
  • QF2:get_query_results(query_text, namespace)
    接收 QF1 传来的查询文本,调用 QF3 生成嵌入,执行实际查询,然后将响应返回给 QF1。
  • QF3:get_embedding(text, model=embedding_model)
    接收文本,生成向量嵌入后返回给 QF2。
  • QF4:display_results(query_results)
    接收 QF1 返回的查询结果,处理后展示给用户。

如图3.6所示,可以将这四个函数简化为两组:

  • 第一组:包含 QF1(query_vector_store)和 QF4(display_results),其中 QF1 通过 QF2 查询向量存储,并将结果返回给 QF4 进行展示。
  • 第二组:包含 QF2(get_query_results),它调用 QF3(get_embedding)对查询文本生成嵌入后,执行向量查询,并将结果返回给 QF1。

image.png

让我们先来看第一组函数。

查询向量存储并返回结果

第一个函数 QF1 接收用户输入:

def query_vector_store(query_text, namespace):
    print("Querying vector store...")

然后,该函数调用 QF2,即 get_query_results 来获取查询结果:

    # 获取查询结果
    query_results = get_query_results(query_text, namespace)

QF2 返回的结果存储在 query_results 中,随后传给 display_results 来提取文本和目标 ID:

    # 处理并展示查询结果
    print("Processed query results:")
    text, target_id = display_results(query_results)
    return text, target_id

display_results 函数会处理接收到的查询结果,返回结果文本及其对应的元数据中的文本内容。当找到对应数据时,函数会获取该条目的 ID:

def display_results(query_results):
    for match in query_results['matches']:
        print(f"ID: {match['id']}, Score: {match['score']}")
        if 'metadata' in match and 'text' in match['metadata']:
            text = match['metadata']['text']
            #print(f"Text: {match['metadata']['text']}")
            target_id = query_results['matches'][0]['id']  # 获取第一个匹配项的 ID
            #print(f"Target ID: {target_id}")
        else:
            print("No metadata available.")
    return text, target_id

文本和 ID 被返回给 QF1 的 query_vector_store,该函数在被调用时返回最终结果。

请注意,出于教学目的,该函数假设 query_results 中始终至少有一个匹配项,并且该匹配项包含 metadatatext 字段。

接下来,我们来看查询是如何被处理的。

查询处理

程序使用 get_query_results 函数对 Pinecone 索引进行查询,输入参数为查询文本和命名空间。但首先必须将输入文本进行向量嵌入,以便在向量存储中执行相似度搜索:

def get_query_results(query_text, namespace):
    # 从查询文本生成查询向量
    query_vector = get_embedding(query_text)  # 替换为你生成嵌入的方法

完成嵌入后,使用向量化的输入在指定命名空间内发起向量搜索:

    # 执行查询
    query_results = index.query(
        vector=query_vector,
        namespace=namespace,
        top_k=1,  # 根据需要调整返回结果数量
        include_metadata=True  # 包含对应的元数据
    )

示例中 top_k 设置为1,表示仅返回最相关的一个结果,以保证查询精度。同时设置 include_metadata=True,以便返回对应文本内容。查询结果返回给 QF2,即 query_results

    # 返回查询结果
    return query_results

嵌入函数与之前写入 Pinecone 索引时使用的相同:

import openai
client = openai.OpenAI()
embedding_model = "text-embedding-3-small"

def get_embedding(text, model=embedding_model):
    text = text.replace("\n", " ")
    response = client.embeddings.create(input=[text], model=model)
    embedding = response.data[0].embedding
    return embedding

确保查询文本使用的嵌入模型与写入数据时相同,这样生成的嵌入向量格式才一致,对于相似度搜索的准确性至关重要。

现在,我们准备执行两个测试:一个针对指令场景的查询(命名空间 genaisys),另一个针对源数据的查询(命名空间 data01)。

检索查询

为了检索指令场景,我们输入用户查询文本和命名空间,让系统找到最接近的指令执行:

# 定义命名空间和查询文本
namespace = "genaisys"  # 示例命名空间
query_text = "The customers like the idea of travelling and learning. Provide your sentiment."

系统应能简要识别任务需求,返回完整的指令场景。为此,我们调用查询函数入口 query_vector_store 并展示返回结果:

# 调用查询函数
text, target_id = query_vector_store(query_text, namespace)
# 显示最终输出
print("Final output:")
print(f"Text: {text}")
print(f"Target ID: {target_id}")

输出结果令人满意,可在第四章会话循环中直接使用:

Querying vector store...
Processed query results:
ID: 2, Score: 0.221010014
Querying response time: 0.54 seconds
Final output:
Text: 200,Sentiment analysis  Read the content return a sentiment analysis nalysis on this text and provide a score with the label named : Sentiment analysis score followed by a numerical value between 0 and 1  with no + or - sign and  add an explanation to justify the score.
Target ID: 2

接下来程序从 Pinecone 索引中检索数据。查询函数保持不变,因为命名空间是变量。下面看针对经典数据命名空间的查询及输出:

# 定义命名空间和查询文本
namespace = "data01"  # 示例命名空间
query_text = "What did the CTO say about the different types of memory?"

打印查询结果:

# 显示最终输出
print("Final output:")
print(f"Text: {text}")
print(f"Target ID: {target_id}")

输出结果令人满意:

Querying vector store...
Processed query results:
ID: 3, Score: 0.571151137
Querying response time: 0.45 seconds
Final output:
Text: We defined memoryless, short-term, long-term memory, and cross-topic memory. For the hybrid travel marketing campaign, we will distinguish semantic memory (facts) from episodic memory (personal events in time, for example). The CTO said that we will need to use episodic memories of past customer trips to make the semantic aspects of our trips more engaging.
Target ID: 3

至此,我们完成了 Pinecone 向量存储的写入和查询。接下来,在为 GenAISys 添加更多功能层之前,我们先总结 Pinecone 索引的实现过程。

总结

本章中,我们推动了 GenAISys 项目向前迈进了一大步,突破了传统的 RAG 框架。首先,我们在源数据语料库之上叠加了专家编写的指令场景,将静态的 RAG 流水线转变为一个动态框架,不仅可以检索事实信息,还能获取模型应遵循的精准推理模式。全球市场节奏加快,用户期望在需求出现的瞬间就获得类似 ChatGPT 级别的辅助;为了跟上这一节奏,我们的架构必须具备灵活性、成本意识,并能实现近实时响应。

我们首先规划了架构设计,接着引入收益递减规律,用以判断隐式相似度搜索何时值得其计算开销,何时直接调用显式功能(如简单网页搜索)会更经济。在理论基础上,我们编写程序,下载、分块、生成嵌入并写入指令场景,存储于 Pinecone 索引的专用命名空间中。随后,利用 GPT-4o 对源文档执行相同的分块与嵌入操作,将向量存储在第二个命名空间。一旦两部分数据准备完毕,我们验证了检索层:单一查询函数即可将任意提示路由至正确命名空间,并返回最佳匹配及其元数据。

通过将指令场景和数据清晰分离且即时可检索,GenAISys 拥有了所需的检索骨干。在下一章,我们将把这些组件接入会话循环,让系统展现其全面、面向业务的敏捷能力。