Issue 01중국 AI
AC POST
중국 AI 목록
掘金2026년 9월 20일 23:13중국어 → 한국어

LangChain.js 에이전트 메모리 실전: Milvus로 장기 기억 구축

대화 벡터화, Milvus 저장, 유사도 검색, 컨텍스트 주입, 새 기억 재기록을 구현하고 단기 이력·요약·장기 의미 기억의 협업을 설명한다.

중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.

요약: 검색식 Memory를 중심으로 대화 벡터화, Milvus 저장, 유사도 검색, 컨텍스트 주입과 새 기억 되쓰기를 완전하게 구현하고, 단기 이력·요약·장기 시맨틱 기억이 어떻게 협력하는지 설명한다.

상편에서는 세 가지 문제를 해결했다:

- 메시지 이력을 사용해 모델에 연속 대화 능력을 부여한다;

- 파일을 사용해 메시지를 프로세스 간에 영속화한다;

- 절단과 요약으로 컨텍스트 길이를 제어한다.

그러나 절단과 요약은 모두 "최근에 무슨 일이 있었는가"를 중심으로 컨텍스트를 관리한다. 사용자가 대량의 대화를 진행한 뒤 갑자기 아주 오래전에 논의했던 벡터 데이터베이스에 대해 묻는다고 가정하자. 최근 몇 개의 메시지에는 관련 내용이 포함되어 있지 않을 수 있고, 최근 컨텍스트만 유지하면 그 오래된 정보는 이미 보이지 않게 되며, 매번 전체 이력을 전달하면 컨텍스트가 계속 팽창하게 된다.

검색식 Memory는 세 번째 경로를 제공한다:

역사 대화를 Milvus에 영속화 ↓ 현재 질문을 벡터로 변환 ↓ 벡터 유사도로 관련 역사 검색 ↓ 관련 역사와 현재 질문을 함께 모델에 전달 ↓ 이번 라운드의 문답을 계속 Milvus에 되쓰기

이렇게 하면 장기 기억이 전부 현재 컨텍스트에 들어갈 필요가 없다. 매 라운드마다 질문과 의미상 가장 가까운 소량의 기록만 가져오므로 입력 규모를 제어하면서도 더 이른 정보를 되찾을 수 있다.

이 글에서는 이 폐루프를 완성한다: Milvus 컬렉션 생성, Embedding 생성, 역사 대화 삽입, 유사도 검색 실행, 모델 컨텍스트 이어붙이기, 그리고 새 문답을 다음 라운드에서 검색 가능한 기억으로 저장하기.

一、검색식 Memory의 구성

전체 과정은 세 가지 핵심 객체를 포함한다:

- Embedding 모델 : 자연어를 고정 차원의 벡터로 변환한다;

- Milvus : 벡터 및 그에 대응하는 대화 본문, 라운드와 시간을 저장한다;

- 채팅 모델 : 검색된 관련 역사를 읽고 현재 질문에 답한다.

의존성은 다음과 같다:

{ "dependencies" : { "@langchain/core" : "^1.2.11" , "@langchain/openai" : "^1.5.13" , "@zilliz/milvus2-sdk-node" : "^3.0.5" , "dotenv" : "^17.4.2" } }

환경 변수는 채팅 모델, Embedding 모델과 Milvus 주소를 동시에 설명해야 한다:

MODEL_NAME=당신의 채팅 모델 이름 OPENAI_API_KEY=당신의 API-Key OPENAI_BASE_URL=당신의 모델 서비스 주소 EMBEDDINGS_MODEL_NAME=당신의 Embedding 모델 이름 MILVUS_ADDRESS=당신의 Milvus 주소

여기서는 채팅 모델과 Embedding 모델이 동일한 API Key와 기본 주소를 사용하도록 하되, 모델 이름은 각각 설정한다.

二、Embedding 모델과 Milvus 클라이언트 초기화

