跳到内容

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

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

依赖项和环境设置

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

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