langchain(二)

消息

消息是聊天模型中的通信单位,⽤于表示聊天模型的 输入输出 ,以及可能与对话关联的任何其 他 上下文或元数据 。

LLM消息结构

每条消息都有⼀个角色和内容,以及因 LLM 的不同而不同的附加元数据。

消息角色(Role) :用来区分对话中不同类型的消息,并帮助聊天模型了解如何响应给定的消息序 列。

|-----------------|-----------------------------------------------|
| 角色 | 描述 |
| system(系统角色) | 用于告诉聊天模型如何⾏为并提供额外的上下⽂。并⾮所有聊天模型提供 商都⽀持。 |
| user(用户角色) | 表示用户与模型交互的输⼊,通常以⽂本或其他交互式输⼊的形式。 |
| assistant(助理角色) | 表示来自模型的响应,其中可以包括⽂本或调用工具的请求。 |
| tool(工具角色) | ⽤于在检索外部数据或将⼯具调⽤的结果传递回模型的消息。与⽀持⼯具 调⽤的聊天模型⼀起使⽤。 |

消息内容 (Content):表⽰多模态数据 (例如,图像、⾳频、视频)的消息⽂本或字典列表的内 容。内容的具体格式可能因底层不同的 LLM ⽽异。⽬前,⼤多数模型都⽀持文本作为主要内容类 型,对多模态数据的⽀持仍然有限。

消息其他元数据 (Additional metadata)

|------------|---------------------------------|
| 元数据 | 描述 |
| ID | 消息标识符。 |
| Name | 名称允许区分具有相同⻆⾊的不同实体。并⾮所有型号都⽀持此功能! |
| Metadata | 有关消息的其他信息,例如时间戳、令牌使⽤情况等。 |
| Tool Calls | 模型发出的⼀个或多个⼯具的调⽤请求 |

Langchain消息

LangChain 提供了⼀种统⼀的消息格式,可以跨聊天模型 使用,允许⽤⼾使⽤不同的聊天模型,而无 需担心每个模型提供商使⽤的消息格式的具体细节。

这些模型提供商不同,但对于其输⼊和输出,统⼀使⽤ LangChain 的消息格式。LangChain 消息格式 主要分为五种,分别是:

|----------------|---------------------------|------------------------------------------------------------------|
| 消息类型 | 对应角色 | 描述 |
| SystemMessage | 对应 system 系统⻆⾊ | ⽤于启动 AI 模型的⾏为并提供额外的上下文,例如指⽰ 模型采⽤特定⻆⾊或设定对话的基调(例如,"你是⼀ 个后端开发的专家")。 |
| HumanMessage | 对应 user ⽤⼾⻆⾊ | ⼈类消息表⽰⽤⼾与模型交互的输⼊。 ⼤多数聊天模型都希望⽤⼾输⼊采⽤⽂本形式。 |
| AIMessage | 对应 assistant 助理⻆⾊ | 这是来⾃模型的响应,其中可以包括⽂本或调⽤⼯具的 请求。它还可能包括其他媒体类型,如图像、⾳频或视 频⸺尽管这⽬前仍然不常⻅。 |
| AIMessageChunk | 对应 assistant 助理⻆⾊, ⽤于流式响应 | 通常在⽣成聊天模型时流式传输响应,因此⽤⼾可以实 时看到响应,⽽不是等待⽣成整个响应后再显⽰。 |
| ToolMessage | 对应 tool 工具角色 | 这表示⼀条角色为"tool"的消息,其中包含调⽤⼯具 的结果。 |

这几个消息类型,它们都是 LangChain BaseMessage 的子类,全部是作为 LangChain 聊天模型的输⼊和输出!!

BaseMessage 介绍

class langchain_core.messages.base.BaseMessage 是作为 LangChain 聊天模型的输 入和输出!!

参数如下:

• content :消息的字符串内容。

• additional_kwargs :与消息关联的其他有效负载数据。对于来⾃ AI 的消息,可能包括模 型提供程序编码的⼯具调⽤。

• response_metadata :响应元数据。例如:响应标头、logprobs、令牌计数、模型名称。

• type :消息的类型。必须是消息类型唯⼀的字符串。此字段的⽬的是在对消息进⾏反序列化时 ⽅便地识别消息类型。

• name :消息名称,为消息提供⼀个⼈类可读的名称。该字段的使⽤是可选的,是否使⽤它取决 于模型实现。

• id :消息的可选唯⼀标识符。理想情况下,这应该由创建消息的提供者/模型提供。

内置方法:

pretty_print() **→**None :打印消息的漂亮表示。

pretty_repr(html: bool = False) **→**str :获得消息的漂亮表示。

◦ 请求:是否将消息格式化为 HTML。如果为 True,则消息将使⽤ HTML 标记进⾏格式化。默 认值为 False。

◦ 响应:这是消息的漂亮表示。

text() **→**str :获取消息的⽂本内容。

对话模式

大多数对话都以设置对话上下⽂的系统消息开始。接下来是包含用户输⼊的用户消息,然后是包含模 型响应的助⼿消息。如下图所示:

缓存历史消息

多轮对话

在与大型语⾔模型交互的过程中,我们常常体验到与智能助⼿进⾏连贯多轮对话的便利性。但⽬前我 们的系统还不支持此功能。

稍作修改,让我们将 AI 回复给我们的响应跟着新的用户消息⼀起发给聊天模型试试。只要将历史消息,重新发送给聊天模型,那么就可以实现多轮对话的功能。

内存缓存

那么对于历史消息的管理就显得尤为重要。在 LangChain 老版本中,可以使用 RunnableWithMessageHistory 消息历史类来包装另⼀个 Runnable 并为其管理聊天消息历史 记录。它将跟踪模型的输⼊和输出,并将其存储在某个数据存储中。未来的交互将加载这些消息,并 将其作为输⼊的⼀部分传递给链。

class langchain_core.runnables.history.RunnableWithMessageHistory 类初始 化参数说明:

• runnable :被包装 Runnable 实例,这⾥就是我们定义的聊天模型

• get_session_history :返回类型为 BaseChatMessageHistory 的函数,传⼊后作为回 调函数。此函数接受⼀个 session_id 字符串类型,并返回相应的聊天消息历史记录实例。

class langchain_core.runnables.history.RunnableWithMessageHistory 类⽅法 说明:

• .invoke() ⽅法:此⽅法与其他 Runnable 实例的 .invoke() ⽅法相同。只不过注意其 config配置,需要配置成 config={"configurable": {"session_id": ""}} ,让 RunnableWithMessageHistory 可以读取到会话id。

python 复制代码
# 内存缓存
# 根据会话id 查询会话里的消息列表
store = {}
def get_session_history(session_id : str) -> BaseChatMessageHistory:
    if session_id not in store:
        # InMemoryChatMessageHistory() 帮助我们将AIMessage、HumanMessage等消息自动添加进来
        store[session_id] = InMemoryChatMessageHistory()
    return store[session_id]

# 包装了 model,让model具备存储历史消息的能力
with_history_message_model = RunnableWithMessageHistory(model, get_session_history)

# model是一个Runnable 实例
# invoke中的config参数 就是配置这里Runnable 实例
config={"configurable": {"session_id": "1"}}# 配置读取哪一个会话id的消息列表
with_history_message_model.invoke(
    [HumanMessage(content="你好,我是小明")],
    config= config,
).pretty_print()

with_history_message_model.invoke(
    [HumanMessage(content="你知道我是谁吗?")],
    config= config,
).pretty_print()

输出结果如下:

这样记忆功能实现了。但是目前的历史消息只存在于内存中,并没有持久化,当程序结束,这样的历史消息也就没有了。另外这样的方法是老版本中的,目前已经不被推荐了。从 LangChain 的 v0.3 版本开始,官⽅建议 LangChain 用户不要使用 RunnableWithMessageHistory ,⽽是利⽤ LangGraph 持久性 来完成。

原因是它们的功能有限,不太适合现实世界的对话式 AI 应⽤程序。这些内存抽象缺乏对多用户、多对 话场景的内置⽀持,⽽这对于实际的对话式⼈⼯智能系统⾄关重要。这些实现中的⼤多数已在 LangChain 0.3.x 中被正式弃用,取⽽代之的是 LangGraph 持久性 。 LangGraph 持久性 ⾮常灵 活,可以⽀持⽐ RunnableWithMessageHistory 接⼝更⼴泛的⽤例。

在之前,对于生产环境, 我们还需要使⽤聊天消息历史记录的持久化实现,例如 RedisChatMessageHistory() ,而不 是 InMemoryChatMessageHistory() ,但现在也已不推荐新应⽤使用它们了。

管理历史消息

前置概念
上下文窗口

管理历史消息,⽆⾮就是理解如何"管理","管理"无非也就是⼀些 "CRUD"。那么在了解如何 管理消息之前,需要先了解下多轮对话的核⼼概念:上下文窗口。上下⽂窗⼝可以理解为模型的"短 期⼯作记忆区",即 LLM 在⼀次处理请求时,所能查看和处理的最大 Token 数量,它包含了:

• 用户的输⼊

• 大模型的输出

• 有时还包括系统指令(SystemMessage)和对话历史。

Token

在⾃然语⾔处理(NLP)中,Token 是⽂本的基本单位。它不是完全等同于⼀个单词或⼀个汉字,而 是⼀个更细粒度的划分。

为什么用 Token?计算机⽆法直接理解⽂字,它需要将⽂本转换为数字(向量)。Tokenization(令 牌化)就是这个转换过程的第⼀步,将句⼦分解成模型可以理解和处理的碎⽚。

• 对于英文: 1个Token ~= 4个字符或0.75个单词 ,1000 个 Tokens 约等于 750 个英文单词。 ⼀个 Token 可以是⼀个单词(如 "apple" )、⼀个词根(如 "un" 在 "unlikely" 中), 或者⼀个标点符号(如 "." )。例如, "ChatGPT is great!" 可能会被分成 ["Chat", "G", "PT", " is", " great", "!"] 这 6 个 Token。

• 对于中文: 1个汉字 ~= 1.5-2个Tokens ,1000 个 Tokens ⼤约相当于 500-700 个汉字。常见 的词和字可能是⼀个 Token,⽣僻字或复杂词可能会被拆分成多个。

消息管理

常见的消息管理的方式有消息裁剪、消息过滤和消息合并。

消息裁剪

有了上下⽂窗⼝和 Token 的认知,再来看多轮对话的实现原理,其实就是:

• 输⼊ = 系统消息 + 对话历史 + 最新用户问题

• 对于模型来说,并不真正"记忆",⽽是每次都将完整的上下⽂重新输⼊。

由于所有模型的上下⽂窗口大小都是有限的,这意味着作为输⼊的 Token 也是有限的。如果有累积了 很长的消息历史记录,则需要管理传递给模型的消息的⻓度。

trim_messages 可用于将聊天历史记录的大小减小为指定的令牌计数或指定的消息计数。

基于输入 Token 数的修剪
python 复制代码
model = ChatOpenAI(model="gpt-4o-mini",)

messages = [
    SystemMessage(content="you're a good assistant"),
    HumanMessage(content="hi! I'm bob"),
    AIMessage(content="hi!"),
    HumanMessage(content="I like vanilla ice cream"),
    AIMessage(content="nice"),
    HumanMessage(content="whats 2 + 2"),
    AIMessage(content="4"),
    HumanMessage(content="thanks"),
    AIMessage(content="no problem!"),
    HumanMessage(content="having fun?"),
    AIMessage(content="yes!"),
    HumanMessage(content="What's my name?"),
]
print(model.invoke(messages))

未裁剪之前,消耗的token数

python 复制代码
model = ChatOpenAI(model="gpt-4o-mini",)
messages = [
    SystemMessage(content="you're a good assistant"),
    HumanMessage(content="hi! I'm bob"),
    AIMessage(content="hi!"),
    HumanMessage(content="I like vanilla ice cream"),
    AIMessage(content="nice"),
    HumanMessage(content="whats 2 + 2"),
    AIMessage(content="4"),
    HumanMessage(content="thanks"),
    AIMessage(content="no problem!"),
    HumanMessage(content="having fun?"),
    AIMessage(content="yes!"),
    HumanMessage(content="What's my name?"),
]

# trim
# 使用 trim_messages 减少发送给模型的消息数量,基于 token数 裁剪
trimmer = trim_messages(
    max_tokens=65,          # 修剪消息的最大令牌数,根据你想要的谈话长度来调整
    strategy="last",        # 修剪策略:
                            # "last"(默认):保留最后的消息。从后向前保留
                            # "first":保留最早的消息。从前向后保留
    token_counter=model,    # 传入一个函数或一个语言模型(因为语言模型有消息令牌计数方法)
    include_system=True,    # 如果想始终保留初始系统消息,可以指定 include_system=True
    allow_partial=False,    # 是否允许拆分消息的内容
    start_on="human",       # 如果需要确保我们的第一条消息(不包括系统消息)始终是特定类型,可以指定 start_on
)

# 先裁剪 再传入model
chain =  trimmer | model

print(chain.invoke(messages))

裁剪之后,消耗的token数

基于消息数的修剪
cpp 复制代码
model = ChatOpenAI(model="gpt-4o-mini",)

messages = [
    SystemMessage(content="you're a good assistant"),
    HumanMessage(content="hi! I'm bob"),
    AIMessage(content="hi!"),
    HumanMessage(content="I like vanilla ice cream"),
    AIMessage(content="nice"),
    HumanMessage(content="whats 2 + 2"),
    AIMessage(content="4"),
    HumanMessage(content="thanks"),
    AIMessage(content="no problem!"),
    HumanMessage(content="having fun?"),
    AIMessage(content="yes!"),
    HumanMessage(content="What's my name?"),
]

# 基于 消息数 裁剪
trimmer = trim_messages(
    max_tokens=11,          # 修剪消息的最大长度,根据你想要的谈话长度来调整
    strategy="last",        # 修剪策略:
                            # "last"(默认):保留最后的消息。
                            # "first":保留最早的消息。
    token_counter=len,      # 传入一个函数或一个语言模型(因为语言模型有消息令牌计数方法)
    include_system=True,    # 如果想始终保留初始系统消息,可以指定 include_system=True
    allow_partial=False,    # 是否允许拆分消息的内容
    start_on="human",       # 如果需要确保我们的第一条消息(不包括系统消息)始终是特定类型,可以指定 start_on
)

# 先裁剪 再传入model
chain =  trimmer | model

print(trimmer.invoke(messages))

消息结果如下,经换行处理

消息过滤

在更复杂的场景下,我们可能会使⽤消息列表来跟踪状态,例如我们可能只想将这个完整消息列表的 子集传递模型调⽤,⽽不是所有的历史记录。

filter_messages ⽅法则可以轻松地按类型、ID 或名称过滤 message。

python 复制代码
# 历史消息记录
messages = [
    SystemMessage("你是⼀个聊天助⼿", id="1"),
    HumanMessage("⽰例输⼊", id="2"),
    AIMessage("⽰例输出", id="3"),
    HumanMessage("真实输⼊", id="4"),
    AIMessage("真实输出", id="5"),
]