먼저 컬렉션 이름과 벡터 차원을 정의한다:

const COLLECTION_NAME = "conversations" ; const VECTOR_DIM = 1024 ;

그다음 Embedding 인스턴스를 생성한다:

import "dotenv/config" ; import { OpenAIEmbeddings } from "@langchain/openai" ; const embeddings = new OpenAIEmbeddings ({ apiKey : process. env . OPENAI_API_KEY , model : process. env . EMBEDDINGS_MODEL_NAME , configuration : { baseURL : process. env . OPENAI_BASE_URL , }, dimensions : VECTOR_DIM , }); async function getEmbedding ( text ) { return embeddings. embedQuery (text); }

embedQuery(text) 는 한 단락의 텍스트를 받아 길이 1024의 숫자 배열을 반환한다. 이 배열 자체는 독자가 읽는 내용이 아니라 이후 유사도 검색의 근거이다.

VECTOR_DIM 은 Embedding 설정과 Milvus 필드 정의에 동시에 사용되며, 두 곳이 반드시 일치해야 한다:

Embedding 출력 차원: 1024 Milvus vector 필드 차원: 1024

그다음 Milvus 클라이언트를 생성한다:

import { MilvusClient } from "@zilliz/milvus2-sdk-node" ; const client = new MilvusClient ({ address : process. env . MILVUS_ADDRESS , });

정식으로 테이블 생성, 쓰기 또는 검색을 실행하기 전에 클라이언트 연결을 기다린다:

console . log ( "Milvus에 연결 중..." ); await client. connectPromise ; console . log ( "연결 성공" );

三、장기 대화를 위한 컬렉션 구조 설계

관계형 데이터베이스는 보통 구조화된 필드를 테이블로 조직하지만, 여기서는 장기 대화를 conversations 라는 이름의 컬렉션에 저장한다. 각 레코드는 다섯 개 필드를 포함한다:

필드 Milvus 타입 용도 id VarChar 대화 레코드 하나를 고유하게 식별하며 동시에 기본 키로 사용 vector FloatVector 대화 본문에 대응하는 1024차원 벡터 content VarChar 읽을 수 있는 사용자 또는 AI 대화 본문 round Int64 대화 라운드 timestamp VarChar ISO 형식 시간 문자열

컬렉션 생성:

import { DataType , IndexType , MetricType , } from "@zilliz/milvus2-sdk-node" ; await client. createCollection ({ collection_name : COLLECTION_NAME , fields : [ { name : "id" , data_type : DataType . VarChar , max_length : 50 , is_primary_key : true , }, { name : "vector" , data_type : DataType . FloatVector , dim : VECTOR_DIM , }, { name : "content" , data_type : DataType . VarChar , max_length : 5000 , }, { name : "round" , data_type : DataType . Int64 , }, { name : "timestamp" , data_type : DataType . VarChar , max_length : 100 , }, ], });

시간 필드는 new Date().toISOString() 으로 문자열을 생성한다:

timestamp : new Date (). toISOString ()

따라서 schema 에서는 이를 VarChar 로 정의하며, 날짜 타입으로 정의하지 않는다.

이 schema 는 또한 벡터 데이터베이스 레코드의 두 부분을 보여준다:

- vector 는 기계가 유사도를 계산하는 데 사용된다;

- content , round , timestamp 는 검색 결과를 사람이 이해할 수 있고 모델도 읽을 수 있는 컨텍스트로 다시 조직하는 데 사용된다.

벡터만 저장하고 본문을 저장하지 않으면 검색 후 실제 대화를 채팅 모델에 되돌려줄 수 없다.

四、벡터 필드에 인덱스 생성

컬렉션 생성 후 vector 필드에 인덱스를 생성한다:

await client. createIndex ({ collection_name : COLLECTION_NAME , field_name : "vector" , index_type : IndexType . IVF_FLAT , metric_type : MetricType . COSINE , }); console . log ( "인덱스가 이미 생성되었습니다" );

