跳到内容

如何为代理的记忆添加语义搜索

本指南展示了如何在代理的记忆存储中启用语义搜索。这使得代理能够通过语义相似性搜索长期记忆存储中的条目。

依赖和环境设置

首先,安装本指南所需的依赖项。

npm install \
  @langchain/langgraph \
  @langchain/openai \
  @langchain/core \
  uuid \
  zod

接下来,我们需要为 OpenAI(我们将使用的 LLM)设置 API 密钥。

export OPENAI_API_KEY=your-api-key

(可选)我们可以设置 LangSmith 跟踪的 API 密钥,这将为我们提供一流的可观察性。

export LANGCHAIN_TRACING_V2="true"
export LANGCHAIN_CALLBACKS_BACKGROUND="true"
export LANGCHAIN_API_KEY=your-api-key

在这里,我们使用索引配置创建记忆存储。

默认情况下,存储库未配置语义/向量搜索。您可以通过在创建存储库时向其构造函数提供IndexConfig来选择索引条目。

如果您的存储类未实现此接口,或者您未传入索引配置,则语义搜索将被禁用,并且传递给 put 的所有 index 参数将不起作用。

现在,让我们创建这个存储库!

import { OpenAIEmbeddings } from "@langchain/openai";
import { InMemoryStore } from "@langchain/langgraph";

const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-small",
});

const store = new InMemoryStore({
  index: {
    embeddings,
    dims: 1536,
  }
});

记忆的结构

在深入了解语义搜索之前,我们先看看记忆是如何构建的以及如何存储它们。

let namespace = ["user_123", "memories"]
let memoryKey = "favorite_food"
let memoryValue = {"text": "I love pizza"}

await store.put(namespace, memoryKey, memoryValue)

如您所见,记忆由命名空间、键和值组成。

命名空间是多维值(字符串数组),允许您根据应用程序的需求对记忆进行分段。在本例中,我们通过使用用户 ID("user_123")作为命名空间数组的第一个维度来按用户对记忆进行分段。

是任意字符串,用于在命名空间内标识记忆。如果您多次向同一命名空间中的同一键写入内容,您将覆盖存储在该键下的记忆。

是表示实际存储记忆的对象。只要可序列化,它们可以是任何对象。您可以根据应用程序的需求来构建这些对象。

简单的记忆检索

让我们向存储库添加更多记忆,然后通过其键获取其中一个,以检查它是否正确存储。

await store.put(
  ["user_123", "memories"],
  "italian_food",
  {"text": "I prefer Italian food"}
)
await store.put(
  ["user_123", "memories"],
  "spicy_food",
  {"text": "I don't like spicy food"}
)
await store.put(
  ["user_123", "memories"],
  "occupation",
  {"text": "I am an airline pilot"}
)

// That occupation is too lofty - let's overwrite
// it with something more... down-to-earth
await store.put(
  ["user_123", "memories"],
  "occupation",
  {"text": "I am a tunnel engineer"}
)

// now let's check that our occupation memory was overwritten
const occupation = await store.get(["user_123", "memories"], "occupation")
console.log(occupation.value.text)
I am a tunnel engineer

使用自然语言搜索记忆

现在我们已经了解了如何通过命名空间和键存储和检索记忆,接下来让我们看看如何使用语义搜索检索记忆。

想象一下,我们有一大堆想要搜索的记忆,但我们不知道与要检索的记忆对应的键。语义搜索允许我们通过使用文本嵌入执行自然语言查询来搜索我们的记忆存储,而无需使用键。我们将在以下示例中演示这一点。

const memories = await store.search(["user_123", "memories"], {
  query: "What is my occupation?",
  limit: 3,
});

for (const memory of memories) {
  console.log(`Memory: ${memory.value.text} (similarity: ${memory.score})`);
}
Memory: I am a tunnel engineer (similarity: 0.3070681445327329)
Memory: I prefer Italian food (similarity: 0.1435366180543232)
Memory: I love pizza (similarity: 0.10650935500808985)

简单示例:ReAct 代理中的长期语义记忆

让我们看一个为代理提供长期记忆的简单示例。

长期记忆可以分为两个阶段:存储和召回。

在下面的示例中,我们通过给代理一个可用于创建新记忆的工具来处理存储。

为了处理召回,我们将添加一个提示步骤,该步骤使用用户聊天消息中的文本查询记忆存储。然后,我们将该查询的结果注入到系统消息中。

简单的记忆存储工具

让我们首先创建一个工具,让大型语言模型(LLM)存储新的记忆。