# 按照类型筛选
# print(filter_messages(include_types="human").invoke(messages))# 筛选出HumanMessage
# print(filter_messages(messages, include_types="human"))

# 按照id筛选
# print(filter_messages(messages, exclude_ids=["4"]))

# 按照id+类型筛选
print(filter_messages(messages, exclude_ids=["4"], include_types=[HumanMessage, AIMessage]))
消息合并

若我们的消息列表存在连续某种类型相同的消息,但实际上某些模型不支持传递相同类型的连续消 息。

对于这种情况,我们可以使⽤ merge_message_runs 方法轻松合并相同类型的连续消 息。

python 复制代码
# 合并 消息
# 历史消息
from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-4o-mini")
messages = [
    SystemMessage("你是⼀个聊天助⼿。"),
    SystemMessage("你总是以笑话回应。"),
    HumanMessage("为什么要使⽤ LangChain?"),
    HumanMessage("为什么要使⽤ LangGraph?"),
    AIMessage("因为当你试图让你的代码更有条理时,LangGraph 会让你感到"节点"是个好主意!"),
    AIMessage("不过别担⼼,它不会"分散"你的注意⼒!"),
    HumanMessage("选择LangChain还是LangGraph?"),
]

# 方式一
print(merge_message_runs(messages))
merge_message = merge_message_runs(messages)
model.invoke(merge_message).pretty_print()

# 方式二
# merge = merge_message_runs()
# chain = merge | model
# chain.invoke(messages).pretty_print()

部分输出结果

上面的两条SystemMessage就合并为了一条消息了。

提示词模板

提示词模板(Prompt Template)是 LangChain 的核心抽象之⼀,它被广泛应用于构建大语言模型 (LLM)应⽤的各个环节。简单来说,只要是需要动态、批量、或有结构地向大语言模型【发送请求】的地方,几乎都会用到提 示词模板

由此可得:提示词模板就是⼀个可复⽤的提示词蓝图,它允许我们动态地⽣成提⽰词,⽽不是每次都 手动编写完整的提示词。它类似于编程中的字符串格式化功能。你创建⼀个带有"占位符"的模板,然后在运行时,用具体的值(变量)填充这些占位符,从而⽣成⼀个最终发送给 LLM 的完整提⽰词。

提示词模板解决了以下⼏个核⼼问题:

  1. 可复⽤性: 只需定义⼀个模板,就可以⽤于⽆数个类似的查询。

  2. 关注点分离: 将提示词的结构和逻辑(工程)与具体的内容和数据分离开。提示工程师可以专注于 优化模板,而应用程序则负责提供变量值。

  3. ⼀致性: 确保发送给LLM的提⽰词结构统⼀,这有助于获得更稳定、可预测的输出结果。

  4. 可维护性: 如果需要修改提示词的风格或结构,只需修改⼀个模板文件,而不用在代码的⽆数个地 方进行修改。

字符串模板

LangChain 提供了 PromptTemplate 类来轻松实现这⼀功能。 PromptTemplate 实现了标准的 Runnable 接口。

class langchain_core.prompts.prompt.PromptTemplate 类,其参数如下:

• template :提示模板

• input_variables :需要其值作为提示输⼊的变量的名称列表。

内置方法

from_template() :从模板定义提⽰模板。⽅法返回了⼀个 PromptTemplate 实例

python 复制代码
model = ChatOpenAI(model="gpt-4o-mini")

# 定义文本提示词模板,Runnable 实例
# 方式一
prompt_template = PromptTemplate(
    template="介绍{city}的历史",
    input_variables=["city"],
)
print(prompt_template.invoke({"city": "重庆"}))

# 方式二
prompt_template = PromptTemplate.from_template("将文本从{language_from}翻译为{language_to}")

# 实例化模板
print(prompt_template.invoke({"language_from": "英文", "language_to": "中文"}))

运行结果

聊天消息模板

ChatPromptTemplate 模板:专为 LangChain 聊天模型设计。可以方便地构建包含 SystemMessage 、 HumanMessage 、 AIMessage 的消息模板。

python 复制代码
# 处理聊天消息的提示词模板
chat_prompt_template = ChatPromptTemplate(
    [
        ("system", "将文本从{language_from}翻译为{language_to}"),
        ("user", "{text}"),
    ]
)

# 实例化,实例化出来的就是一个消息列表
print(chat_prompt_template.invoke(
    {
        "language_from": "英文",
        "language_to": "中文",
        "text": "hello,what is your name?"
    }
))

运行结果,经过换行处理

由于 ChatPromptTemplate 同样也实现了标准的 Runnable 接⼝,因此我们还可以通过链来完成 调用。

消息占位符

在上⾯的 ChatPromptTemplate 中,我们看到了如何格式化两条消息,每条消息都是⼀个字符串。但 如果我们希望将一批消息插入到特定位置怎么办?

使用 MessagesPlaceholder 。 MessagesPlaceholder 负责在特定位置添加消息列表。

python 复制代码
chat_prompt_template = ChatPromptTemplate(
    [
        ("system", "将文本从{language_from}翻译为{language_to}"),
        MessagesPlaceholder("msgs"), # 消息占位符
        ("user", "{text}"),
        # ("ai", "")
    ]
)

messages_placeholder = [
    HumanMessage(content="hi, what is your name?"),
    AIMessage(content="你好,你叫什么名字?")
]

messages = chat_prompt_template.invoke(
    {
        "language_from": "英文",
        "language_to": "中文",
        "text": "hello,what is your age?",
        "msgs": messages_placeholder,
    }
)
print(messages)

打印结果

使用Langchain Hub上的提示词

LangChain Hub 是⼀个用于上传、浏览、拉取和管理提示词(prompts)的地⽅。

随着 LLM 的发展,提⽰变得越来越重要。LangChain 正在打造⼀个与像 GitHub 这样的传统平台, GitHub长期以来⼀直是共享和协作代码的⾸选平台。于是推出了 LangChain Hub 平台。 LangChain Hub 创建⼀个分享和发现 Prompt 的平台,使得开发者可以更容易地发现新⽤例和精炼提 示。 这⼀举措使提示⼯程师更容易合作,重复使⽤现有的提⽰,并对其进⾏微调以实现特定的结果, 从⽽加速对话代理和其他基于语⾔的应⽤程序的开发和部署。早期的时候 LangChain Hub 有 Prompt、Chain、Agent,现在只有Prompt。

Langchain Hub 官方网址

这里使用Langchain Hub下受欢迎程度的最高的 hardkothari/prompt-maker 提示词。

python 复制代码
from langchain_openai import ChatOpenAI
from langsmith import Client

client = Client()
# prompt 就是一个提示词模板,Runnable 实例
prompt = client.pull_prompt("hardkothari/prompt-maker")

model = ChatOpenAI(model="gpt-4o-mini")

chain = prompt | model

while True:
    task = input("\n你的任务是什么?(输出 quit 退出聊天\n")
    if task == "quit":
        break
    lazy_prompt = input("\n你当前任务对应的提示词是什么?(输出 quit 退出聊天\n")
    if lazy_prompt == "quit":
        break

    chain.invoke({"task": task, "lazy_prompt": lazy_prompt}).pretty_print()

输出

下面是输出针对已有提示词的进一步优化

少样本提示

少样本提示是⼀种通过向 LLM 提供少量具体⽰例或样本,来教会它如何执⾏某项特定任务的技术。提 高模型性能的最有效⽅法之⼀是给出⼀个【模型示例】指导⼤模型你想做什么、怎么做。

少样本提⽰则是在给出考题前,先给它看⼏道类似的、附有正确答案的例题。添加⽰例输⼊和预期 输出的技术给到模型提⽰,让模型通过例题来理解任务应该怎么做。

这能解决什么问题?LLM 虽然知识渊博,但有时我们需要它以⾮常特定的格式、⻛格或逻辑来回答问 题。提供正确的⽰例可以减少模型"胡说⼋道"或犯低级错误的概率,将其输出约束在你提供的范例 范围内。举个例⼦更能理解:

  1. 强制要求模型以特定的格式(如JSON、XML、特定的列表样式)输出结果。样例可以当作格式样 板。

  2. 有些任务很难⽤⽂字指令清晰描述(例如:"请用莎士比亚的风格写作")。提供几个例子比写长 篇大论的指令更有效。

  3. 对于需要多步推理的复杂任务,示例可以展示出思考链,引导模型遵循类似的推理路径。

实现少样本提示

实现少样本提⽰的第⼀步也是最重要的⼀步是提出⼀个好的⽰例数据集。好的⽰例应该在运⾏时相 关、清晰、信息丰富,并提供模型尚不知道的信息。

如何让⼤模型看懂这份示例呢?之前我们说过聊天模型读的是聊天消息。因此,接下来我们需要将示 例集实例化成聊天模型可以读懂的聊天消息。对于 LangChain 就需要创建⼀个 FewShotChatMessagePromptTemplate 对象来实例化⽰例集。 FewShotChatMessagePromptTemplate 是⼀个提⽰词模板,专门用来将示例集实例化为聊天消 息,

先来看看 class langchain_core.prompts.few_shot.FewShotChatMessagePromptTemplate ,它也实 现了标准的 Runnable 接⼝。

类初始化说明:

• examples :样本示例。

• example_prompt :ChatPromptTemplate,用于格式化单个示例

• prefix :放在⽰例前⾯的提⽰模板字符串。

• suffix :放在⽰例之后的提⽰模板字符串。

• input_variables :变量的名称列表,这些变量的值需要作为提⽰词的输⼊。

类⽅法说明:

• .invoke() ⽅法:此⽅法与其他 Runnable 实例的 .invoke() ⽅法类似。输⼊⼀个字典给 它,返回完整的提⽰内容 PromptValue :

◦ PromptValue 的 to_string() ⽅法可以将提⽰值作为【字符串】返回。

◦ PromptValue 的 to_messages() ⽅法可以将提⽰作为【消息列表】返回。

python 复制代码
# 案例: 参数
examples = [
    {"text": "hi, what is your name?", "output": "你好,你叫什么名字?"},
    {"text": "hi, what is your age?", "output": "你好,你多大了?"},
]

# 与案例关联的聊天消息模板
examples_prompt_template = ChatPromptTemplate(
    [
        ("user", "{text}"),
        ("ai", "{output}"),
    ]
)

# 将案例转换为 消息列表,插入到提示词模板中去?
# 少样本提示词模板 (Runnable 实例)
few_shot_prompt = FewShotChatMessagePromptTemplate(
    examples=examples,                         # 案例(参数)
    example_prompt=examples_prompt_template,   # ChatPromptTemplate 模板
)

print(few_shot_prompt.invoke({}).to_messages())

输出的提示词消息列表如下

完整实现如下

python 复制代码
from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate
from langchain_openai import ChatOpenAI

# 案例: 参数
examples = [
    {"text": "hi, what is your name?", "output": "你好,你叫什么名字?"},
    {"text": "hi, what is your age?", "output": "你好,你多大了?"},
]

# 与案例关联的聊天消息模板
examples_prompt_template = ChatPromptTemplate(
    [
        ("user", "{text}"),
        ("ai", "{output}"),
    ]
)

# 将案例转换为 消息列表,插入到提示词模板中去?
# 少样本提示词模板 (Runnable 实例)
few_shot_prompt = FewShotChatMessagePromptTemplate(
    examples=examples,                         # 案例(参数)
    example_prompt=examples_prompt_template,   # ChatPromptTemplate 模板
)

print(few_shot_prompt.invoke({}).to_messages())

# 最终提示词模板
chat_prompt_template = ChatPromptTemplate(
    [
        ("system", "将文本从{language_from}翻译为{language_to}"),
        # 示例
        few_shot_prompt,
        ("user", "{text}"),
    ]
)

model = ChatOpenAI(model="gpt-4o-mini")
chain = chat_prompt_template | model
chain.invoke(
    {
        "language_from": "英文",
        "language_to": "中文",
        "text": "hi, what is your favourite food?",
    }
).pretty_print()

运行结果

这样就完全不用告诉模型要做什么,就自动将英语翻译为了中文。因为之前传入的少量提示词模板已将思维链交给了模型。

使用案例

案例1:推理引导

python 复制代码
model = ChatOpenAI(model="gpt-4o-mini")

# 文本提示词模板
example_prompt = PromptTemplate.from_template("Question: {question}\n{answer}")

# 创建示例集
examples = [
    {
        "question": "李白和杜甫,谁更长寿?",
        "answer": """
        是否需要后续问题:是的。
        后续问题:李白享年多少岁?
        中间答案:李白享年61岁。
        后续问题:杜甫享年多少岁?
        中间答案:杜甫享年58岁。
        所以最终答案是:李白
        """
    },
    {
        "question": "腾讯的创始人什么时候出生?",
        "answer": """
        是否需要后续问题:是的。
        后续问题:腾讯的创始人是谁?
        中间答案:腾讯由马化腾创立。
        后续问题:马化腾什么时候出生?
        中间答案:马化腾出生于1971年10月29日。
        所以最终答案是:1971年10月29日
        """,
    },
    {
        "question": "孙中山的外祖父是谁?",
        "answer": """
        是否需要后续问题:是的。
        后续问题:孙中山的母亲是谁?
        中间答案:孙中山的母亲是杨太夫人。
        后续问题:杨太夫人的父亲是谁?
        中间答案:杨太夫人的父亲是杨胜辉。
        所以最终答案是:杨胜辉
        """,
    },
    {
        "question": "电影《红高粱》和《霸王别姬》的导演来自同一个国家吗?",
        "answer": """
        是否需要后续问题:是的。
        后续问题:《红高粱》的导演是谁?
        中间答案:《红高粱》的导演是张艺谋。
        后续问题:张艺来自哪里?
        中间答案:中国。
        后续问题:《霸王别姬》的导演是谁?
        中间答案:《霸王别姬》的导演是陈凯歌。
        后续问题:陈凯歌来自哪里?
        中间答案:中国。
        所以最终答案是:是
        """,
    },
]

# 消息通过提示词模板构建出来
# FewShotPromptTemplate 针对文本、消息
few_shot_prompt = FewShotPromptTemplate(
    examples=examples,
    example_prompt=example_prompt,  # PromptTemplate,用于格式化单个示例
    suffix="Question: {input}",     # suffix表示放在示例之后的模板字符串
    input_variables=["input"],      # 输入变量列表
)
# print(few_shot_prompt.invoke({"input": "《教父》和《星球大战》的导演是否来自一个国家?"}).to_string())
# print(few_shot_prompt.invoke({"input": "《教父》和《星球大战》的导演是否来自一个国家?"}).to_messages())


chain = few_shot_prompt | model
chain.invoke({"input": "《教父》和《星球大战》的导演是否来自一个国家?"}).pretty_print()

输出结果

这里完整的展现出了问题推到的过程,答案是具有一定的可信度的。而这里展现出的问题推导过程也是由少样本提示提供的思维链引导的。

