728x90
반응형

어제까지는 Buffer/Window/Token/Entity/Summary 류의 “대화 로그 기반” 메모리를 정리했습니다.

 

오늘은 한 단계 더 나아가, 의미 검색(semantic search) 을 메모리에 접목하는 VectorStoreRetrieverMemory와, 실제 파이프라인에서 많이 쓰는 LCEL(LangChain Expression Language) 흐름에 메모리를 주입하는 방법을 다룹니다.

 

핵심 포인트는 두 가지입니다.

  1. VectorStoreRetrieverMemory: “대화 한 줄”을 통째로 임베딩해 벡터 DB에 넣고, 질의 시 의미적으로 가장 가까운 대화를 리트리브하여 맥락으로 쓰기
  2. LCEL + Memory: 체인 정의 단계에서 입력/출력에 메모리를 연결해 RAG + Memory를 자연스럽게 합치는 패턴

이번 글에서는 먼저 VectorStoreRetrieverMemory를 실습하고, 이어서 LCEL에 메모리를 붙이는 예제를 다룰 예정입니다.


1. VectorStoreRetrieverMemory

정의

대화 내용을 문장 임베딩으로 저장해두었다가, 사용자의 질문과 의미적으로 가장 유사한 과거 대화를 리트리브해서 맥락으로 제공하는 메모리.

  • Buffer/Window 계열은 “순서대로 붙이기”라면, VectorStoreRetrieverMemory는 “의미로 골라오기”.
  • 인터뷰/고객상담/FAQ처럼 “과거에 했던 특정 내용”을 다시 묻는 경우에 특히 강합니다.
  • 예: “면접자 전공?”, “이 고객이 선호한 플랜?” 등.

언제 쓰면 좋은가

  • 대화가 길고, 특정 사실을 다시 참조해야 할 일이 잦을 때
  • 키워드가 달라도 의미적으로 같은 질문을 잘 매칭해야 할 때
  • RAG 문서 검색과 대화 히스토리 검색동일한 방식(임베딩/리트리버)으로 다루고 싶을 때

코드 예시

먼저, 벡터 스토어를 초기화 합니다.

import faiss
from langchain_openai import OpenAIEmbeddings
from langchain.docstore import InMemoryDocstore
from langchain.vectorstores import FAISS

# 임베딩 모델을 정의합니다.
embeddings_model = OpenAIEmbeddings()

# Vector Store 를 초기화 합니다.
embedding_size = 1536
index = faiss.IndexFlatL2(embedding_size)
vectorstore = FAISS(embeddings_model, index, InMemoryDocstore({}), {})

 

FAISS클래스는 LangChain에서 텍스트 ↔ 벡터 ↔ 문서 검색을 이어주는 래퍼입니다.

생성자의 주요 매개변수는 다음과 같습니다.

1. embedding_function → embeddings_model

  • 텍스트를 벡터로 바꿔주는 함수(임베딩 모델).
  • 여기서는 OpenAIEmbeddings()을 사용하여 OpenAI의 임베딩 모델(text-embedding-3-large, 1536차원)을 적용합니다.
  • 사용자가 add_texts()를 호출하면 내부적으로 이 함수를 불러 문장을 벡터화하고, FAISS 인덱스에 저장합니다.

2. index → faiss.IndexFlatL2(embedding_size)

  • 벡터를 저장하고 검색하는 FAISS 인덱스 객체입니다.
  • IndexFlatL2는 L2 거리(유클리드 거리) 기반으로 최근접 검색을 수행합니다.
  • 인자로 주어진 embedding_size=1536은 벡터 차원 수를 의미합니다.
  • 다른 선택지:
    • IndexFlatIP: 내적 기반 검색 (Cosine 유사도에 활용)
    • IVF, HNSW 등: 대규모 검색에서 근사 최근접 탐색(ANN)을 지원