이 설정은 세 가지를 표현한다:

- 검색 대상은 vector 필드이다;

- 인덱스 타입은 IVF_FLAT 을 사용한다;

- 유사도 측정은 COSINE 을 사용한다.

이후 검색에서도 MetricType.COSINE 을 명시적으로 작성하여, 쓰기 후의 인덱스 설정과 조회 시의 측정 방식이 일관되게 유지되도록 보장한다.

컬렉션과 인덱스는 초기화 작업에 속한다. 아래 초기화 프로그램은 처음 데이터를 준비할 때 실행해야 하며, 같은 이름의 컬렉션이 이미 존재하면 다시 생성할 때 이름 충돌이 발생하므로 초기화 로직을 매 라운드 채팅 요청에 섞어 반복 실행하면 안 된다.

五、시드 대화를 벡터로 변환하여 Milvus에 쓰기

먼저 세 라운드, 여섯 개 대화를 준비한다:

const conversations = [ { id : "conversation_1" , content : "사용자: 안녕하세요, 저는 최근 Milvus 벡터 데이터베이스를 공부하고 있습니다." , round : 1 , timestamp : new Date (). toISOString (), }, { id : "conversation_2" , content : "AI: Milvus는 벡터 데이터베이스로, 주로 RAG, 시맨틱 검색과 추천 시스템에 사용됩니다." , round : 1 , timestamp : new Date (). toISOString (), }, { id : "conversation_3" , content : "사용자: 벡터 데이터베이스와 MySQL의 차이는 무엇인가요?" , round : 2 , timestamp : new Date (). toISOString (), }, { id : "conversation_4" , content : "AI: MySQL은 구조화된 데이터와 정확한 조회에 더 적합하고, Milvus는 벡터 유사도에 따른 시맨틱 검색에 더 능숙합니다." , round : 2 , timestamp : new Date (). toISOString (), }, { id : "conversation_5" , content : "사용자: 그렇다면 RAG는 왜 벡터 데이터베이스를 사용해야 하나요?" , round : 3 , timestamp : new Date (). toISOString (), }, { id : "conversation_6" , content : "AI: RAG는 대량의 텍스트에서 사용자 질문과 의미가 가장 유사한 내용을 찾아 대규모 모델에 전달해 답변을 생성해야 하기 때문입니다." , round : 3 , timestamp : new Date (). toISOString (), }, ];

각 레코드에는 이 시점에 아직 vector 가 없다. Promise.all() 로 모든 벡터를 동시에 생성하고 기존 필드를 유지할 수 있다:

const conversationData = await Promise . all ( conversations. map ( async (item) => ({ ...item, vector : await getEmbedding (item. content ), })) );

변환 전:

{ id, content, round, timestamp }

변환 후:

{ id, content, round, timestamp, vector }

마지막으로 한 번에 쓴다:

await client. insert ({ collection_name : COLLECTION_NAME , data : conversationData, });

여기서는 시드 데이터를 개별 사용자 메시지 또는 AI 메시지 단위로 저장하므로, 한 번의 검색이 어떤 질문을 반환할 수도 있고 그에 대응하는 답변을 반환할 수도 있다. 이후 새 대화를 저장할 때는 조금 다른 단위를 사용한다: 한 라운드의 "사용자 질문 + AI 답변" 전체를 하나의 레코드로 합친다.

六、전체 초기화 프로그램

연결, 컬렉션 생성, 인덱스 생성과 시드 데이터 삽입을 조합하면:

import "dotenv/config" ; import { MilvusClient , DataType , MetricType , IndexType , } from "@zilliz/milvus2-sdk-node" ; import { OpenAIEmbeddings } from "@langchain/openai" ; const COLLECTION_NAME = "conversations" ; const VECTOR_DIM = 1024 ; const embeddings = new OpenAIEmbeddings ({ apiKey : process. env . OPENAI_API_KEY , model : process. env . EMBEDDINGS_MODEL_NAME , configuration : { baseURL : process. env . OPENAI_BASE_URL , }, dimensions : VECTOR_DIM , }); async function getEmbedding ( text ) { return embeddings. embedQuery (text); } const client = new MilvusClient ({ address : process. env . MILVUS_ADDRESS , }); async function main ( ) { try { console . log ( "Milvus에 연결 중..." ); await client. connectPromise ; console . log ( "연결 성공" ); await client. createCollection ({ collection_name : COLLECTION_NAME , fields : [ { name : "id" , data_type : DataType . VarChar , max_length : 50 , is_primary_key : true , }, { name : "vector" , data_type : DataType . FloatVector , dim : VECTOR_DIM , }, { name : "content" , data_type : DataType . VarChar , max_length : 5000 , }, { name : "round" , data_type : DataType . Int64 , }, { name : "timestamp" , data_type : DataType . VarChar , max_length : 100 , }, ], }); await client . createIndex ({ collection_name : COLLECTION_NAME , field_name : "vector" , index_type : IndexType . IVF_FLAT , metric_type : MetricType . COSINE , }); const conversations = [ { id : "conversation_1" , content : "사용자: 안녕하세요, 저는 최근 Milvus 벡터 데이터베이스를 공부하고 있습니다." , round : 1 , timestamp : new Date (). toISOString (), }, { id : "conversation_2" , content : "AI: Milvus는 벡터 데이터베이스로, 주로 RAG, 시맨틱 검색 및 추천 시스템에 사용됩니다." , round : 1 , timestamp : new Date (). toISOString (), }, { id : "conversation_3" , content : "사용자: 벡터 데이터베이스와 MySQL은 무슨 차이가 있나요?" , round : 2 , timestamp : new Date (). toISOString (), }, { id : "conversation_4" , content : "AI: MySQL은 구조화된 데이터와 정확한 질의에 더 적합하고, Milvus는 벡터 유사도를 기반으로 한 시맨틱 검색에 더 뛰어납니다." , round : 2 , timestamp : new Date (). toISOString (), }, { id : "conversation_5" , content : "사용자: 그러면 RAG는 왜 벡터 데이터베이스를 사용하나요?" , round : 3 , timestamp : new Date (). toISOString (), }, { id : "conversation_6" , content : "AI: RAG는 대량의 텍스트에서 사용자 질문과 시맨틱상 가장 유사한 내용을 찾아낸 뒤, 이를 대규모 모델에 넘겨 답변을 생성해야 하기 때문입니다." , round : 3 , timestamp : new Date (). toISOString (), }, ]; const conversationData = await Promise . all ( conversations. map ( async (item) => ({ ...item, vector : await getEmbedding (item. content ), })) ); await client. insert ({ collection_name : COLLECTION_NAME , data : conversationData, }); console . log ( "초기 대화가 Milvus에 기록되었습니다" ); } catch (error) { console . error ( "오류:" , error); } } main ();

초기화가 완료되면 conversations 컬렉션은 검색 가능한 장기 대화를 갖추게 된다.

7. 현재 질문을 기반으로 관련 이력 검색

검색 함수는 현재 질문과 반환 개수 k를 받는다:

import { MetricType } from "@zilliz/milvus2-sdk-node" ; async function retrieveRelevantConversations ( query, k = 2 ) { try { const queryVector = await getEmbedding (query); const searchResult = await client. search ({ collection_name : COLLECTION_NAME , vector : queryVector, limit : k, metric_type : MetricType . COSINE , output_fields : [ "id" , "content" , "round" , "timestamp" ], }); return searchResult. results ; } catch (error) { console . error ( "대화 검색 중 오류:" , error. message ); return []; } }

그 실행 과정은 세 단계로 나눌 수 있다.

1. 현재 질문을 벡터로 변환