案例2:使用示例数据增强 LangChain 信息提取能力

这里实现⼀个基于 LangChain 的结构化信息提取系统, 专门从文本中提取人物相关信息。tool_example_to_messages ⽅法说明:

tool_example_to_messages ⽅法说明:

• 此功能处于测试阶段。它正在积极开发中,因此 API 可能会发⽣变化。

• 此⽅法是⼀个⼯具⽅法,它可以将单个⽰例转换为聊天模型可以识别的消息列表。

• 参数说明:

◦ input :输⼊字符串

◦ tool_calls :list[BaseModel],表⽰为 Pydantic BaseModel 的⼯具调⽤列表

◦ ai_response :可选的。如果提供,将是最终 AIMessage 的内容。

• 返回值:list[BaseMessage] 消息列表

python 复制代码
# 定义大模型
model = ChatOpenAI(model="gpt-4o-mini")

# 1. 定义结构化输出
class Person(BaseModel):
    """一个人的信息。"""

    # 注意:
    # 1. 每个字段都是 Optional "可选的" ------ 允许 LLM 在不知道答案时输出 None。
    # 2. 每个字段都有一个 description "描述" ------ LLM使用这个描述。
    # 有一个好的描述可以帮助提高提取结果。
    name: Optional[str] = Field(default=None, description="这个人的名字")
    hair_color: Optional[str] = Field(default=None, description="如果知道这个人头发的颜色")
    skin_color: Optional[str] = Field(default=None, description="如果知道这个人的肤色")
    height_in_meters: Optional[str] = Field(default=None, description="以米为单位的高度")

class Data(BaseModel):
    """人员列表数据"""
    people: List[Person] = Field(description="人员列表")


# 2.定义示例(不是 Message)
examples = [
    (
        "海洋是广阔的、蓝色的。它有两万多英尺深",
        Data(people=[]),
    ),
    (
        "小明在跳舞,1米78的身高看起来很灵活",
        Data(people=[
            Person(name='小明', hair_color=None, skin_color=None, height_in_meters='1.78'),
        ]),
    ),
]

# 3. 定义提示词模板
prompt_template = ChatPromptTemplate(
    [
        SystemMessage(content="你是一个提取信息的专家,只从文本中提取相关信息。如果您不知道要提取的属性的值,属性值返回null"),
        MessagesPlaceholder("example_messages"), # 消息占位符,将示例转换为Message后插入进来
        ("user", "{new_message}"),
    ]
)

# 4. 将示例转换为Messages
example_messages=[]
for txt, tool_call in examples:
    if tool_call.people:
        ai_response = "检测到人"
    else:
        ai_response = "未检测到人"
    example_messages.extend(tool_example_to_messages(
        """以examples中第一个元组分析
         txt 相当于"海洋是广阔的、蓝色的。它有两万多英尺深",
         tool_call 相当于 Data(people=[]) 一个pydantic对象,
         而 tool_call相当去做一个适配,如果是一个pydantic对象, 就拿到对应的属性,反之就拿到空的属性"""
        txt,  # 示例的输入
        [tool_call],  # 工具( Data(people=[]) 准确的参考标准)参数的作用是指定工具调用的信息,这些信息将被转换成相应的消息。这里则是将pydantic对象指定为工具,并转换为pydantic对象相应的消息
        ai_response=ai_response  # 让 LLM 强制返回ai_response
    ))


# 下面是输出的是  将示例转换为Messages 的内容,对于上面的函数注释可以参考分析
# [
#   HumanMessage(content='海洋是广阔的、蓝色的。它有两万多英尺深', additional_kwargs={}, response_metadata={}),
#   AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'e7f433f7-be82-4258-8e7f-b993734b25bf', 'type': 'function', 'function': {'name': 'Data', 'arguments': '{"people":[]}'}}]}, response_metadata={}, tool_calls=[{'name': 'Data', 'args': {'people': []}, 'id': 'e7f433f7-be82-4258-8e7f-b993734b25bf', 'type': 'tool_call'}]),
#   ToolMessage(content='You have correctly called this tool.', tool_call_id='e7f433f7-be82-4258-8e7f-b993734b25bf'),
#   AIMessage(content='未检测到人', additional_kwargs={}, response_metadata={}),

#   HumanMessage(content='小明在跳舞,1米78的身高看起来很灵活', additional_kwargs={}, response_metadata={}),
#   AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'b241ecaf-c792-4f18-9ef6-39c3107d9c72', 'type': 'function', 'function': {'name': 'Data', 'arguments': '{"people":[{"name":"小明","hair_color":null,"skin_color":null,"height_in_meters":"1.78"}]}'}}]}, response_metadata={}, tool_calls=[{'name': 'Data', 'args': {'people': [{'name': '小明', 'hair_color': None, 'skin_color': None, 'height_in_meters': '1.78'}]}, 'id': 'b241ecaf-c792-4f18-9ef6-39c3107d9c72', 'type': 'tool_call'}]),
#   ToolMessage(content='You have correctly called this tool.', tool_call_id='b241ecaf-c792-4f18-9ef6-39c3107d9c72'),
#   AIMessage(content='检测到人', additional_kwargs={}, response_metadata={})
# ]
# print(example_messages)


# 5. 定义的结构化模型
structured_model = model.with_structured_output(schema=Data)

# 6. 定义链(没有强制结构化输出)
chain = prompt_template | structured_model
print(chain.invoke(
    {
        "example_messages": example_messages,
        "new_message": "篮球场上,身高两米的中锋王伟默契地将球传给一米七的后卫挚友李明,完成一记绝杀。这对老友用十年配合弥补了身高的差距。"
    }
))

上面代码将示例转换为Messages的过程需要认真梳理和查阅相关资料。

这里根据少样本提示提供区分人物和非人物的信息逻辑。

示例选择器

⼀旦我们有了示例数据集,就需要考虑提⽰中应该有多少个示例。关键的权衡是,更多的示例通常会 提高性能,但更大的示例会增加成本和延迟。超过某个阈值,太多示例可能会开始混淆模型。

找到正确数量的示例在很⼤程度上取决于模型、任务、示例的质量以及成本和延迟限制。有趣的是, 模型越好,它需要精准的示例就越少。但其实,最佳的⽅法是使⽤不同数量的示例进行⼀些实验。

若此时我们有【⼤量】的示例数据集。对于⼤模型来说,就没必要全部使⽤与参考。我们需要有⼀种 方法可以根据给定的输⼊,从数据集中选择⽰例。

在 LangChain 中,示例选择器就可以帮我们从⼀组【示例的集合】中根据具体策略选择正确的【示例 子集】构建少样本提示。

选择策略有:

• Length :根据特定【⻓度】内可以容纳的数量选择示例。

• Similarity :使⽤输⼊和⽰例之间的【语义相似性】来决定选择哪些示例。

• MMR :使⽤输⼊和⽰例之间的【最⼤边际相关性】来决定要选择哪些示例。

• Ngram :使⽤输⼊和⽰例之间的【ngram 重叠】来决定要选择哪些示例。

这些其实都是自然语言处理(NLP)⾥的相似性衡量问题。

按长度选择示例

当我们担⼼构造提⽰时,将超过上下⽂窗⼝⻓度,根据特定⻓度内可以容纳的数量选择⽰例。对于较 ⻓的输⼊,它将选择更少的⽰例来包含;⽽对于较短的输⼊,它将选择更多⽰例。

实现按⻓度选择⽰例的⽰例选择器是:class langchain_core.example_selectors.length_based.LengthBasedExampleSelecto r 类,

其参数如下:

• example_prompt :PromptTemplate,⽤于格式化⽰例的提⽰模板。

• examples :模板所需的⽰例列表。

• max_length :提⽰的最⼤⻓度,超过该⻓度将剪切⽰例。

• get_text_length :测量提⽰⻓度的⽅法。默认为字数统计。

内置方法:

add_example(example: dict[str, str]) :将新示例添加到列表中。

◦ 输⼊:⼀个字典,其中键作为输⼊变量,值作为其值。

select_examples(input_variables: dict[str, str]) **→**list[dict] :根据输 ⼊⻓度选择要使⽤的⽰例。

◦ 输⼊:⼀个字典,其中键作为输⼊变量,值作为其值。

◦ 输出:要包含在提⽰中的⽰例列表。

python 复制代码
# 反义词⽰例集合
examples = [
    {"input": "happy", "output": "sad"},
    {"input": "tall", "output": "short"},
    {"input": "energetic", "output": "lethargic"},
    {"input": "sunny", "output": "gloomy"},
    {"input": "windy", "output": "calm"},
]

# 示例模板(文本)
example_prompt = PromptTemplate.from_template("Input: {input}\nOutput: {output}")

# 示例选择器(长度)
example_selector = LengthBasedExampleSelector(
    examples=examples,
    example_prompt=example_prompt,
    max_length=25, # 25指的是格式化示例的最大长度
    # ⽤于获取字符串⻓度的函数,⽤于确定包含哪些⽰例。
    # 如果没有指定,它是作为默认值提供的。
    # 该函数返回⼀个整数,表⽰字符串中由换⾏符或空格分隔的"单词"数量
    # get_text_length: Callable[[str], int] = lambda x: len(re.split("\n| ",x))
)

# 少样本模板
# 转换Message
few_shot_prompt = FewShotPromptTemplate(
    example_selector= example_selector, # 添加 示例选择器
    example_prompt= example_prompt, # 格式化单个示例
    prefix="给出每个输入的反义词:", # 添加少样本提示的前缀
    suffix="Input: {adjective}\nOutput: ", # 添加少样本提示的后缀
    input_variables=["adjective"],
)
print(few_shot_prompt.invoke({"adjective": "big"}).to_string())

当示例中写的内容大多表示一种含义,就比较合适使用长度选择示例

按语义相似性选择示例

实现按语义相似性选择⽰例的⽰例选择器是: class langchain_core.example_selectors.semantic_similarity.SemanticSimilarity ExampleSelector 类,

内置方法:

from_examples() :根据示例集生成语义相似示例选择器

◦ 输⼊:

▪ examples :示例列表

▪ embeddings :初始化的嵌⼊ API 接口,如 OpenAIEmbeddings()

▪ vectorstore_cls :向量存储数据库接口类。

▪ k :最终要选择的⽰例的数量。默认值为 4。

◦ 输出:语义相似性示例选择器

add_example(example: dict[str, str]) :将新示例添加到列表中。

◦ 输⼊:⼀个字典,其中键作为输⼊变量,值作为其值。

select_examples(input_variables: dict[str, str]) **→**list[dict] :根据输 ⼊选择要使⽤的示例。

◦ 输⼊:⼀个字典,其中键作为输⼊变量,值作为其值。

◦ 输出:要包含在提示中的示例列表。

python 复制代码
# 反义词⽰例集合
examples = [
    {"input": "happy", "output": "sad"},
    {"input": "tall", "output": "short"},
    {"input": "energetic", "output": "lethargic"},
    {"input": "sunny", "output": "gloomy"},
    {"input": "windy", "output": "calm"},
]

# 示例模板(文本)
example_prompt = PromptTemplate.from_template("Input: {input}\nOutput: {output}")

# 示例选择器(语义相似性)
example_selector = SemanticSimilarityExampleSelector.from_examples(
    examples, # 示例集
    OpenAIEmbeddings(
        model="text-embedding-3-large"
    ), # 使用嵌入模型的能力度量语义
    Chroma, # 存储向量,向量数据库
    k=1, # 表示要生成示例的数量
)

# 少样本模板
# 转换Message
few_shot_prompt = FewShotPromptTemplate(
    example_selector= example_selector, # 添加 示例选择器
    example_prompt= example_prompt, # 格式化单个示例
    prefix="给出每个输入的反义词:", # 添加少样本提示的前缀
    suffix="Input: {adjective}\nOutput: ", # 添加少样本提示的后缀
    input_variables=["adjective"],
)
print(few_shot_prompt.invoke({"adjective": "worried"}).to_string())

运⾏前先安装 langchain-chroma 库: pip install -U langchain-chroma

运行结果

输⼊的内容和最后选择的示例都表示心情,在语义上最相似!

按最大边际相关性选择示例(MMR)

什么是最⼤边际相关性?它是⼀种重新排序算法,它使⽤语义相似性作为基础⼯具,从⼀个候选集中 挑选出⼀组既能代表查询主题又彼此多样化的结果

听起来好像和语义相似性类似,⽤⼀个例⼦看下两者的区别:

• 【语义相似性】就像⾯试官衡量每个应聘者与职位要求的匹配度。他会给每个应聘者打⼀个分数。

• 【最⼤边际相关性】就像团队经理(MMR算法)要组建⼀个团队。⽬标是选出⼀组"精华"结果, 而不是⼀个单⼀结果,也就是**根据语义相似性的结果做二次排序,**对单语义进行去重,多样化筛选

◦ 每个成员都要满⾜基本职位要求(满足相关性)。

◦ 但经理不希望团队⾥全是只会⼀种技能的程序员。他需要前端、后端、算法、测试等不同专⻓ 的⼈,以确保团队能⼒全⾯、减少冗余(新颖性/多样性)。

◦ 经理的策略是:先招⼀个最匹配的技术⼤⽜(第⼀步),然后接下来招的⼈,既要技术达标, ⼜要和已招的⼈技能互补(迭代过程)。

• 语义相似性使⽤场景:搜索引擎的基础排序、重复检测、聚类、语义搜索。

• MMR 使用场景:

◦ 推荐系统:推荐与用户兴趣相关但又不同类型的物品,避免"信息茧房"。

◦ 文档摘要:从⻓⽂档中选择能代表主旨⼜包含不同信息的句⼦,避免摘要内容重复。

◦ RAG (检索增强⽣成):在从知识库检索完⼀堆相关⽂档后,使⽤ MMR 进⾏去重和多样化筛选, 再交给LLM生成答案,能有效提升答案质量和减少幻觉。

了解了相关概念后,LangChain 提供了按最⼤边际相关性选择⽰例的能⼒,该⽰例选择器是: class langchain_core.example_selectors.semantic_similarity.MaxMarginalRelevan ceExampleSelector 类,

内置方法:

from_examples() :根据⽰例集⽣成 MMR 示例选择器

◦ 输⼊:

▪ examples :示例列表

▪ embeddings :初始化的嵌⼊ API 接⼝,如 OpenAIEmbeddings()

▪ vectorstore_cls :向量存储数据库接⼝类。

▪ k :最终要选择的示例的数量。默认值为 4。

◦ 输出:MMR 示例选择器

add_example(example: dict[str, str]) :将新⽰例添加到列表中。

◦ 输⼊:⼀个字典,其中键作为输⼊变量,值作为其值。

select_examples(input_variables: dict[str, str]) **→**list[dict] :根据输 ⼊选择要使⽤的⽰例。

◦ 输⼊:⼀个字典,其中键作为输⼊变量,值作为其值。

◦ 输出:要包含在提⽰中的⽰例列表。