3. docstore → InMemoryDocstore({})

  • 원문 텍스트를 저장하는 공간입니다.
  • FAISS 인덱스에는 숫자 벡터만 저장되므로, 원문을 함께 보관할 별도 저장소가 필요합니다.
  • 여기서는 간단히 파이썬 딕셔너리 기반의 InMemoryDocstore({})를 사용했습니다.
  • 실전에서는 SQLite, MongoDB, Chroma 등 외부 저장소와 연결할 수 있습니다.

4. index_to_docstore_id → {}

  • 벡터 ID ↔ 문서 ID 매핑을 관리하는 딕셔너리입니다.
  • 예: {0: "doc_1", 1: "doc_2", ...} 형태로 인덱스와 문서 식별자를 연결합니다.
  • 처음에는 빈 딕셔너리 {}로 시작하며, add_texts() 등을 호출하면 자동으로 채워집니다.

정리

  • embedding_function: 텍스트를 벡터로 변환
  • index: 벡터 저장/검색 엔진
  • docstore: 원문 텍스트 보관
  • index_to_docstore_id: 벡터와 문서의 ID 매핑

이 네 가지가 합쳐져야,

텍스트 → 벡터화 → 인덱싱 → 검색 결과 원문 반환의 파이프라인이 완성됩니다.

 

실제 사용에서는 k를 더 높은 값으로 설정하지만, 여기서는 k=1을 사용하여 다음과 같이 표시합니다.

 

from langchain.memory import VectorStoreRetrieverMemory

# 벡터 조회가 여전히 의미적으로 관련성 있는 정보를 반환한다는 것을 보여주기 위해서입니다.
retriever = vectorstore.as_retriever(search_kwargs={"k": 1})
memory = VectorStoreRetrieverMemory(retriever=retriever)

# 임의의 대화를 저장합니다.
memory.save_context(
    inputs={
        "human": "안녕하세요, 오늘 면접에 참석해주셔서 감사합니다. 자기소개 부탁드립니다."
    },
    outputs={
        "ai": "안녕하세요. 저는 컴퓨터 과학을 전공한 신입 개발자입니다. 대학에서는 주로 자바와 파이썬을 사용했으며, 최근에는 웹 개발 프로젝트에 참여하여 실제 사용자를 위한 서비스를 개발하는 경험을 했습니다."
    },
)
memory.save_context(
    inputs={"human": "프로젝트에서 어떤 역할을 맡았나요?"},
    outputs={
        "ai": "제가 맡은 역할은 백엔드 개발자였습니다. 사용자 데이터 처리와 서버 로직 개발을 담당했으며, RESTful API를 구현하여 프론트엔드와의 통신을 담당했습니다. 또한, 데이터베이스 설계에도 참여했습니다."
    },
)
memory.save_context(
    inputs={
        "human": "팀 프로젝트에서 어려움을 겪었던 경험이 있다면 어떻게 해결했나요?"
    },
    outputs={
        "ai": "프로젝트 초기에 의사소통 문제로 몇 가지 어려움이 있었습니다. 이를 해결하기 위해 저희 팀은 정기적인 미팅을 갖고 각자의 진행 상황을 공유했습니다. 또한, 문제가 발생했을 때는 적극적으로 의견을 나누고, 합리적인 해결책을 찾기 위해 노력했습니다."
    },
)
memory.save_context(
    inputs={"human": "개발자로서 자신의 강점은 무엇이라고 생각하나요?"},
    outputs={
        "ai": "제 강점은 빠른 학습 능력과 문제 해결 능력입니다. 새로운 기술이나 도구를 빠르게 습득할 수 있으며, 복잡한 문제에 직면했을 때 창의적인 해결책을 제시할 수 있습니다. 또한, 팀워크를 중시하며 동료들과 협력하는 것을 중요하게 생각합니다."
    },
)

 