const queryVector = await getEmbedding (query);

과거 본문과 현재 질문은 동일한 Embedding 설정을 사용하여 같은 차원의 벡터를 생성한다.

2. 동일한 벡터 필드에서 검색 실행

const searchResult = await client. search ({ collection_name : COLLECTION_NAME , vector : queryVector, limit : 2 , metric_type : MetricType . COSINE , output_fields : [ "id" , "content" , "round" , "timestamp" ], });

limit: 2는 가장 관련성이 높은 두 개의 레코드만 반환한다는 뜻이다. k를 제어하는 것은 곧 모델에 주입되는 장기 이력의 개수를 제어하는 것이다.

3. 컨텍스트를 구성할 수 있는 필드 반환

vector는 이미 검색 임무를 완수했고, 챗 모델에 넘길 때 실제로 필요한 것은 읽을 수 있는 필드이므로 output_fields를 통해 ID, 본문, 라운드, 시간을 가져온다.

예외가 발생하면 빈 배열을 반환한다:

async function retrieveRelevantConversations ( query, k = 2 ) { try { // 执行向量检索 } catch (error) { console . error ( "대화 검색 중 오류:" , error. message ); return []; } }

이렇게 하면 상위 채팅 로직은 검색 결과가 없다는 이유로 모델 호출을 계속할 수 없게 되는 대신, 현재 사용자 질문만 사용하는 방식으로 퇴화하여 계속 동작할 수 있다.

8. 검색 결과를 모델 컨텍스트에 주입

세 개의 질문을 연속으로 시연한다고 가정하자:

const conversations = [ { input : "왜 벡터 데이터베이스를 배워야 하나요" }, { input : "MySQL과 Milvus의 차이" }, { input : "RAG는 어느 데이터베이스와 강하게 연관되어 있나요" }, ];

각 라운드마다 먼저 검색을 실행한다:

const retrievedConversations = await retrieveRelevantConversations (input, 2 );

그다음 결과를 구조화된 텍스트 한 단락으로 변환한다:

let relevantHistory = "" ; if (retrievedConversations. length > 0 ) { relevantHistory = retrievedConversations . map ( ( item, index ) => ` [历史对话 ${index + 1 } ] 轮次: ${item.round} ${item.content} ` ) . join ( "\n\n----------\n\n" ); } else { console . log ( "관련 이력 대화를 찾지 못했습니다" ); }

이력이 성공적으로 발견되면 최종 메시지는 검색 내용과 현재 질문을 동시에 포함한다:

const contextMessages = relevantHistory ? [ new HumanMessage ( `相关历史对话: ${relevantHistory} 用户问题: ${input} ` ), ] : [userMessage]; const response = await model. invoke (contextMessages);

모델이 실제로 받는 내용은 대략 다음과 같다:

相关历史对话: [历史对话 1] 轮次:2 用户:向量数据库和 MySQL 有什么区别? ---------- [历史对话 2] 轮次:2 AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。 用户问题:MySQL 和 Milvus 的区别

여기서 챗 모델이 스스로 Milvus에 접속하도록 요구하는 것이 아니다. 검색은 모델 호출 전에 발생하며, 애플리케이션이 결과를 HumanMessage로 정리하고, 모델은 이 컨텍스트를 읽고 답변을 생성하는 역할만 맡는다.

결과가 없으면 contextMessages는 원래의 userMessage로 퇴화한다:

[ new HumanMessage (input)]

9. 단기 history와 모델 컨텍스트는 같은 개념이 아니다

시연에서는 메모리 이력도 하나 유지한다:

const history = new InMemoryChatMessageHistory (); await history. addMessage (userMessage); await history. addMessage (response);

그런데 모델 호출에 사용한 것은:

await model. invoke (contextMessages);

이며, 다음이 아니다:

await model. invoke ( await history. getMessages ());

이는 현재 구현에서 history가 이번 프로그램 실행 기간에 발생한 문답을 기록하는 역할은 하지만, 다음 턴의 모델 입력에 자동으로 참여하지는 않는다는 뜻이다. 실제로 모델 컨텍스트에 들어가는 것은 "Milvus 검색 결과 + 현재 질문"이다.

이 점이 중요하다:

addMessage() 는 단지 메시지를 저장할 뿐이고 getMessages() 를 model.invoke() 에 넣어야 모델의 이번 턴 답변에 영향을 준다

따라서 이 예시가 강조하는 것은 검색식 장기 기억 이지, 이번 실행의 모든 단기 메시지까지 매 턴 함께 가져오는 것이 아니다. 둘을 동시에 사용해야 한다면 contextMessages를 구성할 때 명확히 병합해야 하며, history.addMessage() 만 호출해서는 안 된다.

十、새 문답을 장기 기억에 다시 쓰기

모델이 답변한 뒤, 먼저 이번 턴의 사용자 질문과 AI 답변을 한 단락의 텍스트로 조합한다:

const conversationText = `사용자: ${input} AI: ${response.content} ` ;

그다음 고유 ID, 벡터와 시간을 생성한다:

const conversationId = `conv_ ${ Date .now()} _ ${index + 1 } ` ; const conversationVector = await getEmbedding (conversationText);

마지막으로 동일한 컬렉션에 다시 쓴다:

await client. insert ({ collection_name : COLLECTION_NAME , data : [ { id : conversationId, content : conversationText, vector : conversationVector, round : index + 1 , timestamp : new Date (). toISOString (), }, ], });

이로써 한 턴의 대화가 폐루프를 이룬다:

현재 질문 ↓ Embedding 쿼리 Milvus ↓ 관련 역사 + 현재 질문 ↓ 챗 모델 답변 ↓ 사용자 질문 + AI 답변 ↓ Embedding 을 Milvus 에 다시 쓰기

다음 턴의 검색은 맨 처음 삽입된 시드 대화를 찾을 수 있을 뿐 아니라, 방금 생성된 새 대화도 찾을 수 있다.

시드 데이터는 단일 메시지 단위로 저장되고, 새 데이터는 한 문답을 하나의 레코드로 병합한다. 이 두 가지 입도(granularity) 모두 현재 schema가 받아들일 수 있지만, 검색 결과를 이해할 때는 차이를 주의해야 한다: 전자는 하나의 결과가 사용자 또는 AI의 단일 메시지만을 나타내고, 후자는 하나의 결과가 완전한 한 턴의 문답을 나타낸다.

十一、완전한 검색식 Memory 예시

아래에서는 검색, 생성, 다시 쓰기를 바로 이해할 수 있는 하나의 완전한 흐름으로 조합한다:

import "dotenv/config" ; import { OpenAIEmbeddings , ChatOpenAI } from "@langchain/openai" ; import { InMemoryChatMessageHistory } from "@langchain/core/chat_history" ; import { HumanMessage } from "@langchain/core/messages" ; import { MilvusClient , MetricType , } from "@zilliz/milvus2-sdk-node" ; const COLLECTION_NAME = "conversations" ; const VECTOR_DIM = 1024 ; const embeddings = new OpenAIEmbeddings ({ apiKey : process. env . OPENAI_API_KEY , model : process. env . EMBEDDINGS_MODEL_NAME , configuration : { baseURL : process. env . OPENAI_BASE_URL , }, dimensions : VECTOR_DIM , }); async function getEmbedding ( text ) { return embeddings. embedQuery (text); } const model = new ChatOpenAI ({ modelName : process. env . MODEL_NAME , apiKey : process. env . OPENAI_API_KEY , temperature : 0 , configuration : { baseURL : process. env . OPENAI_BASE_URL , }, }); const client = new MilvusClient ({ address : process. env . MILVUS_ADDRESS , }); async function retrieveRelevantConversations ( query, k = 2 ) { try { const queryVector = await getEmbedding (query); const searchResult = await client. search ({ collection_name : COLLECTION_NAME , vector : queryVector, limit : k, metric_type : MetricType . COSINE , output_fields : [ "id" , "content" , "round" , "timestamp" ], }); return searchResult. results ; } catch (error) { console . error ( "검색 대화 시 오류:" , error. message ); return []; } } async function retrievalMemoryDemo ( ) { try { console . log ( "Milvus 에 연결 중..." ); await client. connectPromise ; console . log ( "연결 성공\n" ); } catch (error) { console . error ( "Milvus 에 연결할 수 없습니다:" , error. message ); return ; } const history = new InMemoryChatMessageHistory (); const conversations = [ { input : "왜 벡터 데이터베이스를 배워야 하는가" }, { input : "MySQL 과 Milvus 의 차이" }, { input : "RAG 는 어느 데이터베이스와 강하게 관련되는가" }, ]; for ( let index = 0 ; index < conversations. length ; index++) { const { input } = conversations[index]; const userMessage = new HumanMessage (input); console . log ( `제 ${index + 1 } 턴 대화, 사용자: ${input} ` ); console . log ( "[관련 역사 대화 검색]" ); const retrievedConversations = await retrieveRelevantConversations (input, 2 ); let relevantHistory = "" ; if (retrievedConversations. length > 0 ) { relevantHistory = retrievedConversations . map ( ( item, resultIndex ) => ` [역사 대화 ${resultIndex + 1 } ] 턴: ${item.round} ${item.content} ` ) . join ( "\n\n----------\n\n" ); } else { console . log ( "관련 역사 대화를 찾지 못했습니다" ); } const contextMessages = relevantHistory ? [ new HumanMessage ( `관련 역사 대화: ${relevantHistory} 사용자 질문: ${input} ` ), ] : [userMessage]; const response = await model. invoke (contextMessages); console . log (response. content ); await history. addMessage (userMessage); await history. addMessage (response); const conversationText = `사용자: ${input} AI: ${response.content} ` ; const conversationId = `conv_ ${ Date .now()} _ ${index + 1 } ` ; const conversationVector = await getEmbedding (conversationText); try { await client. insert ({ collection_name : COLLECTION_NAME , data : [ { id : conversationId, content : conversationText, vector : conversationVector, round : index + 1 , timestamp : new Date (). toISOString (), }, ], }); } catch (error) { console . error ( "삽입 실패:" , error); } } } retrievalMemoryDemo (). catch ( console . error );

실행 순서는 다음과 같다: 먼저 초기화를 따로 실행하여 컬렉션, 인덱스와 시드 대화가 이미 존재하도록 보장한 다음, 검색식 채팅 흐름을 실행한다.

十二、세 가지 컨텍스트 관리 방식을 함께 비교하기

여기까지 해서 세 가지 서로 다른 Memory 관리 전략을 얻게 되었다:

전략 | 역사 선택 근거 | 컨텍스트에 들어가는 내용 | 주요 특징 절단 | 시간 순서 | 최근 몇 개의 메시지 | 단순하고 직접적이나 오래된 정보는 제거됨 요약 | 시간 순서 + 모델 압축 | 오래된 역사 요약 + 최근 원문 | 오래된 정보의 요점은 보존하지만 세부 내용은 압축됨 검색 | 현재 질문과 역사의 벡터 유사도 | 가장 관련성 높은 K 개의 역사 | 비교적 이른 시점의 관련성 높은 대화를 되찾을 수 있음

이들은 세 가지 서로 다른 질문에 답한다:

절단: 최근에 무엇을 말했는가? 요약: 과거에 전반적으로 무엇을 말했는가? 검색: 과거의 어떤 내용이 현재 질문과 가장 관련성이 높은가?

따라서 완전한 Memory 모듈은 이 능력들을 동시에 갖출 수 있으며, 셋 중 하나만 선택할 필요가 없다. 예를 들어:

현재 세션은 최근 메시지를 유지 + 이전 세션은 정기적으로 요약 생성 + 장기 내용은 Milvus 에 기록 + 매 턴 질문에 따라 관련 역사를 검색

자연스러운 발전 방향 하나는 예를 들어 대화가 20건 쌓일 때마다 요약을 한 번 트리거하여, 요약이나 대화를 Milvus에 기록하는 것이다. 이후 Milvus에서 관련 이력을 다시 가져와 최근 메시지와 함께 컨텍스트를 구성한다. 이렇게 하면 Memory는 계속 커지기만 하는 messages 배열이 아니라, Agent Harness 안의 독립적인 저장 및 관리 모듈이 된다.

13. 구현할 때 가장 헷갈리기 쉬운 몇 가지 지점

1. 저장했다고 해서 모델이 본 것은 아니다

메시지가 메모리에 저장되든, 파일에 저장되든, Milvus에 저장되든, 호출 전에 읽어서 model.invoke()의 메시지 배열에 넣어야만 현재 답변에 영향을 준다.

2. Embedding 차원은 앞뒤가 반드시 일치해야 한다

예시는 명확히 1024를 사용한다:

dimensions : 1024

컬렉션 필드도 반드시 다음과 같아야 한다:

dim : 1024

그렇지 않으면 생성된 벡터를 현재 schema대로 정상적으로 기록하거나 검색할 수 없다.

3. 인덱스 생성과 검색은 같은 측도를 사용한다

인덱스 생성과 검색 모두 MetricType.COSINE 를 사용한다. 한쪽만 바꿔서 양쪽 설정이 서로 다른 유사도 기준을 표현하게 해서는 안 된다.

4. content가 최종적으로 채팅 모델에 주입되는 내용이다

벡터는 레코드를 찾는 데 사용되고, 채팅 모델이 읽는 것은 content 다. 따라서 레코드에는 기계 검색용 vector 도 있어야 하고, 모델이 읽을 수 있는 본문도 있어야 한다.

5. 초기화와 채팅 루프는 역할이 다르다

컬렉션, 필드, 인덱스는 저장 구조를 준비하는 역할을 하고, 채팅 루프는 검색과 데이터 추가를 담당한다. 이 둘을 분리하면 매번 채팅할 때마다 같은 이름의 컬렉션을 반복 생성하는 것을 피할 수 있다.

정리

메모리 메시지 배열에서 Milvus까지 이어지는 여정을 보면, Agent Memory의 핵심은 결코 단순히 "채팅 기록을 저장하는 것"만이 아니라, 두 가지 문제를 동시에 해결하는 것임을 알 수 있다:

- 저장 문제 : 이력을 메모리에 둘지, 파일에 둘지, 데이터베이스에 둘지;

- 관리 문제 : 이번 라운드에서는 결국 어떤 이력을 골라 모델에 넘길지.

단기 대화에는 InMemoryChatMessageHistory 를 사용할 수 있고, 프로세스 간 복구에는 파일 이력을 사용할 수 있다; 컨텍스트가 길어지면 메시지 수나 Token 수 기준으로 잘라낼 수 있고, 오래된 대화를 요약으로 만들 수도 있다; 장기 이력이 더 많아지면 Embedding 과 Milvus로 의미 기반으로 관련 레코드를 가져올 수 있다.

최종적으로 형성되는 검색식 Memory 폐루프는 다음과 같다:

대화 생성 → 벡터화 → 영속화 ↑ ↓ 모델 답변 ← 컨텍스트 주입 ← 유사도 검색

이 단계에 이르면 모델은 여전히 무상태이지만, Agent는 이미 Harness 안의 Memory 모듈을 통해 이력을 저장하고, 컨텍스트를 제어하며, 필요할 때 관련 정보를 찾아낼 수 있다.