python 复制代码
# 反义词⽰例集合
examples = [
    {"input": "happy", "output": "sad"},
    {"input": "tall", "output": "short"},
    {"input": "energetic", "output": "lethargic"},
    {"input": "sunny", "output": "gloomy"},
    {"input": "windy", "output": "calm"},
]

# 示例模板(文本)
example_prompt = PromptTemplate.from_template("Input: {input}\nOutput: {output}")

# 示例选择器(以语义相似性为基础的MMR算法)
example_selector = MaxMarginalRelevanceExampleSelector.from_examples(
    examples, # 示例集
    OpenAIEmbeddings(
        model="text-embedding-3-large"), # 使用嵌入模型的能力度量语义
    Chroma, # 存储向量,向量数据库
    k=3, # 表示要生成示例的数量
)

# 少样本模板
# 转换Message
few_shot_prompt = FewShotPromptTemplate(
    example_selector= example_selector, # 添加 示例选择器
    example_prompt= example_prompt, # 格式化单个示例
    prefix="给出每个输入的反义词:", # 添加少样本提示的前缀
    suffix="Input: {adjective}\nOutput: ", # 添加少样本提示的后缀
    input_variables=["adjective"],
)
print(few_shot_prompt.invoke({"adjective": "worried"}).to_string())

运行结果

通过ngram重叠选择示例

什么是【ngram】?ngram 指⼀个⽂本序列中连续的 n 个词(word) 或字符(character)。 什么是【ngram 重叠】?通过计算它们之间共同拥有的 ngram 数量来⼀种衡量两段⽂本相似度的⽅ 法。

例如下述两段⽂本:

text1 = "苹果⼿机很好⽤" (分词后: 苹果 ⼿机 很 好⽤ )

text2 = "这款⼿机很好⽤" (分词后: 这款 ⼿机 很 好⽤ )

这两段⽂本单词重复度很高,连续三个词的相同的情况也存在,因此 ngram 重叠高。

再看个例⼦:

text1 = 苹果⼿机很好⽤" (分词后: 苹果 ⼿机 很 好⽤ )

text2 = "iPhone 非常不错" (分词后: iPhone ⾮常 不错 )

这两段⽂本在含义上⾮常相似,但它们的 ngram 重叠度为 0。

因此,传统 ngram 重叠是⼀种表⾯形式的匹配。它只关⼼词是否完全⼀样,但对于同义词却⽆法处 理。

什么是【语义 ngram 重叠】?不再⽐较词本⾝,⽽是⽐较词背后的语义向量(Embedding)。也就是说,它不是看两个词 [苹果] 和 [iPhone] 的字⾯是否相同,⽽是计算它们在语义空间中的向量是 否相似。如果相似度超过某个阈值,就认为它们"重叠"了。

那么语义 ngram 重叠的使⽤场景是什么?语义 ngram 重叠常⽤于需要更精准语义评估的场景,例如 剽窃检测 , 能够发现那些改换了词汇但保留了核⼼思想的"智能"剽窃。

LangChain 实现按语义 ngram 重叠选择示例的⽰例选择器是: class langchain_community.example_selectors.ngram_overlap.NGramOverlapExample Selector 类,其参数如下:

• example_prompt :PromptTemplate,⽤于格式化示例的提示模板。

• examples :模板所需的示例列表。

• threshold :算法停⽌的阈值。默认设置为 -1.0。

◦ 对于负阈值:按 [重叠分数] 对⽰例进⾏排序,但不排除任何⽰例。

◦ 对于等于 0.0 的阈值:按 [重叠分数] 进⾏排序,并排除与输⼊没有 ngram 重叠的⽰例。

◦ 对于⼤于 1.0 的阈值:排除所有⽰例,并返回⼀个空列表。

内置方法:

• add_example(example: dict[str, str]) :将新⽰例添加到列表中。

◦ 输⼊:⼀个字典,其中键作为输⼊变量,值作为其值。

• select_examples(input_variables: dict[str, str]) **→**list[dict] :返回 根据输⼊得到的重叠分数排序的降序⽰例列表。

◦ 输⼊:⼀个字典,其中键作为输⼊变量,值作为其值。

◦ 输出:要包含在提⽰中的⽰例列表。

python 复制代码
# 示例模板(文本)
example_prompt = PromptTemplate.from_template("Input: {input}\nOutput: {output}")

examples = [
    {"input": "See Spot run.", "output": "看⻅Spot跑。"},
    {"input": "My dog barks.", "output": "我的狗叫。"},
    {"input": "Spot can run.", "output": "Spot可以跑。"},
]

# 示例选择器(NGram选择器)
example_selector = NGramOverlapExampleSelector(
    examples=examples, # 示例集
    example_prompt = example_prompt, # 格式化示例的提示模板
    threshold=0.0, # 阈值。
                  # 负值代表不相关的示例也被筛选出来。
                  # 0.0 的阈值:输出结果是只与输入重叠的⽰例。
                  # 1.0 的阈值:排除所有⽰例,返回⼀个空列表。
)
few_shot_prompt = FewShotPromptTemplate(
    example_selector= example_selector, # 添加 示例选择器
    example_prompt= example_prompt, # 格式化单个示例
    prefix="给出每个输入的中文翻译:", # 添加少样本提示的前缀
    suffix="Input: {sentence}\nOutput: ", # 添加少样本提示的后缀
    input_variables=["sentence"],
)
print(few_shot_prompt.invoke({"sentence": "Spot can run fast"}).to_string())

• 运行前先安装 nltk 库: pip install nltk 。它是自然语言处理工具包,在 NLP 领域中 最常使用的⼀个Python库。

• 运行前先安装 langchain_community 库: pip install -U langchain_community

运行结果

输出解析器

负责获取模型的输出,并将输出转换为更结构化的格式。当使⽤ LLM 生成结构化数据或规范化聊天模 型和 LLM 的输出时,这很有用。

大型语⾔模型(LLM)的输出本质上是非结构化的⽂本。但在构建应⽤程序时,我们通常希望得到结 构化的、机器可读的数据,这样可以将其转换为更适合下游任务的格式,比如:JSON 对象、Python 字典或列表、⼀个特定的 Pydantic 模型实例和⼀个简单的布尔值或字符串枚举。

输出解析器的作⽤就是架起这座桥梁:它们将 LLM 的⾮结构化⽂本输出转换为结构化格式。这使得与 LLM 的交互从"模糊的⽂本对话"变成了"精确的数据 API 调⽤",是构建可靠、⾼效 LLM 应⽤不可 或缺的组件。

与with_structured_output() 的区别

它们都实现了相同的功能,但

维度不同:输出解析器是 LangChain 中的⼀个功能性组件,with_structured_output() 是 聊天模型的⼀个⽅法。

用法不同:输出解析器的⼯作流程可以是链式的,可以将 Prompt、LLM 和 Parser 像管道⼀样连接 起来: chain = prompt | llm | parser 。⽽ with_structured_output(schema) 却不⾏,只能⼿动调⽤,返回⼀个新的、具备了结构化输出能力的Runnable(可运行对象)

解析文本输出

其实对于使⽤ StrOutputParser 输出解析器输出⽂本,我们已经使⽤过多次了。对于 StrOutputParser ,它也实现了标准的 Runnable 接口。

python 复制代码
model = ChatOpenAI(model="gpt-4o-mini")
chain = model | StrOutputParser()
for chunk in chain.stream("写⼀⾸夏天的诗词,50字以内。"):
print(chunk, end="|")

若是不使⽤输出解析器,⽽是直接得到聊天模型返回的 AIMessage,⽂本内容则需要从消息中的 content 字段获取。

解析结构化对象输出

要输出结构化对象,需要⽤到的输出解析器是 PydanticOutputParser 。 class langchain_core.output_parsers.pydantic.PydanticOutputParser 类,

参数如下

pydantic_object :要解析的 pydantic 模型。

内置方法

invoke() :将单个输⼊转换为输出。

get_format_instructions() **→**str :重要!!

• 作用:⽣成⼀个指令字符串,这个字符串会被添加到发送给 LLM 的提示(Prompt)的末尾。

• 目的:告诉 LLM 应该以什么样的格式返回它的响应。例如,"请将你的回复封装在 XML 标签 中"或"请以 JSON 格式输出,包含 'name' 和 'age' 两个字段"。

python 复制代码
# 定义聊天模型
model = ChatOpenAI(model="gpt-4o-mini",)

# Pydantic对象
class Joke(BaseModel):
    """给用户讲一个笑话"""

    setup: str = Field(description="这个笑话的开头"),
    punchline: str = Field(description="这个笑话的妙语"),
    rating: Optional[int] = Field(default=None,description="从1-10分,给这个笑话评分")

# 定义解析器
parser = PydanticOutputParser(pydantic_object=Joke)

# print(parser.get_format_instructions())

# 提示模板
prompt = PromptTemplate(
    template="回复用户问题。\n返回结构说明:{format_instructions}\n用户问题:{query}\n",
    partial_variables={"format_instructions": parser.get_format_instructions()}, # 将返回的结构作为提示词发送给大模型
    input_variables=["query"],
)
# print(prompt.invoke({"query": "将一个关于跳舞的笑话"}))

# 定义链
chain = prompt | model | parser
result = chain.invoke({"query": "将一个关于跳舞的笑话"})
print(result)

输出结果

其中get_format_instructions方法返回的结果,类似于schema结构。

解析JSON输出

要输出 JSON 格式,需要⽤到的输出解析器是 JsonOutputParser 。

class langchain_core.output_parsers.json.JsonOutputParser 类,

其参数如下:

pydantic_object :⽤于验证的 Pydantic 对象。如果为空,则不执⾏任何验证。

内置方法

invoke() :将单个输⼊转换为输出。

get_format_instructions() **→**str :重要!!

• 作⽤:⽣成⼀个指令字符串,这个字符串会被添加到发送给 LLM 的提⽰(Prompt)的末尾。

• ⽬的:告诉 LLM 应该以什么样的格式返回它的响应。例如,"请将你的回复封装在 XML 标签 中"或"请以 JSON 格式输出,包含 'name' 和 'age' 两个字段"。

python 复制代码
# 定义聊天模型
model = ChatOpenAI(model="gpt-4o-mini")

# Pydantic对象
class Joke(BaseModel):
    """给用户讲一个笑话"""

    setup: str = Field(description="这个笑话的开头"),
    punchline: str = Field(description="这个笑话的妙语"),
    rating: Optional[int] = Field(default=None,description="从1-10分,给这个笑话评分")

# 定义解析器
# JSON Schema 不好写
parser = JsonOutputParser(pydantic_object=Joke)

# print(parser.get_format_instructions())

# 提示模板
prompt = PromptTemplate(
    template="回复用户问题。\n返回结构说明:{format_instructions}\n用户问题:{query}\n",
    partial_variables={"format_instructions": parser.get_format_instructions()}, # 将返回的结构作为提示词发送给大模型
    input_variables=["query"],
)
# print(prompt.invoke({"query": "将一个关于跳舞的笑话"}))

# 定义链
chain = prompt | model | parser
result = chain.invoke({"query": "将一个关于跳舞的笑话"})
print(result)

输出结果

除了上面讲的⽂本、对象、JSON解析器,其实 LangChain 官⽅还提供了更多类型的解析器,如:

• XML 解析器: XMLOutputParser

• Yaml 解析器: YamlOutputParser

• CSV 解析器: CommaSeparatedListOutputParser

• 枚举解析器: EnumOutputParser

• 日期解析器: DatetimeOutputParser 等等,

更多类型参考https://reference.langchain.com/python/langchain_core/

除此之外,LangChain 还⽀持我们自定义输出解析器,以将模型输出结构化为⾃定义格式,详细情况 参考https://docs.langchain.com/oss/python/langchain/overview

文档加载器

RAG介绍

RAG(Retrieval-Augmented Generation,检索增强生成)。这是当前⼤语⾔模型应⽤的核⼼模式。RAG 的流程相对复杂,为了更好的理解 RAG,我们先⽤ AI 搜索 来引出 RAG。

• 对于【AI ⼤模型】来说,它最擅⻓的是语义理解和⽂本总结,最不擅⻓的就是获取实时的信息。因为大模型的训练数据是有截⽌⽇期的!

• 对于【搜索引擎】来说,它最擅⻓的就是获取实时的信息,缺点是信息分散,每次都需要⼈为进行 总结。

• 大模型与搜索引擎的结合,就是给 AI 配备了⼀个活字典,让 AI 可以随时进行查阅。

首先,先来思考⼀个问题:搜索引擎可以帮我们解决实时数据的获取,但获取到的数据也是受限的。 它只能获取到公开在⽹络中的数据,而无法获取到⼀些本地数据,或企业内部的私有数据等,此时该 如何?

答案是使用 RAG(检索增强生成)技术!当用户向 LLM 提问时,系统首先在知识库(如公司内部文 档)中进行语义搜索,找到最相关的内容,然后将这些内容和问题⼀起交给 LLM 来⽣成答案。与 AI 搜 索类比,本质是知识库改变了,从搜索引擎线上搜索改为了本地或私有知识库中搜索。

RAG流程

RAG 的流程分为【离线数据处理】和【在线检索 】两个过程。

上面提到,RAG 知识库可以是本地⽂档、公司内部文档等⼀些私有化数据。但这些私有数据或⽂档实 际上并不能很好地被直接进行检索访问。因此需要将这些私有化数据构建成可以被检索的知识库 ,这 就是离线数据处理要⼲的事情。经过离线数据后,知识则会按照某种格式以及排列⽅式存储在知识库 中,等待被使用。

在线检索则是我们依赖知识库查询,通过大模型生成结果的过程。

过程如下图所示:

这张图将会是我们后续要了解的 LangChain 组件知识地图,现在我们只 需掌握其流程,接触相关概念即可。

• 文档加载 (Document Loading):加载多种不同来源加载文档。LangChain 提供了 100 多种不同的 文档加载器,包括 PDF 在内的非结构化的数据、SQL 在内的结构化的数据,以及 Python、Java 之类的代码等。

• ⽂本分割 (Splitting):⽂本分割器把 Documents 切分为指定⼤⼩的块。

• 存储 (Storage):存储涉及到两个环节,分别是:

◦ 将切分好的⽂档块进行嵌⼊(Embedding),即将⽂档块转换成向量的形式。

◦ 将 Embedding 后的向量数据,存储到向量数据库中。

• 检索 (Retrieval):数据存入向量数据库后。当我们需要进行数据检索时,会通过某种检索算法找到 与输⼊问题相似的⽂档块。

• 输出 (Output):把问题以及检索出来的⽂档块⼀起提交给 LLM,LLM 会通过问题和检索出来的提 示⼀起来⽣成更加合理的答案。

Document 文档类

要想实现 RAG,首先就需要从源中获取数据,即加载数据或⽂档。这是通过 LangChain 的⽂档加载器 完成的。LangChain 文档加载器可以将各种数据源加载成⼀系列的文档对象 Document

class langchain_core.documents.base.Document ⽤于存储⼀段⽂本和相关元数据的 类,我们可以直接定义LangChain 文档列表,