search_kwargs={"k": 1}

  • 가장 유사한 1개만 가져오도록 설정. 필요에 따라 3~5로 조정해 다중 근거를 넣을 수도 있어요.

VectorStoreRetrieverMemory

  • save_context() 호출 시 inputs/outputs 전체 텍스트를 하나의 문서처럼 임베딩해서 저장.
  • load_memory_variables({"human": "...질문..."}) 시 질문을 임베딩 → 벡터스토어에서 가장 유사한 과거 대화(k=1)를 찾아 history로 반환.

다음의 질문을 했을 때 Vector Store로부터 1개(k=1이기 때문)의 가장 관련성 높은 대화를 반환합니다.

질문: “면접자 전공은 무엇인가요?”

# 메모리에 질문을 통해 가장 연관성 높은 1개 대화를 추출합니다.
print(memory.load_memory_variables({"human": "면접자 전공은 무엇인가요?"})["history"])

 

출력 결과:

human: 안녕하세요, 오늘 면접에 참석해주셔서 감사합니다. 자기소개 부탁드립니다.
ai: 안녕하세요. 저는 컴퓨터 과학을 전공한 신입 개발자입니다. 대학에서는 주로 자바와 파이썬을 사용했으며, 최근에는 웹 개발 프로젝트에 참여하여 실제 사용자를 위한 서비스를 개발하는 경험을 했습니다.

 

이번에는 다른 질문을 통해 가장 연관성 높은 1개 대화를 추출합니다.

질문: "면접자가 프로젝트에서 맡은 역할은 무엇인가요?”

print(
    memory.load_memory_variables(
        {"human": "면접자가 프로젝트에서 맡은 역할은 무엇인가요?"}
    )["history"]
)

 

출력 결과:

human: 프로젝트에서 어떤 역할을 맡았나요?
ai: 제가 맡은 역할은 백엔드 개발자였습니다. 사용자 데이터 처리와 서버 로직 개발을 담당했으며, RESTful API를 구현하여 프론트엔드와의 통신을 담당했습니다. 또한, 데이터베이스 설계에도 참여했습니다.

2. LCEL (대화 내용 기억하기): 메모리 추가

이번에는 임의의 체인에 메모리를 추가해보도록 하겠습니다.

코드 예시

from operator import itemgetter
from langchain.memory import ConversationBufferMemory
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
from langchain_openai import ChatOpenAI

# ChatOpenAI 모델을 초기화합니다.
model = ChatOpenAI()

# 대화형 프롬프트를 생성합니다. 이 프롬프트는 시스템 메시지, 이전 대화 내역, 그리고 사용자 입력을 포함합니다.
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "You are a helpful chatbot"),
        MessagesPlaceholder(variable_name="chat_history"),
        ("human", "{input}"),
    ]
)

 

대화 내용을 저장할 메모리인 ConversationBufferMemory를 생성하고 return_messages 매개변수를 True로 설정하여, 생성된 인스턴스가 메시지를 반환하도록 합니다.

  • memory_key 설정: 추후 Chain의 prompt안에 대입될 key입니다. 변경하여 사용할 수 있습니다.
# 대화 버퍼 메모리를 생성하고, 메시지 반환 기능을 활성화합니다.
memory = ConversationBufferMemory(return_messages=True, memory_key="chat_history")

 

저장된 대화기록을 확인합니다. 아직 저장하지 않았으므로, 대화기록은 비어 있습니다.

memory.load_memory_variables({})  # 메모리 변수를 빈 딕셔너리로 초기화합니다.

 

출력 결과:

{'chat_history': []}

 

RunnablePassthrough.assign을 사용하여 chat_history 변수에 memory.load_memory_variables 함수의 결과를 할당하고, 이 결과에서 chat_history 키에 해당하는 값을 추출합니다.

runnable = RunnablePassthrough.assign(
    chat_history=RunnableLambda(memory.load_memory_variables)
    | itemgetter("chat_history")  # memory_key 와 동일하게 입력합니다.
)
runnable.invoke({"input": "hi"})

 

