跳到内容

如何为您的代理的内存添加语义搜索

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

依赖项和环境设置

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

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