python 复制代码
# 手动定义的文档列表
documents = [

    # 对于单个Document文档,它一般表示较大的文档的某个块或者某一页
    Document(
        # 内容
        page_content="狗是忠实的伴侣",
        # 元数据字典
        # 元数据属性可以包含:文档源,与其他文档的关系以及其他属性信息
        metadata={"source": "pets-doc"},
    ),
    Document(
        # 内容
        page_content="猫是独立的宠物",
        # 元数据字典
        # 元数据属性可以包含:文档源,与其他文档的关系以及其他属性信息
        metadata={"source": "pets-doc"},
    ),
]

加载PDF文档

将本地的 PDF ⽂档加载到 LangChain 中,其实就是将 PDF ⽂档转换为⼀个个 Document 对象。这时 就需要我们使⽤ PyPDFLoader 文档加载器完成这⼀功能。

class langchain_community.document_loaders.pdf.PyPDFLoader 类,有以下关键 函数:

• init() 初始化函数,⼊参 file_path ,表示要加载的 PDF ⽂件的路径。

• load() → list[Document] :将数据加载到⽂档对象中。返回⽂档对象列表。

python 复制代码
# 文档加载器(PDF)
loader = PyPDFLoader(file_path="../Docs/pdf/脚手架级微服务租房平台Q&A.pdf")
# 加载:生成文档列表
docs = loader.load()

# PDF加载器默认将文档按分页进行拆分
print(f"PDF文档总页数:\n{len(docs)}\n")
print(f"第一页文本的内容(前200)是:\n{docs[0].page_content[:200]}\n")
print(f"第一页的元数据字典是:\n{docs[0].metadata}\n")
print(f"第二页文本的内容(前200)是:\n{docs[1].page_content[:200]}\n")
print(f"第二页的元数据字典是:\n{docs[1].metadata}\n")

# PDF加载器将文本加载进来了,图片呢? 图片是没有办法加载进来的
print(f"第三页文本的内容(前200)是:\n{docs[2].page_content[:200]}\n")
print(f"第三页的元数据字典是:\n{docs[2].metadata}\n")
print(f"第三页:\n{docs[2]}\n")

# 目前的流程 PDF 文本 -》 Document -》 LLM
# 可以处理为 PDF 包含图片  -》 LLM (支持多模态)   # 直接将图片交给支持多模态的大模型处理可能是更加准确的!

这里需要提前安装以下 关于pdf的包,pip install pypdf

加载markdown文档

将本地的 Markdown ⽂档加载到 LangChain 中,需要我们使用 UnstructuredMarkdownLoader ⽂档加载器完成这⼀功能。 class langchain_community.document_loaders.markdown.UnstructuredMarkdownLoade r 类,有以下关键函数:

• init() 初始化函数,所需参数:

◦ file_path :表⽰要加载的 Markdown ⽂件的路径。

◦ mode :加载⽂件时要使⽤的模式。可以是 single 或 elements。默认为 single。

▪ single:文档将作为单个 Document 对象返回

▪ elements:会将⽂档拆分为 Title 和 NarrativeText 等不同类型的元素。

• load() → list[Document] :将数据加载到⽂档对象中。返回⽂档对象列表。

LangChain 实现的 UnstructuredMarkdownLoader 需要依赖 Unstructured 包。因此在使⽤前我 们需要先安装它:pip install "unstructured[md]" nltk

python 复制代码
# 文档加载器(MD)
md_loader = UnstructuredMarkdownLoader(
    "../Docs/markdown/脚手架级微服务租房平台Q&A.md",
    # mode="single",    # MD 加载器默认将文档加载为一个
    mode="elements",    # 拆分成不同类型的子块
)
# Document 列表
docs = md_loader.load()


print(f"MD文档总数:\n{len(docs)}\n")
print(f"第一个文档的内容是:\n{docs[0].page_content}\n")
# 'source': '../Docs/markdown/脚手架级微服务租房平台Q&A.md'
# 'category': 'Title',                              # 分类
# 'element_id': '3a0670f9bfd58576e430ef11def41593'  # 每个文档的唯一标识
print(f"第一个文档的元数据字典是:\n{docs[0].metadata}\n")

print(f"第二个文档的内容是:\n{docs[1].page_content}\n")
# 'source': '../Docs/markdown/脚手架级微服务租房平台Q&A.md'
# 'parent_id': '3a0670f9bfd58576e430ef11def41593',
# 'category': 'Title',
# 'element_id': 'fcb08b2a85942455eecebb9467ffca4c'
print(f"第二个文档的元数据字典是:\n{docs[1].metadata}\n")

print(f"第三个文档的内容是:\n{docs[2].page_content}\n")
# 'source': '../Docs/markdown/脚手架级微服务租房平台Q&A.md'
# 'parent_id': 'fcb08b2a85942455eecebb9467ffca4c',
# 'category': 'UncategorizedText',                    # 未分类文本
# 'element_id': 'a6fc0b5a457d21234bf1c4a6ae0a18db'
print(f"第三个文档的元数据字典是:\n{docs[2].metadata}\n")

# {
# 'Table',            表格
# 'Image',            图像
# 'NarrativeText',    叙事性文本
# 'Title',            标题
# 'ListItem',         列表项
# 'UncategorizedText' 未分类的文本
# }
print(f"当前MD文档的所有分类:{set(document.metadata['category'] for document in docs)}")

对于 LangChain 来说,能加载的⽂档类型远不⽌这些,它还能加载⽹⻚、⼀些云提供商⽂件、社交媒 体平台⽂档等,更多文档加载器见https://docs.langchain.com/oss/python/integrations/document_loaders

文本分割器

⽂档拆分通常是将大文本分解为更小的、易于管理的块。这对于索引数据并将其传递到模型中都很有 用。因为,大块更难搜索并且不适合模型的有限上下文窗口。拆分可以提高搜索结果的粒度,从而可 以更精确地将查询与相关⽂档部分进行匹配。

LangChain 的文本分割器便能将大型文档分解为更小的块。

根据文档长度与文档语义拆分

我们可以直接根据⽂档的⻓度拆分⽂档,是最简单且有效的⽅法。可确保每个块不超过指定的大小限 制。按照指定长度进行拆分,如果语义并未覆盖完,长度是能自动动态调整的,也就是长度是一个参考标准。对于长度拆分,其实也分为两种: 基于字符长度拆分 和 基于Token长度拆分 。

基于字符长度拆分

根据给定的字符序列进⾏拆分,拆分的块长度则按字符数来衡量。

python 复制代码
# single 模式,只生成一个大文档
loader = UnstructuredMarkdownLoader("../Docs/markdown/脚手架级微服务租房平台Q&A.md",)
# Document 列表
data = loader.load()

# 定义文本分割器
text_splitter = CharacterTextSplitter(
    separator="\n\n",        # 分割符。一般来说,有一个默认的分割符优先级列表:["\n\n", "\n", " "]
    chunk_size=400,          # 块大小(参考标准,为了保证段落/句子完整,会超出此设定的大小)
    chunk_overlap=50,        # 块重叠大小,让相邻的块有小部分重叠,保证语义完整性
    length_function=len,     # 测量字符长度的函数
    is_separator_regex=False,# 是否是正则表达式描写分隔符吗?
)

# 分割文档
documents = text_splitter.split_documents(data)
for document in documents[:10]:
    print("*" * 30)
    print(document)

部分输出解释

这里创建了1171大小的块,超出了代码里设定的400,这也说明了保证语义完整性是大前提,设定的块大小只是一个参考标准。

那么分割逻辑到底是什么,可以⽀持保持语义完整性?

• 尝试分割:

◦ ⾸先,它尝试⽤ separator (我们设置的是 "\n\n" 双换⾏,这通常代表段落之间)来分 割⽂本。如果分割后的任何⼀个段落仍然⼤于 chunk_size ,它会继续下⼀步。

• 如果仍然有单个单词或字符串的⻓度超过了 400 ,分割器就陷⼊了两难境地:

◦ 选项A:强⾏把这个⻓字符串在任意位置(⽐如第400个字符处)截断。但这会破坏单词、URL 或数据的完整性,导致⽣成⽆意义的⽚段(例如,把 "Christopher" 截断成 "Christop" 和 "her"),严重影响后续的嵌⼊或语⾔模型处理效果。

◦ 选项B:保留这个完整的、超⻓的字符串作为⼀个块,并记录⼀条信息告知⽤⼾。

如何应对和处理?增⼤ chunk_size :如果我们的大部分块都超长,可能是 chunk_size 设置得 太小了。尝试适当增大它。

基于 Token 长度拆分

LLM ⼤模型实际上并不是直接接收字符串,⽽是需要先做 token 切分编码。这⾥我们 可以借助 【 tiktoken 分词器】来进⾏ token 的切分编码。

python 复制代码
# single 模式,只生成一个大文档
loader = UnstructuredMarkdownLoader("../Docs/markdown/脚手架级微服务租房平台Q&A.md",)
# Document 列表
data = loader.load()

# tiktoken 分词器
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", # cl100k_base 是tiktoken 分词器中的一种编码方式
    chunk_size=400,              # 块token大小(参考标准,为了保证段落/句子完整,会超出此设定的大小)
    chunk_overlap=50,            # 块重叠大小
)

# 分割文档
documents = text_splitter.split_documents(data)
for document in documents[:10]:
    print("*" * 30)
    print(document)

ps:cl100k_base 是 tiktoken 分词器中的⼀种编码⽅式。 gpt-4 、 gpt-3.5-turbo 等 都采⽤这种切分编码⽅式。 可以看到采用切分编码 cl100k_base ,拆解后的文本字符串为 ["my", "name", "is", " Li", "H", "ua", "!"] 。token 编码表示为 [2465, 836, 374, 14851, 39, 4381, 0] 。使用根据 cl100k_base 编码⽅式 的 tiktoken 分词器来拆分文档。这对于 OpenAI 模型来说,会更准确。

硬性约束长度拆分

如果我们就想要求任何块都不能超过指定大小,可以使⽤ RecursiveCharacterTextSplitter类 或 RecursiveCharacterTextSplitter.from_tiktoken_encoder 方法,它会严格遵守对块 大小的硬约束。

python 复制代码
# single 模式,只生成一个大文档
loader = UnstructuredMarkdownLoader("../Docs/markdown/脚手架级微服务租房平台Q&A.md",)
# Document 列表
data = loader.load()

# # 强制按照约定的块大小分割文本
# text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
#     encoding_name="cl100k_base", # cl100k_base 是tiktoken 分词器中的一种编码方式
#     chunk_size=100,              # 块token大小(参考标准,为了保证段落/句子完整,会超出此设定的大小)
#     chunk_overlap=0,            # 块重叠大小
# )

text_splitter = RecursiveCharacterTextSplitter(
    separators=["\n\n", "\n", " "],        # 分割符。一般来说,有一个默认的分割符优先级列表:["\n\n", "\n", " "]
    chunk_size=100,          # 块大小(参考标准,为了保证段落/句子完整,会超出此设定的大小)
    chunk_overlap=0,        # 块重叠大小
    length_function=len,     # 测量字符长度的函数
    is_separator_regex=False,# 是否正则表达式描写分隔符吗?
)

# 分割文档
documents = text_splitter.split_documents(data)
for document in documents[:10]:
    print("*" * 30)
    print(document)

但这样,其实是剥夺了⼀些保证语义完整性的能⼒,某些含义相似的内容,被强制分开。除此之外,还要再说明的是,⽂本内容是中⽂时,使⽤默认分隔符列表 ["\n\n", "\n", " ", ""] 拆分⽂本可能会导致⼀个词组被拆分成两个字,导致语义失效。若要将词组放在⼀起,可以 覆盖分隔符列表以包含其他标点符号,例如中⽂的逗号 , 、句号 。 或其他中⽂符号,

python 复制代码
块text_splitter = RecursiveCharacterTextSplitter(
separators=[
    "\n\n",
    "\n",
    " ",
    "。",
    ",",
    "",
    ],
    # Existing args
)

这样在分割时将递归⽤ separators 来尝试分割⽂本:

• 首先,它尝试用 "\n\n" (双换⾏,通常代表段落)来分割,如果分割后的任何⼀个段落仍然大 于 chunk_size ,它会继续下⼀步。

• 接着,它尝试用 "\n" (单换⾏,通常代表行之间)来分割那些仍然过大的段落。

• 然后,它尝试用 " " (空格,单词之间)来分割。

• ....

特殊文档拆分

若对于代码等特殊⽂本,可以尝试使⽤ Language 提供的不同的分割器(如 PythonCodeTextSplitter 、 HTMLHeaderTextSplitter 等)效果会更好,它会理解代码的 语法结构。

这里了解下常见的拆分原则即可:

• Markdown:根据标头拆分(例如,#、##、###)

• HTML:使用标签拆分

• JSON: 按对象或数组元素拆分

• Code 代码 :按函数、类或逻辑块拆分

python 复制代码
# 字符串文档
PYTHON_CODE = """
    def hello_world():
        print("Hello, World!")
    
    def hello_python():
        print("Hello, Python!")
"""

# 分割器(python代码)
splitter = PythonCodeTextSplitter(
    chunk_size=50,
    chunk_overlap=0,
)

# 创建文档:遵守分割器规则
python_docs = splitter.create_documents([PYTHON_CODE])

for document in python_docs:
    print("*" * 30)
    print(document)

文本向量

嵌入与嵌入模型

计算机天⽣擅⻓处理数字,但不理解⽂字、图⽚的含义。嵌⼊(Embedding)的核心思想就是将⼈类 世界的符号(如单词、句⼦、产品、用户、图⽚)转换为计算机能够理解的数值形式(即向量,本质 上是⼀个数字列表),并且要求这种转换能够保留原始符号的语义和关系。

说明:我们之前⼀直⽤的⼤语⾔模型是生成式模型。它理解输⼊并生成新的⽂本(回答问题、写⽂ 章)。它内部实际上也使⽤嵌⼊技术来理解输⼊,但最终目标是"创造"。

而嵌⼊模型(Embedding Models)是表示型模型。它的目标不是⽣成⽂本,⽽是为输⼊的⽂本创建 ⼀个最佳的、富含语义的数值表示(向量)。如 OpenAI 的 "text-embedding-3-large" 嵌⼊模型; Google 的 "gemini-embedding-001" 嵌⼊模型;阿⾥的 "Qwen3-Embedding-8B" 嵌⼊模型等。

向量

⾸先我们要知道,嵌⼊的结果是就是⼀个向量,它本质上是⼀个数字列表(⼀维数组)。例如: [0.023, 0.487, -0.129, ..., 0.325] 。对于向量来说,有两个关键概念需要了解:

向量维度

嵌⼊结果得到的列表⻓度是固定的,称为向量的"维度"。例如,OpenAI 的 text-embedding-ada-002 模型会⽣成⼀个 1536 维的向量, text-embedding-3-large 模型会⽣成⼀个 3072 维 的向量。

维度越⾼,通常能捕捉更细微的语义信息,但也需要更多的计算和存储资源。

向量空间

想象⼀个⽆限延伸的、拥有⽆数个维度的宇宙,这个宇宙就是⼀个向量空间。这有点抽象,可以想象 ⼀下:

• 在三维世界⾥,⼀个点可以⽤ (x, y, z) 坐标表⽰,例如 (2, 5, -1) 。

• 在机器学习的⾼维向量空间中,⼀个点可能是 (0.1, 0.7, -0.2, 0.4, ..., 0.02) ,⼀ 个有⼏百或⼏千个数字的坐标。

在这个空间⾥,每个点(即每个向量)都能代表⼀个概念。例如在嵌⼊模型中,⼀个点可以代表⼀个 单词、⼀句话、⼀张图⽚、⼀个⽤⼾、⼀部电影等。

到这⾥,向量空间的威⼒就能体现出来:我们可以⽤数学来度量语义。可以通过计算两个向量之间 的"距离"或"相似度"来实现这⼀点。

欧式距离(Euclidean Distance):就是我们⾼中⼏何学的两点之间的直线距离。距离越短,相似 度越⾼。

余弦相似度(Cosine Similarity):它忽略向量的绝对⻓度(⼤⼩),只关注两个向量在⽅向上的 差异。在⽂本和语义的世界⾥,"⽅向"代表"含义",⽽"⻓度"往往只代表"⽂本的⻓ 度"或"词汇的多少"。换句话说,余弦相似度关注的是 "你们是否指向同⼀个⽅向" / "你们是 否代表同⼀个含义"

因此,在捕捉语义上的相似性上,余弦相似度是更常⽤的度量⽅式。

我们⼜能反推出,由于使⽤向量来绘制向量空间,⽽向量是有维度的,维度越⾼,则更能捕捉极其细 微和复杂的语义差别(⽐如"⾼兴"和"喜悦"的区别)。

这能⼲什么?这能解决⼀个传统数据库(如MySQL)不擅⻓的问题:基于内容的相似性搜索,而不是 基于精确匹配的查询

嵌入模型应用场景

根据嵌⼊的特性,由此延伸出了许多嵌⼊模型在 AI 应⽤的使⽤场景:

语义搜索(Semantic Search) 传统搜索依赖关键词匹配(搜 "苹果" ,只能找到包含 "苹果" 这个词的⽂档)。

语义搜索则能将查询(如 "⼀种红⾊的⽔果" )和⽂档库中的所有⽂档都转换为向量。然后计算查询 向量与所有⽂档向量的相似度,返回最相似的⽂档。这样即使⽂档⾥没有 "红⾊" 和 "⽔果" 这些 词,但只要它是关于 "苹果" 的,就能被找到。如下图为我们展⽰了借助嵌⼊模型进⾏⽂档搜索的过程:

  1. 为多⽂档⽣成其各⾃的向量,

  2. 为搜索查询语句⽣成向量,

  3. 衡量查询向量与每个⽂档向量之间的相似性,得到相似度最⾼的⽂档。

检索增强生成(Retrieval-Augmented Generation, RAG)

这是当前⼤语⾔模型应⽤的核⼼模式。当⽤⼾向 LLM 提问时,系统⾸先使⽤嵌⼊模型在知识库(如公 司内部⽂档)中进⾏语义搜索,找到最相关的内容,然后将这些内容和问题⼀起交给 LLM 来⽣成答 案。这极⼤地提⾼了答案的准确性和时效性。

推荐系统(Recommendation Systems)

将⽤⼾(根据其历史⾏为、偏好)和物品(商品、电影、新闻)都转换为向量。喜欢相似物品的⽤ ⼾,其向量会接近;相似的物品,其向量也会接近。通过计算⽤⼾和物品向量的相似度,就可以进⾏ 精准推荐。

异常检测(Anomaly Detection)

正常数据的向量通常会聚集在⼀起。如果⼀个新数据的向量远离⼤多数向量的聚集区,它就可能是⼀ 个异常点(如垃圾邮件、欺诈交易)。

Embeddings 嵌入模型类

在 LangChain 中,有很多的嵌⼊模型提供⽅,使⽤不同的模型提供⽅,需要安装为其各⾃包,例如:

• OpenAI: pip install -U langchain-openai

• Ollama: pip install -U langchain-ollama

• Google Gemini: pip install -U langchain-google-genai

更多参考这里https://docs.langchain.com/oss/python/integrations/providers/overview

定义嵌入模型

定义 OpenAI 下的嵌⼊模型使⽤: class langchain_openai.embeddings.base.OpenAIEmbeddings ,官⽅接⼝介绍⻅https://reference.langchain.com/python/integrations/langchain_openai/#langchain_openai.embeddings.base.OpenAIEmbeddings

python 复制代码
from langchain_openai import OpenAIEmbeddings
    embeddings = OpenAIEmbeddings(
    model="text-embedding-3-large", # 是 OpenAI 2024年发布的最新嵌⼊模型,⽣成3072维
的⾼质量向量
)

在 LangChain 框架中基础 Embeddings 类( OpenAIEmbeddings 继承了它)设计了两个核⼼⽅法 来处理⽂本嵌⼊。

• .embed_documents() : ⽤于处理⽂档 Documents 。它的输⼊是多个⽂本。例如要将⼀个 知识库⾥的所有段落都转换成向量后存⼊数据库,就会使⽤这个⽅法。

◦ 它返回⼀个【⼆维列表】 List[List[float]] 。外层列表的每个元素对应⼀个输⼊⽂档, 内层列表则是该⽂档的向量表⽰。

• .embed_query() : 用于处理查询 Query 。它的输⼊是单个文本(⼀个字符串,str)。例 如,当用户提出⼀个问题时,需要将这个问题转换成向量,以便在数据库中搜索相似的⽂档段落, 就会使⽤这个方法。

◦ 它返回⼀个【⼀维列表】,⾥⾯是浮点数( List[float] ),代表单个查询⽂本的向量。

其实分别对应下图中⽂档与查询的向量⽣成:

之所以设计成两个⽅法,是因为某些嵌⼊模型提供商(如 OpenAI、Cohere 等)会针对 "被搜索的⽂ 档" 和 "搜索查询本⾝" 采⽤不同的优化策略和模型。即使底层是同⼀个模型,也可能对两者进⾏不 同的预处理(例如添加不同的指令前缀),以获得更好的搜索效果。

嵌入文档列表

embed_documents 的语义是 "索引"。它的⽬的是预处理⼤量⽂本,为它们创建向量表⽰,以便 后续被搜索。这⼀般是⼀个离线、批量处理的过程。

python 复制代码
markdown_path = "../Docs/Markdown/脚⼿架级微服务租房平台Q&A.md"
# single 模式加载后,默认只有⼀个 Document 对象
loader = UnstructuredMarkdownLoader(markdown_path)
data = loader.load()
    # ⽣成分割器
    text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", chunk_size=200, chunk_overlap=50
)
# 分割⽂档
documents = text_splitter.split_documents(data)
# 定义嵌⼊模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
# 嵌⼊⽂档列表,⽣成向量列表
# 注意这⾥需要提取⽂档内容为字符串列表,才能传递给嵌⼊模型
texts = [doc.page_content for doc in documents]

documents_vector = embeddings.embed_documents(texts)

print(f"⽂档数量为:{len(documents)},⽣成了{len(documents_vector)}个向量的列表")
print(f"第⼀个⽂档向量维度:{len(documents_vector[0])}")
print(f"第⼆个⽂档向量维度:{len(documents_vector[1])}")

嵌入单个查询

embed_query 的语义是 "搜索"。它的⽬的是在⽤⼾发起请求时,实时地将⼀个问题或指令转换 为向量,⽤于在已索引的⽂档向量中进⾏检索。这是⼀个在线、实时、按需处理的过程。

python 复制代码
# 定义嵌⼊模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
# 嵌⼊单个查询
query_vector = embeddings.embed_query("项⽬中遇到了哪些挑战?如何解决?")
print(f"向量维度:{len(query_vector)}")
print(f"向量前五个数值为:{query_vector[:5]}")

向量存储

向量数据库介绍

想要从向量数据库中进⾏相似度搜索,⾸先就需要将向量存储管理起来。想象⼀下, ⼀篇⽂档若转换成⼀个有1536个浮点数的向量。⼀百万篇⽂档就是1536MB(约1.5GB)的纯向量数 据。这只是⼀个起点,现实中的数据集可能轻松达到千万甚⾄亿级。如何⾼效地存储和管理这些向 量?

向量数据库则提供了专⻔⽤于高效存储管理检索高维向量的能⼒。其核⼼就是 "高效地组织和检 索这些数据"。

常⻅的向量数据库核心机制如下:

专门的索引

这是向量数据库的灵魂。它们不会使⽤暴⼒搜索,⽽是会预先为所有向量构建⼀种特殊的索引结构。

常⻅的⽅法有近似最近邻(ANN)搜索 :为了追求极致的速度,它愿意牺牲⼀点点精度。它不会保证 找到绝对最相似的向量(即最近邻),但能以极⾼的概率找到非常相似的向量。通过聚类、分层、压 缩等算法技术,将搜索范围从"整个数据库"缩小到"几个最可能的候选集"。

这就像在⼀个大图书馆⾥找书,你不是从 A 到 Z 遍历所有书架,⽽是先根据分类(⽂学、历史、科 学)找到大概区域,再在这个区域内仔细找,这样快得多。

向量相似度计算优化

向量数据库底层使用高度优化的库来进行向量运算。如 FAISS 向量数据库,它是 Facebook AI 研究院 开发的⼀种⾼效的相似性搜索和聚类的库。它能够快速处理⼤规模数据,并且⽀持在⾼维空间中进行 相似性搜索。

这些库充分利⽤了 CPU 的 SIMD 指令集和 GPU 的并⾏计算能⼒,让⼤规模的向量计算速度极快。

SIMD 指令集指 "单指令多数据流" 技术,是⼀种采⽤⼀个控制器来控制多个处理器,本质上⾮常类 似⼀个向量处理器,可对控制器上的⼀组数据(⼜称"数据向量") 同时分别执⾏相同的操作从⽽ 实现空间上的并⾏。简单来说,就是⼀个指令能够同时处理多个数据。

数据管理功能

现代的向量数据库不仅有专⻔的索引,还提供了完整的数据管理功能:

• CRUD 操作:⽀持增删改查,可以动态地更新向量数据。

• 元数据过滤:这是⾮常关键的功能。除了向量本⾝,每篇⽂档还有元数据(Metadata),⽐如创 建时间、作者、类别等。向量数据库允许你先⽤元数据过滤("找出xxxx年以后的、属于科技类 别的⽂档"),再在这个缩⼩的范围内进⾏向量相似度搜索,极⼤地提升了准确性和效率。

• 可扩展性与持久化:它们可以轻松地分布式部署,处理海量数据;同时保证数据持久化,不像纯 内存⽅案⼀样断电丢失。

• 集成方便:提供友好的API(如gRPC, RESTful),使得像 LangChain 这样的框架可以轻松地与 之集成,开发者⽆需关心底层细节。

因此,我们可以利⽤专⻔的向量数据库(如Chroma, Weaviate, Pinecone, Qdrant, Milvus等),通过 构建索引、优化计算流程、并提供丰富的过滤和管理功能,从⽽实现对海量⾼维向量数据进⾏快速、 可扩展的【相似性检索】的⼀套完整技术⽅案。

内存存储

使⽤ LangChain 的 InMemoryVectorStore 来实现向量的内存存储。

基本操作
初始化

LangChain 中的⼤多数向量在初始化向量存储时接受嵌⼊模型作为参数。

python 复制代码
from langchain_openai import OpenAIEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
# 定义嵌⼊模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
# 内存存储初始化
vector_store = InMemoryVectorStore(embedding=embeddings)
添加文档

我们可以使⽤ add_documents ⽅法,向内存存储中去添加⽂档。要注意的是,该⽅法会为添加的 ⽂档编排索引,索引列表随着该⽅法返回。

这也就是在前⽂中,我们⼀直在提的:当我们想对某⽂本进⾏【数据检索】时的两个步骤:

• 编制索引: ⽤于从源中摄取数据并为其编制索引。

• 检索和⽣成 :接受⽤⼾查询并从索引中检索相关数据,然后将其传递给模型。

python 复制代码
# ⽣成分割器
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", chunk_size=200, chunk_overlap=50
)
# 加载⽂档
data = UnstructuredMarkdownLoader("../Docs/Markdown/脚⼿架级微服务租房平台
Q&A.md").load()
# 分割⽂档
documents = text_splitter.split_documents(data)
# 添加⽂档
ids = vector_store.add_documents(documents=documents)
print(f"共编排了{len(ids)}个⽂档索引")
print(f"前3个⽂档的索引是:{ids[:3]}")
获取文档

使⽤ get_by_ids ⽅法,通过索引列表获取对应的⽂档列表。

python 复制代码
doc_3 = vector_store.get_by_ids(ids[:3])
print(f"{[doc.page_content for doc in doc_3]}")
删除文档

使⽤ delete⽅法,删除传⼊索引列表对应的⽂档列表;若不传⼊索引列表,则认为全量删除。

python 复制代码
vector_store.delete(ids=ids[:3])
向量搜索

如果我们传⼊⼀个查询,向量存储将嵌⼊该查询,在所有嵌⼊的⽂档中执⾏相似性搜索,并返回最相 似的⽂档。

这体现了两个重要的概念:⾸先,需要有⼀种⽅法来衡量查询与任何嵌⼊⽂档之间的相似性。其次, 需要有⼀种算法能够⾼效地在所有嵌⼊⽂档中执⾏这种相似性搜索。

对于【相似性搜索】则可以通过向量存储提供的搜索⽅法实现。

相似性搜索

想要获取根据相似性搜索的结果,即嵌⼊单个查询,并查找相似的⽂档,并将它们作为⽂档列表返 回。这可以使⽤ similarity_search ⽅法来实现。说明:InMemoryVectorStore 是根据【余 弦相似度】来捕捉语义的

python 复制代码
search_docs = vector_store.similarity_search(query="数据库表怎么设计的?", k=2)
for doc in search_docs:
    print("*" * 30)
    print(doc.page_content)

方法参数解释如下:

• query :输⼊的查询str。

• k :要返回的⽂档数。默认为 4。

元数据过滤

虽然向量数据库实现了搜索算法,来有效地搜索所有嵌⼊的⽂档以找到最相似的⽂档。但现实场景 中,我们还希望通过先根据元数据进⾏过滤,来帮助缩⼩搜索范围。例如从特定来源或⽇期范围检索 文档。

python 复制代码
from langchain_core.documents import Document
def _filter_function(doc: Document) -> bool:
    return doc.metadata.get("source") == "hahaha"
search_docs = vector_store.similarity_search(
    query="数据库表怎么设计的?",
    k=2,
    filter=_filter_function
)
for doc in search_docs:
    print("*" * 30)
    print(doc.page_content)

这次,我们给搜索⽅法加⼊了 filter 参数,它接收⼀个 bool 值,表⽰我们可以根据条件选择是否 过滤某些⽂档。因此我们定义了⼀个 _filter_function 过滤函数,可以根据⽂档元数据先过滤出 ⽂档,再去进⾏搜索。

实操
python 复制代码
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import CharacterTextSplitter

# 定义嵌入模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
# 内存向量存储
vector_store = InMemoryVectorStore(embedding=embeddings)

# 获取文档列表
# single 模式,只生成一个大文档
loader = UnstructuredMarkdownLoader("../Docs/markdown/脚手架级微服务租房平台Q&A.md",)
# Document 列表
data = loader.load()
# tiktoken 文本分割器
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", # cl100k_base 是tiktoken 分词器中的一种编码方式
    chunk_size=400,              # 块token大小(参考标准,为了保证段落/句子完整,会超出此设定的大小)
    chunk_overlap=50,            # 块重叠大小
)
# 文档列表
docs = text_splitter.split_documents(data)