출력 결과:

{'input': 'hi', 'chat_history': []}

 

runnable과 prompt, model을 연결하여 chain을 만들어 보았습니다.

chain = runnable | prompt | model

 

첫 번째 대화를 진행합니다.

# chain 객체의 invoke 메서드를 사용하여 입력에 대한 응답을 생성합니다.
response = chain.invoke({"input": "만나서 반갑습니다. 제 이름은 테디입니다."})
print(response.content)  # 생성된 응답을 출력합니다.

 

출력 결과:

만나서 반가워요, 테디님. 무엇을 도와드릴까요?

 

메모리에도 저장되어있는지 확인해볼까요?

memory.load_memory_variables({})

 

출력 결과:

{'chat_history': []}

 

저장 되어있지 않습니다. 따로 저장을 해야하는것을 알 수 있습니다.

 

memory.save_context 함수는 입력 데이터(inputs)와 응답 내용(response.content)을 메모리에 저장하는 역할을 합니다. 이는 AI 모델의 학습 과정에서 현재 상태를 기록하거나, 사용자의 요청과 시스템의 응답을 추적하는 데 사용될 수 있습니다.

# 입력된 데이터와 응답 내용을 메모리에 저장합니다.
memory.save_context(
    {"human": "만나서 반갑습니다. 제 이름은 테디입니다."}, {"ai": response.content}
)

# 저장된 대화기록을 출력합니다.
memory.load_memory_variables({})

 

출력 결과:

{'chat_history': [HumanMessage(content='만나서 반갑습니다. 제 이름은 테디입니다.', additional_kwargs={}, response_metadata={}),
AIMessage(content='만나서 반가워요, 테디님. 무엇을 도와드릴까요?', additional_kwargs={}, response_metadata={})]}

 

이름을 기억하고 있는지 추가 질의해보도록 하겠습니다.

# 이름을 기억하고 있는지 추가 질의합니다.
response = chain.invoke({"input": "제 이름이 무엇이었는지 기억하세요?"})
# 답변을 출력합니다.
print(response.content)

 

출력 결과:

네, 테디님이시죠. 어떻게 도와드릴까요?

마무리

오늘은 25일차에 이어 VectorStoreRetrieverMemory와 LCEL에 메모리 주입하기를 다뤄봤습니다.

  • VectorStoreRetrieverMemory는 단순히 대화를 순서대로 저장하는 Buffer 계열과 달리, 의미 기반 검색을 통해 과거 대화 중 “관련 있는 부분만” 다시 불러올 수 있다는 점에서 강력했습니다. 키워드가 조금 달라도 의미적으로 유사하면 매칭할 수 있기에, 고객 상담이나 FAQ 같은 사실 기반 대화에 특히 유용합니다.
  • 이어서, LCEL(LangChain Expression Language) 안에 메모리를 직접 넣어 체인을 구성하는 방법을 실습했습니다. 기존에는 ConversationChain 같은 추상화된 체인에 의존했지만, LCEL에서는 입력 → 프롬프트 → 모델 → 출력 파이프라인에 메모리를 하나의 모듈처럼 삽입할 수 있었습니다. 이 방식 덕분에 더 유연하고 직관적으로, RAG와 Memory를 결합할 수 있다는 점이 큰 장점이었습니다.

즉, 오늘의 핵심은:

  • 과거 대화 검색도 문서 검색처럼, 의미 기반으로 다룰 수 있다.
  • LCEL을 쓰면 메모리도 체인의 한 부분으로 명확히 주입 가능하다.

앞으로는 단순히 대화 내용을 이어가는 수준을 넘어서, RAG + Memory를 통합한 지능형 대화형 에이전트를 설계하는 데 필수적인 기반이 될 것 같습니다 🚀

 

읽어주셔서 감사합니다!

728x90
반응형

+ Recent posts