import { tool } from "@langchain/core/tools";
import { LangGraphRunnableConfig } from "@langchain/langgraph";

import { z } from "zod";
import { v4 as uuidv4 } from "uuid";

const upsertMemoryTool = tool(async (
  { content },
  config: LangGraphRunnableConfig
): Promise<string> => {
  const store = config.store as InMemoryStore;
  if (!store) {
    throw new Error("No store provided to tool.");
  }
  await store.put(
    ["user_123", "memories"],
    uuidv4(), // give each memory its own unique ID
    { text: content }
  );
  return "Stored memory.";
}, {
  name: "upsert_memory",
  schema: z.object({
    content: z.string().describe("The content of the memory to store."),
  }),
  description: "Upsert long-term memories.",
});

在上面的工具中,我们使用 UUID 作为键,这样记忆存储可以无限期地累积记忆,而无需担心键冲突。我们这样做而不是将记忆累积到单个对象或数组中,因为记忆存储是按键索引项的。在存储中为每个记忆提供其自己的键,允许为每个记忆分配其自己独特的嵌入向量,该向量可以与搜索查询匹配。

简单的语义召回机制

既然我们有了存储记忆的工具,接下来创建一个提示函数,我们可以将其与 createReactAgent 一起使用来处理召回机制。

请注意,如果我们这里没有使用 createReactAgent,您也可以将此函数作为图中的第一个节点,它同样会工作得很好。

import { MessagesAnnotation } from "@langchain/langgraph";

const addMemories = async (
  state: typeof MessagesAnnotation.State,
  config: LangGraphRunnableConfig
) => {
  const store = config.store as InMemoryStore;

  if (!store) {
    throw new Error("No store provided to state modifier.");
  }

  // Search based on user's last message
  const items = await store.search(
    ["user_123", "memories"], 
    { 
      // Assume it's not a complex message
      query: state.messages[state.messages.length - 1].content as string,
      limit: 4 
    }
  );


  const memories = items.length 
    ? `## Memories of user\n${
      items.map(item => `${item.value.text} (similarity: ${item.score})`).join("\n")
    }`
    : "";

  // Add retrieved memories to system message
  return [
    { role: "system", content: `You are a helpful assistant.\n${memories}` },
    ...state.messages
  ];
};

整合所有内容

最后,让我们使用 createReactAgent 将所有这些整合到一个代理中。请注意,我们这里没有添加检查点。下面的示例不会重用消息历史记录。所有未包含在输入消息中的详细信息都将来自上面定义的召回机制。

import { ChatOpenAI } from "@langchain/openai";
import { createReactAgent } from "@langchain/langgraph/prebuilt";

const agent = createReactAgent({
  llm: new ChatOpenAI({ model: "gpt-4o-mini" }),
  tools: [upsertMemoryTool],
  prompt: addMemories,
  store: store
});

使用我们的示例代理

既然我们已经把所有东西都整合好了,就来测试一下吧!

首先,让我们定义一个辅助函数,用于打印对话中的消息。

import {
  BaseMessage,
  isSystemMessage,
  isAIMessage,
  isHumanMessage,
  isToolMessage,
  AIMessage,
  HumanMessage,
  ToolMessage,
  SystemMessage,
} from "@langchain/core/messages";

function printMessages(messages: BaseMessage[]) {
  for (const message of messages) {
    if (isSystemMessage(message)) {
      const systemMessage = message as SystemMessage;
      console.log(`System: ${systemMessage.content}`);
    } else if (isHumanMessage(message)) {
      const humanMessage = message as HumanMessage;
      console.log(`User: ${humanMessage.content}`);
    } else if (isAIMessage(message)) {
      const aiMessage = message as AIMessage;
      if (aiMessage.content) {
        console.log(`Assistant: ${aiMessage.content}`);
      }
      if (aiMessage.tool_calls) {
        for (const toolCall of aiMessage.tool_calls) {
          console.log(`\t${toolCall.name}(${JSON.stringify(toolCall.args)})`);
        }
      }
    } else if (isToolMessage(message)) {
      const toolMessage = message as ToolMessage;
      console.log(
        `\t\t${toolMessage.name} -> ${JSON.stringify(toolMessage.content)}`
      );
    }
  }
}

现在,如果我们运行代理并打印消息,我们会看到代理记住了我们在本次演示开始时添加到存储中的食物偏好!

let result = await agent.invoke({
  messages: [
    {
      role: "user",
      content: "I'm hungry. What should I eat?",
    },
  ],
});