# 存储文档到内存向量存储中
# add_documents: 将要存储的文档列表进行编排索引。
ids = vector_store.add_documents(docs)
print(f"共有{len(docs)}个文档,编排了{len(ids)}个索引")
print(f"前三个文档的索引:{ids[:3]}")

# # 根据索引获取文档
# doc_2 = vector_store.get_by_ids(ids[:2])
# print(doc_2)
#
# # 删除文档
# vector_store.delete(ids=ids[:2])
# doc_2 = vector_store.get_by_ids(ids[:3])
# print(doc_2)

# # 检索   similarity_search是根据余弦相似度来捕捉语义的
# search_docs = vector_store.similarity_search(query="项目介绍", k=2# query是查询的字符串,k是需要查询出来和query相似的K个文档
# for doc in search_docs:
#     print("*" * 30)
#     print(doc)

# 元数据过滤后 -> 检索
# {'source': '../Docs/markdown/脚手架级微服务租房平台Q&A.md'}
def _filter_function(doc: Document) -> bool:
    return doc.metadata.get("source") == "../Docs/markdown/脚手架级微服务租房平台Q&A.md"

search_docs = vector_store.similarity_search(
    query="项目介绍",
    k=2,
    filter=_filter_function   # filter 接收一个bool值
)

for doc in search_docs:
    print("*" * 30)
    print(doc)

Redis向量存储

还可以使⽤ Redis 来存储向量。⼤多数开发者都熟悉 Redis,因为它速度快、拥有庞⼤的客⼾端库 ⽣态系统,并且多年来已被众多⼤型企业采⽤。从本质上讲,Redis 是⼀种键值型的 NoSQL 数据库, 除了传统⽤例之外,Redis 还提供了诸如搜索和查询功能等额外能力,允许用户在 Redis 内创建二级 索引结构。这使得 Redis 能够以缓存的速度充当向量数据库。

基本概念
理解 RediSearch

RediSearch 是 Redis 官方提供的⼀款⾼性能【搜索】与【全文索引】引擎模块。它基于 Redis 构建, 使用户能够直接在 Redis 数据库中执⾏复杂的【搜索】和【分词查询】,⽆需额外引⼊外部搜索引 擎。RediSearch 特别适⽤于轻量级、响应速度要求较⾼的分词搜索场景。

RediSearch 提供了内置的分词功能,既避免 LIKE 的局限性(⽆法⽀持分词查询。例如:数据 为"Apple iPhone 17 Pro Max 256GB 星宇橙⾊",搜"苹果17橙⾊"⽆法匹配),⼜⽆需部署 ES 这 种⼤型组件,集成简单、性能出⾊,⾮常适合中等规模的全⽂检索需求。

理解 Index

Index(索引)是 RediSearch 模块⾥的概念,⽤于定义⼀个查询⽬录。Index 是⼀个独⽴的数据结 构,它建立在多个 Redis Keys (Hash 类型)之上,这专⻔为了极速执⾏⽂本搜索、过滤和聚合⽽设 计。它本⾝不存储数据,⽽是存储了指向其他 Redis Keys 的指针,和这些 Keys 中特定字段的索引信 息。

理解 Index Fields

Index Fields(索引字段) 是创建索引时,明确指定的那些需要被索引的字段。它们定义了索引的"结 构"或"蓝图",告诉 RediSearch:"请针对这些字段的内容,以其特定的⽅式为我构建快速搜索的 能⼒。"

可以把它想象成在⼀本书后⾯制作索引⻚(⽐如⼈名索引、主题索引)。我们不会把书中的每⼀个字 都做到索引⾥,⽽是只选择那些重要的关键词(字段),并记录下它们出现的⻚码(⽂档ID)。这⾥ 的"关键词"就是 Index Fields。

在 RediSearch 中,索引字段是有特定的类型:

• TAG :精确匹配的分类/标签,可以⽤来多值分类、过滤、分组。

• NUMERIC :整数/浮点数,可以进⾏数值范围查询,排序和统计。

• TEXT :全⽂搜索的字符串,⽀持分词、词⼲化、模糊匹配。

• GEO :经纬度坐标,⽤来查询地理空间,计算距离。

理解 metadata schema

schema 就是描述数据结构的声明格式。 metadata schema 则⽤来描述元数据的结构 声明。

这⾥的元数据是指我们将来要嵌⼊文档的元数据。因为对于⽂档元数据来说,它在存⼊ Redis 后,就 被定义成了索引字段。

对于文档元数据来说,里面存放的就是⼀些⽂档属性值,如 source 表⽰⽂档来源。我们还可以⼿动 加⼊其他元数据,这需要设置每个字段的声明: name 表⽰字段名, type 表⽰字段类型。

环境设置

使⽤ Redis 来存储向量,⾸先需要将相关环境配置好。

第一步:启动 Redis 服务端:使⽤ Docker 启动 Redis 实例。

这⾥要说明:对于 Redis 版本 >= 8.0 ,使⽤:docker run -d -p 6379:6379 -it redis:latest

对于 Redis 版本 < 8.0 ,使⽤:docker run -d -p 6379:6379 redis/redis-stack:latest

使⽤ docker ps 查看是否启动成功

第二步:安装 Redis 客⼾端包,以便将来定义客⼾端,以及运⾏搜索和查询命令。 由于我们使⽤ Python 进⾏开发,则选择 redis-py 库完成客⼾端定义。要安装 redis-py,只需:pip install redis

第三步:在 LangChain 中想要使⽤ Redis 向量库,需要安装 langchain-redis 包 pip install -qU langchain-redis

第四步:定义 Redis 连接 URL,客⼾端连接 Redis 时需要使⽤。 Redis 连接 URL 的基本结构是:[protocol]://[auth]@[host]:[port]/[database]

第五步:测试连接(Ping)

python 复制代码
import redis
redis_url = "redis://localhost:6379"
# 定义Redis客⼾端
redis_client = redis.from_url(redis_url)
# Ping
print(redis_client.ping())

若输出 True ,则表⽰连接测试成功。

基本操作

LangChain 中使⽤ RedisVectorStore 初始化 Redis 向量存储。由于 Redis 需要相关配置,如连 接 URL 等,因此 LangChain 提供了 RedisConfig 配置类供我们使⽤。

初始化

LangChain 中使⽤ RedisVectorStore 初始化 Redis 向量存储。由于 Redis 需要相关配置,如连 接 URL 等,因此 LangChain 提供了 RedisConfig 配置类供我们使⽤。

class langchain_redis.vectorstores.RedisVectorStore 类,其初始化参数如下:

• embeddings :⽤于此存储的 Embeddings 实例。

• config :可选的 RedisConfig 对象。

class langchain_redis.config.RedisConfig 配置类,其关键配置参数如下:

• index_name :Redis 中索引的名称。默认为⽣成的 ULID 唯⼀标识符。

• key_prefix :Redis Key 的前缀。如果未设置,则默认为 index_name。

• redis_url :Redis 实例的 URL。默认为"redis://localhost:6379"。

• metadata_schema :元数据字段的 schema。设置该字段对于将来的元数据过滤有帮助。

python 复制代码
from langchain_openai import OpenAIEmbeddings
from langchain_redis import RedisConfig, RedisVectorStore
# 定义嵌⼊模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
# 配置 Redis 客⼾端
redis_url = "redis://192.168.100.238:6379"
config = RedisConfig(
    index_name="qa",
    redis_url=redis_url,
    metadata_schema=[
        {"name": "category", "type": "tag"},
        {"name": "num", "type": "numeric"},
    ],
)
# Redis 存储初始化
vector_store = RedisVectorStore(embeddings, config=config)

可以根据 Index Name 查询其下的所有的 Index Fields ,这需要安装 redisvl ( pip install -U redisvl ),使⽤ rvl 命令⾏⼯具来检查索引。

rvl index info -i qa --host 192.168.100.238 --port 6379

可以看到 Index Fields 中就包括了:

• text :⽂档⽂本。

• embedding :向量,类型为数组。

• _index_name :索引名。

• meteData schema :⽂档元数据。

其中⽂档元数据的每个属性还都被当作单独的 Index Fields 。

添加文档

可以使⽤ add_documents ⽅法,向向量库中去添加⽂档。这次我们可以给被分割的⽂档添加相 关的元数据。

python 复制代码
# 嵌入模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large",)

# Redis 配置
config = RedisConfig(
    index_name="qa",  # 定义Index索引名
    redis_url="redis://106.52.186.95:8081",
    metadata_schema=[
        {"name": "category", "type": "tag"},   # 添加索引字段:分类
        {"name": "num", "type": "numeric"},    # 添加索引字段:编号
    ]
)

# 初始化 Redis 向量存储实例(建立了索引结构)
vector_store = RedisVectorStore(
    embeddings=embeddings,
    config=config,
)

# 添加文档
# single 模式,只生成一个大文档
loader = UnstructuredMarkdownLoader("../Docs/markdown/脚手架级微服务租房平台Q&A.md",)
# Document 列表
data = loader.load()

# tiktoken 分词器
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", # cl100k_base 是tiktoken 分词器中的一种编码方式
    chunk_size=400,              # 块token大小(参考标准,为了保证段落/句子完整,会超出此设定的大小)
    chunk_overlap=50,            # 块重叠大小
)

# 文档列表
docs = text_splitter.split_documents(data)
for i, doc in enumerate(docs, start=1):
    doc.metadata["category"] = "QA"
    doc.metadata["num"] = i

# 向量库中添加文档(编制索引)
ids = vector_store.add_documents(documents=docs)
print(f"编制了{len(ids)}个索引")
print(f"前三个索引是:{ids[:3]}")

运行结果

获取文档

使⽤ get_by_ids ⽅法,通过索引列表获取对应的⽂档列表。

python 复制代码
# 查
print(vector_store.get_by_ids(["01KGA7V7PYMHZ2VJN83S3YQJCD"]))

运行结果

删除文档

使⽤ delete ⽅法,删除传⼊索引列表对应的⽂档列表。

python 复制代码
vector_store.delete([":01K4Q0A3DSQVZBRFKJD5MS25HJ"])

# 删除指定内容, 这里需要将qa前缀加上
vector_store.index.drop_keys(["qa::01K4Q0A3DSQVZBRFKJD5MS25HJ"])

# 全量删除(连带索引结构全部删除)
vector_store.index.delete(drop=True)
向量搜索
相似性搜索

想要获取根据相似性搜索的结果,即嵌⼊单个查询,并查找相似的⽂档,并将它们作为⽂档列表返 回。这可以使⽤ similarity_search ⽅法来实现。

python 复制代码
search_docs = vector_store.similarity_search(query="数据库表怎么设计的?", k=2)
for doc in search_docs:
    print("*" * 30)
    print(doc.page_content)

⽅法参数解释如下:

• query :输⼊的查询str。

• k :要返回的⽂档数。默认为 4。

除了上⾯的 similarity_search ⽅法,其实还提供了:

• 根据向量搜索⽅法: similarity_search_by_vector

• 根据查询搜索⽅法,并返回相似分值: similarity_search_with_score

• 根据向量搜索⽅法,并返回相似分值: similarity_search_with_score_by_vector

python 复制代码
# 结果打分: 分数越低表示相似度越高
search_docs_results = vector_store.similarity_search_with_score(query="项目介绍", k=2)

for doc, score in search_docs_results:
    print("*" * 30)
    print(f"文档分数:{score}")
    print(f"文档内容:{doc.page_content}")
    print(f"文档元数据:{doc.metadata}")
元数据过滤

对于上述列举的⽅法,在他们搜索之前,都可以根据元数据先进⾏过滤。对于 RedisVectorStore 来说,需要使⽤ Redis 过滤表达式进⾏筛选。

对于 Redis 过滤表达式,也可以使⽤ & 和 | 运算符组合。如我们现根据元数据中的 category 与 num 进⾏过滤,如下所⽰:

python 复制代码
# 过滤条件:
# (category == "QA") &  (num > 6)
filter_condition = (Tag("category") == "QA") & (Num("num") > 6)
search_docs_results = vector_store.similarity_search_with_score(
    query="项目介绍",
    k=2,
    filter=filter_condition
)

for doc, score in scored_results:
    print("*" * 30)
    print(f"Content: {doc.page_content[:100]}...")
    print(f"Metadata: {doc.metadata}")
    print(f"Score: {score}")
最大边际相关性搜索(MMR)

它是⼀种重新排序算法,它使⽤语义相似性作为基础⼯具,从⼀个 候选集中挑选出⼀组既能代表查询主题⼜彼此多样化的结果。

• 【语义相似性】就像⾯试官衡量每个应聘者与职位要求的匹配度。他会给每个应聘者打⼀个分数。

• 【最⼤边际相关性】就像团队经理(MMR算法)要组建⼀个团队。⽬标是选出⼀组"精华"结果, ⽽不是⼀个单⼀结果:

MMR 使⽤场景:

• 推荐系统:推荐与⽤⼾兴趣相关但⼜不同类型的物品,避免"信息茧房"。

• ⽂档摘要:从⻓⽂档中选择能代表主旨⼜包含不同信息的句⼦,避免摘要内容重复。

• RAG (检索增强⽣成):在从知识库检索完⼀堆相关⽂档后,使⽤ MMR 进⾏去重和多样化筛选,再 交给LLM⽣成答案,能有效提升答案质量和减少幻觉。

使⽤ MMR 搜索,需要⽤到 max_marginal_relevance_search 方法,

python 复制代码
filter_condition = (Tag("category") == "QA") & (Num("num") > 6)

# MMR 搜索: 基于语义搜索,先筛选出一批文档,然后进行重排序输出
search_docs = vector_store.max_marginal_relevance_search(
    query="项目介绍",
    k=2,
    filter=filter_condition,
    fetch_k=10, # 筛选一批文档的数量
)

for doc in search_docs:
    print("*" * 30)
    print(f"文档内容:{doc.page_content}")
    print(f"文档元数据:{doc.metadata}")

我们需要注意⼀个参数 fetch_k , fetch_k 是 MMR 算法第⼀步中,从向量库中初步获取的候选 ⽂档数量。

为了理解它,我们⾸先要明⽩ MMR 搜索是⼀个两阶段过程:

  1. 初步获取:系统⾸先根据查询的纯向量相似度,从庞⼤的向量库中找出最相似的 fetch_k 个⽂ 档。这⼀步的⽬标是"⼴撒⽹",先找到⼀个⾜够⼤的相关⽂档池。

  2. 重新排序与筛选:然后,MMR 算法会在这个较⼩的候选池(⼤⼩为 fetch_k )中运⾏。它不再 只考虑与查询的相似度,还会考虑候选⽂档之间的多样性。它会从这 fetch_k 个⽂档中,挑选 出既与查询相关,彼此之间⼜不太相似的 k 个⽂档作为最终结果。

Pinecone 向量存储

Pinecone 是为机器学习应⽤量⾝打造的⽣产级向量数据库服务,适⽤于⾼维向量数据的⾼效存储、索 引与查询。它屏蔽了基础设施管理,提供⽆缝扩展、实时数据写⼊和强⼤安全保障,让开发者和数据 科学家能够以极低运维成本,快速构建⾼效的相似度搜索、推荐系统和 AI 应⽤。

Pinecone 是⼀个全托管的向量数据库平台,即负责所有后端维护、扩展、更新和监控,让⽤⼾专注于 应⽤开发,⽆需担⼼数据库管理。

Pinecone 地址:https://www.pinecone.io/

环境设置

• 首次使⽤需注册新⽤⼾,选择个⼈免费版

• 继续创建账⼾相关信息,或者直接右上⻆ skip 跳过。这⾥我直接跳过。

• 注册成功会⽣成⼀个默认的 API Key。注意保存好你的 key。

• 也可以创建新的 API key:

• 设置 PINECONE_API_KEY ,将 Key 添加进环境变量。

• 更新包

pip install -qU pinecone langchain-pinecone

基本操作
初始化

LangChain 中使⽤ PineconeVectorStore 类初始化 Pinecone 向量库。我们需要:

  1. 创建索引,参考下面

https://docs.pinecone.io/guides/index-data/create-an-index#bring-your-own-vectors

  1. 使⽤索引来初始化 PineconeVectorStore 。
python 复制代码
# 创建Pinecone客户端
pc = Pinecone()
index_name = "qa"
if not pc.has_index(index_name):
    pc.create_index(
        name=index_name,
        dimension=3072,   # 维度
        metric="cosine",  # 度量方式,cosine余弦相似度
        spec=ServerlessSpec(
            cloud="aws",               # 亚马逊云
            region="us-east-1"         # 区域
        ),
    )

# 获取索引
index = pc.Index(index_name)
# 嵌入模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large",)
# 定义 pinecone 向量库
vector_store = PineconeVectorStore(
    embedding=embeddings,
    index=index,          # pinecone 向量库的索引
)

class langchain_redis.vectorstores.PineconeVectorStore 类,其初始化参数如 下:

• embeddings :⽤于此存储的 Embeddings 实例。

• index :Pinecone 索引

如果我们是第⼀次创建索引,在 Pinecone 控制台 则可以看⻅被创建的索引。

添加文档

可以使⽤ add_documents ⽅法,向向量库中去添加⽂档。

python 复制代码
# single 模式,只生成一个大文档
loader = UnstructuredMarkdownLoader("../Docs/markdown/脚手架级微服务租房平台Q&A.md",)
# Document 列表
data = loader.load()

# tiktoken 分词器
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", # cl100k_base 是tiktoken 分词器中的一种编码方式
    chunk_size=400,              # 块token大小(参考标准,为了保证段落/句子完整,会超出此设定的大小)
    chunk_overlap=50,            # 块重叠大小
)

# 文档列表
docs = text_splitter.split_documents(data)
for i, doc in enumerate(docs, start=1):
    doc.metadata["category"] = "QA"
    doc.metadata["num"] = i

# 添加文档
ids = vector_store.add_documents(documents=docs)
print(f"编制了{len(ids)}个索引")
print(f"前三个索引是:{ids[:3]}")

运行结果

删除文档
python 复制代码
# 全量删除
vector_store.delete(delete_all=True)
# 删除指定id的⽂档列表
delete_ids = []
vector_store.delete(ids=delete_ids)
向量搜索

想要获取根据相似性搜索的结果,即嵌⼊单个查询,并查找相似的⽂档,并将它们作为⽂档列表返 回。这可以使⽤ similarity_search ⽅法来实现。

python 复制代码
search_docs = vector_store.similarity_search(query="项目介绍", k=2)
for doc in search_docs:
    print("*" * 30)
    print(f"文档内容:{doc.page_content}")
    print(f"文档元数据:{doc.metadata}")

检索器

检索系统

检索系统(Information Retrieval System, IR System)是⼀个为了满⾜⽤⼾信息需求,从⼤规模、⾮ 结构化的数据集合中,⾃动、⾼效地查找、排序并返回相关信息的计算机系统。

它的核⼼任务是:在正确的时间,以正确的⽅式,将正确的信息传递给正确的⼈。最常⻅的例⼦就 是:搜索引擎(如Google、百度)。

随着⼤型语⾔模型的流⾏,检索系统已成为⼈⼯智能应⽤(例如 RAG)的重要组成部分。且存在多种 【不同类型】的检索系统,包括:

关系数据库

关系数据库是许多应⽤程序中使⽤的结构化数据存储的基本类型。数据存储在⾏(记录)和列(属 性)中,可以通过 SQL(结构化查询语⾔)进⾏⾼效的查询和作。关系数据库擅⻓维护数据完整性、 ⽀持复杂查询以及处理不同数据实体之间的关系。

词法搜索索引

许多搜索引擎基于将查询中的单词与每个⽂档中的单词进⾏匹配,这种⽅法称为词法检索。即⼀个单 词经常出现在⽤⼾的查询和特定⽂档中,那么这个⽂档可能是⼀个很好的匹配。这通常使⽤【倒排索 引】实现。

向量数据库

向量存储不使⽤字频,⽽是使⽤【嵌⼊模型】将⽂档转换为⾼维向量表⽰。这允许使⽤余弦相似度等 数学运算对嵌⼊向量进⾏有效的相似性搜索。

检索器

检索器是检索系统中的⼀个核⼼组件,它接收来⾃⽤⼾接⼝的查询(Query),检索出包含查询关键词 的候选⽂档集合。

我们可以使⽤上⾯提到的任何检索系统实现⽅式创建检索器!如关系数据库、向量数据库等。由于其 重要性和多样性,LangChain 提供了⼀个统⼀的接⼝来与不同类型的检索系统进⾏交互。LangChain 的检索器接⼝⾮常简单:

  1. 输⼊:查询字符串

  2. 输出:⽂档列表(标准化的 LangChain ⽂档对象 Document)

例如,使用关系数据库的检索系统,检索器可以将问题转换为 SQL 语句,并执⾏查询,最后将查询结 果响应用户。

使用向量数据库作为检索器

基本使用

向量存储是索引和检索⾮结构化数据的⼀种强⼤⽽有效的⽅法。可以通过调⽤向量数据库的 as_retriever ⽅法,将向量存储⽤作检索器。在这⾥我们使⽤ Redis 向量存储。

as_retriever ⽅法也⽀持我们传递相关参数修改搜索结果,如:

• search_type :设置相似算法,包括: "similarity" (默 认)、 "mmr" 或 "similarity_score_threshold" (相似性分数阈值)

• search_kwargs :

◦ k :限制检索器返回的文档 k 数量。

◦ fetch_k :要传递给 MMR 算法的⽂档量。

python 复制代码
from typing import List

from langchain_core.documents import Document
from langchain_core.runnables import chain
from langchain_openai import OpenAIEmbeddings
from langchain_redis import RedisConfig, RedisVectorStore

# 嵌入模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

# Redis 配置
config = RedisConfig(
    index_name="qa",  # 定义索引名
    redis_url="redis://192.168.100.238:6379",
    metadata_schema=[
        {"name": "category", "type": "tag"},   # 添加索引字段:分类
        {"name": "num", "type": "numeric"},    # 添加索引字段:编号
    ]
)

# 初始化 Redis 向量存储实例(建立了索引结构)
vector_store = RedisVectorStore(
    embeddings=embeddings,
    config=config,
)

# 检索器依赖向量库(Runnable)
retriever = vector_store.as_retriever(search_kwargs={"k" : 2})

search_docs = retriever.invoke("项目介绍")
for doc in search_docs:
    print("*" * 30)
    print(doc.page_content)

LangChain 检索器是⼀个 Runnable 的对象 ,它是 LangChain 组件的标准接⼝。这意味着它有⼀些常 ⽤⽅法,包括 invoke ,⽤于与其交互。默认情况下,向量存储检索器使⽤相似性搜索。

要注意的是, Retrievers 检索器虽然是 Runnable 对象,但其不提供任何流式处理,因为它本⾝ 通常是同步的、阻塞的操作。

使用 @chain 创建"检索器"

除了使⽤ as_retriever ⽅法,我们还可以⾃⾏创建⼀个"检索器"。回想⼀下检索器的特点:

  1. LangChain 检索器是⼀个 Runnable 的对象

  2. LangChain 检索器输⼊为查询字符串,输出为⽂档列表(标准化的 LangChain ⽂档对象 Document)

python 复制代码
# 使用@chain,定义检索器函数,当作具有Runnable的"检索器"使用
@chain
def retriever(query: str) -> List[Document]:
    # 这里使用向量数据库提供的方法
    return vector_store.similarity_search(query=query ,k=2)


search_docs = retriever.invoke("项目介绍")
for doc in search_docs:
    print("*" * 30)
    print(doc.page_content)

上面定义了⼀个函数,使⽤ @chain 修饰,该修饰可以使其成为 Runnable 函数,且满⾜检索器输⼊ 输出的要求。在函数中,我们依旧使⽤向量数据库的相似性搜索⽅法,这样灵活性也更⾼,想要进⾏ 元数据筛选也更⽅便。

注意,这并不是真正的检索器,检索器是⼀个 Runnable 对象,⽽我们定义的只是⼀个函数,具备其 特点罢了。

RAG

因为之前已经完成了离线处理了,所以这里写的内容主要是关于在线检索

python 复制代码
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_redis import RedisConfig, RedisVectorStore

# 构建链:完成RAG能力
# 定义组件,构建链

# 嵌入模型
embeddings = OpenAIEmbeddings(
model="text-embedding-3-large",
    api_key="sk-uLYUiX6UUmBizCDZ8BikPST0W1QOR6juvRM6IYkiIyTZAd6j",
    base_url="https://api.xiaocaseai.com/v1"
)
# 聊天模型
model = ChatOpenAI(
    model="gpt-4o-mini",
    api_key="sk-YAlENCT8nJagdKf_ynV9Psoq6dsXQL4C1FWHQe0yS3xOSwU47DsGxgXM_3E",
    base_url="https://tapi.nyc.mn/v1"
)

# 1. 先从知识库中检索
# Redis 配置
config = RedisConfig(
    index_name="qa",  # 定义索引名
    redis_url="redis://106.52.186.95:8081",
    metadata_schema=[
        {"name": "category", "type": "tag"},   # 添加索引字段:分类
        {"name": "num", "type": "numeric"},    # 添加索引字段:编号
    ]
)
# Redis 向量库
vector_store = RedisVectorStore(
    embeddings=embeddings,
    config=config,
)
# 检索器
retriever = vector_store.as_retriever()

# 2. 将检索结果+查询语句 构建为提示词(提示词模板)

# 提示词模板
prompt = ChatPromptTemplate.from_messages(
    [
        (
            "human",
            """你是负责回答问题的助手。使用一下检索到的上下文片段来回答问题。如果你不知道答案,就说不知道答案。最多回复三句话的结果,回答要简明扼要
            Question:{question}
            Context:{context}
            Answer:"""
        )
    ]
)

# 将检索出来的文档转换成文本传递给提示词模板
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# 3. 将消息发送给 LLM(实例化消息,交由链完成)

# 定义链,执行时需要 question
# 检索器 + format_docs, question (同时传递)
# prompt
# model
# 输出解析器
chain = (
    # 检索器 + format_docs 分支1
    # question            分支2: RunnablePassthrough() 在链中透传输入数据,保持原始问题不变,直接传递给后续步骤
    # question 会广播到字典中的每个一个value
    {"context":  retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | model
    | StrOutputParser()   # 输出解析器
)

# 执行流:
# 输入问题:"项目介绍"
# 并行执行两个分支,提高效率
# 输出结果:
# {
#    "context":"xxxx"
#    "question":"项目介绍"
# }


# 4. 打印字符串结果 (流式)
while True:
    # 获取用户输入
    question = input("\n请输入您的问题(输入'退出'或'quit'结束程序): ").strip()

    # 检查是否退出
    if question.lower() in ["退出", "quit"]:
        print("程序已结束,再见!")
        break

    # 检查输入是否为空
    if not question:
        print("问题不能为空,请重新输入。")
        continue

    # 执行链,流式输出
    print("回答: ", end="", flush=True)
    for chunk in chain.stream(question):
        print(chunk, end="", flush=True)
    print()  # 换行

上述代码中, RunnablePassthrough 我们之前还没有⻅过,简单来说, RunnablePassthrough 是⼀个"伪"Runnable,它的主要作⽤是在链(Chain)中透明地传递 输⼊数据,而不做任何修改。

当我们需要将原始输⼊和另⼀个处理过程的输出⼀起传递给下⼀个步骤时,就需要 RunnablePassthrough 。就例如代码中,我们需要将【Query】与【通过检索出来的⽂档转换的 字符串】同时发送给提⽰词模板。

相关推荐
nvd113 小时前
LangChain 经典回顾:ConversationBufferMemory 与 ConversationChain
langchain
沐雪架构师4 小时前
LangChain 1.0 Agent开发实战指南
开发语言·javascript·langchain
nvd117 小时前
LangChain 核心对比:ChatPromptTemplate vs PromptTemplate
人工智能·langchain
<花开花落>8 小时前
浅学 LangChain,AI 赋能软件测试
软件测试·langchain
玄同7659 小时前
LangChain v1.0 中间件深度解析:从 Callback 到 Middleware 的演进
人工智能·语言模型·自然语言处理·中间件·langchain·agent·智能体
沐雪架构师9 小时前
LangChain 1.0 记忆管理:短期与长期记忆详解
服务器·数据库·langchain
TGITCIC13 小时前
LangChain入门(十五)- LangGraph为什么这么香,看它是如何逆天DIFY的
langchain·工作流·rag·ai agent·ai智能体·langgraph·agentic
玄同7651 天前
告别 AgentExecutor:LangChain v1.0+ Agent 模块深度迁移指南与实战全解析
人工智能·语言模型·自然语言处理·langchain·nlp·agent·智能体
TGITCIC1 天前
LangChain入门(十四)- Agentic RAG 的正确打开方式:用 LangChain 实现“有思考、可解释、不遗漏”的检索增强问答
langchain·rag·ai agent·agentic·智能体开发·rag增强检索·agentic flow