printMessages(result.messages);
User: I'm hungry. What should I eat?
Assistant: Since you prefer Italian food and love pizza, how about ordering a pizza? You could choose a classic Margherita or customize it with your favorite toppings, making sure to keep it non-spicy. Enjoy your meal!

存储新记忆

既然我们知道召回机制有效,那么让我们看看是否能让我们的示例代理存储一个新的记忆。

result = await agent.invoke({
  messages: [
    {
      role: "user",
      content: "Please remember that every Thursday is trash day.",
    },
  ],
});

printMessages(result.messages);
User: Please remember that every Thursday is trash day.
    upsert_memory({"content":"Every Thursday is trash day."})
        upsert_memory -> "Stored memory."
Assistant: I've remembered that every Thursday is trash day!
既然它已经存储了,我们来看看它是否记住了。

请记住——这里没有检查点。每次我们调用代理时,都是一次全新的对话。

result = await agent.invoke({
  messages: [
    {
      role: "user",
      content: "When am I supposed to take out the garbage?",
    },
  ],
});

printMessages(result.messages);
User: When am I supposed to take out the garbage?
Assistant: You take out the garbage every Thursday, as it's trash day for you.

高级用法

上面的示例相当简单,但希望它能帮助您想象如何将存储和召回机制交织到您的代理中。在下面的部分中,我们将探讨一些可能对您处理更高级用例有所帮助的主题。

多向量索引

您可以单独存储和搜索记忆的不同方面,以提高召回率或在语义索引过程中省略某些字段。

import { InMemoryStore } from "@langchain/langgraph";

// Configure store to embed both memory content and emotional context
const multiVectorStore = new InMemoryStore({
  index: {
    embeddings: embeddings,
    dims: 1536,
    fields: ["memory", "emotional_context"],
  },
});

// Store memories with different content/emotion pairs
await multiVectorStore.put(["user_123", "memories"], "mem1", {
  memory: "Had pizza with friends at Mario's",
  emotional_context: "felt happy and connected",
  this_isnt_indexed: "I prefer ravioli though",
});
await multiVectorStore.put(["user_123", "memories"], "mem2", {
  memory: "Ate alone at home",
  emotional_context: "felt a bit lonely",
  this_isnt_indexed: "I like pie",
});

// Search focusing on emotional state - matches mem2
const results = await multiVectorStore.search(["user_123", "memories"], {
  query: "times they felt isolated",
  limit: 1,
});

console.log("Expect mem 2");

for (const r of results) {
  console.log(`Item: ${r.key}; Score(${r.score})`);
  console.log(`Memory: ${r.value.memory}`);
  console.log(`Emotion: ${r.value.emotional_context}`);
}
Expect mem 2
Item: mem2; Score(0.58961641225287)
Memory: Ate alone at home
Emotion: felt a bit lonely

存储时覆盖字段

无论存储的默认配置如何,您都可以在存储特定记忆时使用 put(..., { index: [...fields] }) 覆盖要嵌入的字段。

import { InMemoryStore } from "@langchain/langgraph";

const overrideStore = new InMemoryStore({
  index: {
    embeddings: embeddings,
    dims: 1536,
    // Default to embed memory field
    fields: ["memory"],
  }
});

// Store one memory with default indexing
await overrideStore.put(["user_123", "memories"], "mem1", {
  memory: "I love spicy food",
  context: "At a Thai restaurant",
});

// Store another memory, overriding which fields to embed
await overrideStore.put(["user_123", "memories"], "mem2", {
  memory: "I love spicy food",
  context: "At a Thai restaurant",
  // Override: only embed the context
  index: ["context"]
});

// Search about food - matches mem1 (using default field)
console.log("Expect mem1");
const results2 = await overrideStore.search(["user_123", "memories"], {
  query: "what food do they like",
  limit: 1,
});

for (const r of results2) {
  console.log(`Item: ${r.key}; Score(${r.score})`);
  console.log(`Memory: ${r.value.memory}`);
}

// Search about restaurant atmosphere - matches mem2 (using overridden field)
console.log("Expect mem2");
const results3 = await overrideStore.search(["user_123", "memories"], {
  query: "restaurant environment",
  limit: 1,
});

for (const r of results3) {
  console.log(`Item: ${r.key}; Score(${r.score})`);
  console.log(`Memory: ${r.value.memory}`);
}
Expect mem1
Item: mem1; Score(0.3375009515587189)
Memory: I love spicy food
Expect mem2
Item: mem2; Score(0.1920732213417712)
Memory: I love spicy food