LangChain 긴 문서 다루기: Stuff, Refine, Map Reduce, Map Re-rank

LangChain에서 document loader를 이용해 문서를 읽어들인 후 text splitter를 이용해 긴 문서를 chunk들로 나누었습니다. 그 다음엔 분할한 문서들로 요약이나 질의응답(QA)과 같은 LLM 작업을 수행해야겠죠? 문서를 이용해 LLM 작업을 수행하는 방법들에 대해 알아보겠습니다.

Stuff

먼저 Stuff 방법입니다. 이 방법은 분할할 필요가 없는 짧은 문서 전체를 프롬프트에 넣어 LLM에 전달하는 방법입니다. 간단하고 API를 한 번만 호출하면 되지만, 문서 길이가 길어지면 사용할 수 없는 방법입니다.

Refine

Refine 방법을 이용해 4개의 chunk로 나눠진 문서를 요약하는 과정을 생각해봅시다.

  • 우선 1번 chunk를 요약합니다.
  • 1번 요약에 2번 chunk를 더한 내용을 요약합니다.
  • 1+2번 요약에 3번 chunk를 더한 내용을 요약합니다.
  • 1+2+3번 요약에 4번 chunk를 더한 내용을 요약합니다. 이 내용이 최종 결과가 됩니다.

이 방법은 문서의 맥락을 유지하며 긴 문서를 요약할 수 있는 방법이지만, Stuff 방법에 비하면 API 호출 횟수가 많습니다. 또한 LLM을 순차적으로 호출해야 하므로 전체 작업 시간이 길어지게 됩니다.

Map Reduce

Map Reduce 방법을 이용해 4개의 chunk로 나눠진 문서를 요약하는 과정을 생각해봅시다.

  • 1번부터 4번 chunk를 각각 요약합니다.
  • 4개의 요약 내용을 합해서 다시 요약합니다. 이 내용이 최종 결과가 됩니다.

이 방법은 각 Chunk를 요약할 때 병렬로 요약할 수 있기 때문에 Refine 방법보다 빠르지만 Reduce 과정 포함하므로 API 호출 횟수가 더 많아집니다.

Map Re-rank

Map Re-rank 방법을 이용해 4개의 chunk로 나눈 문서에서 질문에 대한 답을 찾는 과정을 생각해봅시다.

  • 1번부터 4번까지의 Chunk에서 각각 답을 찾습니다. 이 때 답에 대해 확신하는 정도를 점수로 나타냅니다.
  • 확신 점수가 가장 높은 답을 결과로 채택합니다.

이 방법은 Map Reduce 방법에 비하면 API 호출 횟수가 적지만, 몇 개의 Chunk에 걸쳐 답에 관한 정보가 나눠져 있을 경우 이 정보들을 조합해서 답을 찾을 수는 없습니다.

Map Re-rank vs VectorDB

벡터 데이터베이스에서는 문서 Chunk들의 임베딩 벡터와 질문의 임베딩 벡터 사이의 코사인 거리를 이용해 질문과 의미가 가까운 내용을 찾았습니다. 이 방법도 일종의 점수를 매기는 방법이라고 할 수 있겠죠. 그럼 Map Re-rank와 벡터 임베딩을 이용하는 방법은 어떤 차이가 있을까요?

벡터 임베딩을 이용하는 경우에는 벡터 거리를 이용하므로 글의 의미가 질문과 얼마나 가까운지만 고려합니다. 반면에, Map Re-rank 방법은 의미 뿐 아니라 글의 품질과 같은 요소도 고려할 수 있습니다. 따라서 벡터 임베딩과 함께 사용하면 벡터 임베딩 기법을 보완할 수 있는 방법이라고 할 수 있습니다.

LangChain: MarkdownHeaderTextSplitter로 마크다운 문서 나누기

LangChain에서 Document loader로 읽어들인 문서의 텍스트를 적절한 길이의 chunk로 분할할 때Text splitter를 사용합니다. 지정한 문자들로 텍스트를 자르는 RecursiveCharacterTextSplitter에 이어 MarkdownHeaderTextSplitter에 대해 살펴보겠습니다.

MarkdownHeaderTextSplitter

MarkdownHeaderTextSplitter에서는 Markdown 파일에서 사용하는 #, ##, ### 등의 헤더를 기준으로 Markdown 텍스트를 분할합니다. 예제 코드를 보겠습니다.

간단한 마크다운 문자열이 있습니다.

md = """
# Title

## Chapter 1

### Sub1
content 1-1

### Sub2
content 1-2

## Chapter 2
content 2-0

## Chapter 3

### Sub1
content 3-1

### Sub2
content 3-2
"""

1단계 #, 2단계 ##, 3단계 ###까지 헤더를 사용한 문자열입니다. MarkdownHeaderTextSplitter로 분할해보겠습니다.

from langchain.text_splitter import MarkdownHeaderTextSplitter

headers_to_split_on = [
    ("#", "Header 1"),
    ("##", "Header 2"),
    ("###", "Header 3")
]

markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on = headers_to_split_on)
md_header_splits = markdown_splitter.split_text(md)
md_header_splits

## 출력
[Document(page_content='content 1-1', metadata={'Header 1': 'Title', 'Header 2': 'Chapter 1', 'Header 3': 'Sub1'}),
 Document(page_content='content 1-2', metadata={'Header 1': 'Title', 'Header 2': 'Chapter 1', 'Header 3': 'Sub2'}),
 Document(page_content='content 2-0', metadata={'Header 1': 'Title', 'Header 2': 'Chapter 2'}),
 Document(page_content='content 3-1', metadata={'Header 1': 'Title', 'Header 2': 'Chapter 3', 'Header 3': 'Sub1'}),
 Document(page_content='content 3-2', metadata={'Header 1': 'Title', 'Header 2': 'Chapter 3', 'Header 3': 'Sub2'})]

위에서 headers_to_split_on을 이용해 어떤 헤더(#, ##, ###)를 기준으로 분할할지, 분할한 헤더의 이름(Header 1, Header 2, Header 3)을 무엇으로 할지 지정해줍니다. 분할한 헤더의 이름은 결과로 나온 Document 객체의 metadata에 들어갑니다. 출력 결과를 보면 지정해준 1, 2, 3단계 헤더들을 기준으로 문서를 분할한 것을 알 수 있습니다.

이번엔 2단계 헤더까지만 사용해보겠습니다.

headers_to_split_on = [
    ("#", "Header 1"),
    ("##", "Header 2")
]

markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on = headers_to_split_on)
md_header_splits = markdown_splitter.split_text(md)
md_header_splits

## 출력
[Document(page_content='### Sub1\ncontent 1-1  \n### Sub2\ncontent 1-2', metadata={'Header 1': 'Title', 'Header 2': 'Chapter 1'}),
 Document(page_content='content 2-0', metadata={'Header 1': 'Title', 'Header 2': 'Chapter 2'}),
 Document(page_content='### Sub1\ncontent 3-1  \n### Sub2\ncontent 3-2', metadata={'Header 1': 'Title', 'Header 2': 'Chapter 3'})]

결과를 보면 2단계 헤더까지만 이용해 텍스트가 나눠지고, 3단계 헤더는 page_content에 들어가 있는 것을 확인할 수 있습니다.

앞에서 봤던 RecursiveCharacterTextSplitter 또는 다른 Text splitter와 달리, 길이를 기준으로 나누는 것이 아니라 헤더를 기준으로 나누기 때문에 길이가 매우 긴 Document가 나올 수 있습니다.

MarkdownHeaderTextSplitter와 유사한 Text splitter로 HTMLHeaderTextSplitter가 있습니다. HTMLHeaderTextSplitter는 HTML 헤더를 이용해 HTML 파일을 분할합니다.

LangChain: RecursiveCharacterTextSplitter로 긴 글 자르기

LangChain에서 Document loader를 이용해 문서를 읽어들인 후 문서가 길면 LLM에서 소화할 수 있도록 chunk로 분할해야 합니다. 이런 작업을 해주는 클래스들이 langchain.text_splitter 모듈에 들어 있습니다. 해당 모듈에는 다양한 클래스들이 있고, 계속 추가되고 있습니다. 몇 가지만 적어보겠습니다.

Text Splitter

  • CharacterTextSplitter: 특정 문자를 이용해 문자열을 나눕니다.
  • RecursiveCharacterTextSplitter: 아래에서 자세히 살펴보겠습니다.
  • MarkdownHeaderTextSplitter: 마크다운 파일의 헤더를 기준으로 나눕니다.
  • HTMLHeaderTextSplitter: HTML 파일의 헤더를 기준으로 나눕니다.
  • TokenTextSplitter: 토큰 수를 기준으로 나눕니다. OpenAI의 tiktoken을 이용합니다.

RecursiveCharacterTextSplitter

RecursiveCharacterTextSplitter는 지정한 chunk_size 이하가 되도록 문자열을 자르는데, 기본적으로 ["\n\n", "\n", " ", ""]와 같은 문자를 이용해 자릅니다. 순서대로 가장 먼저 “\n\n”으로 자르고, 그래도 chunk_size 보다 긴 chunk는 “\n”으로 자르고, 그래도 길면 ” “로 자르는 방식으로 chunk를 만듭니다. 단순히 글자수로 자르는 것에 비해 문단, 문장, 단어 등으로 자르기 때문에 글의 의미를 보존하는데 좋은 방법이라 할 수 있습니다.

구분 문자(separators)는 원하는 문자들의 리스트로 바꿀 수 있습니다. chunk_size를 계산하는 함수는 기본적으로 len인데, 토크나이저를 지정해서 토큰 수로 chunk_size를 계산할 수도 있습니다. 정규표현식을 이용해 나누는 것도 가능합니다.

예제를 살펴보겠습니다. 프로젝트 구텐베르그 사이트에서 명상록 텍스트 파일을 받아 파일로 저장했습니다.

import requests
url = "https://www.gutenberg.org/cache/epub/2680/pg2680.txt"
response = requests.get(url)

with open("Meditations_pg.txt",'wb') as fh:
    fh.write(response.content)

다음엔 Document loader 중 텍스트 파일을 읽는데 사용하는 TextLoader를 이용해 앞에서 쓴 텍스트 파일을 읽습니다.

from langchain.document_loaders import TextLoader

loader = TextLoader("Meditations_pg.txt")
data = loader.load()

data는 Document 자료형의 원소를 하나 가진 리스트입니다.

len(data[0].page_content) # 417445

길이가 길기 때문에 LLM에 그대로 입력으로 쓸 수는 없습니다. 분할해봅시다.

from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size = 1000,
    chunk_overlap  = 50,
    length_function = len,
    is_separator_regex = False,
)
texts = text_splitter.split_documents(data)

한 chunk의 길이가 1000자 이내가 되도록 나눴습니다.

len(texts) # 595
type(texts) # list

texts[0]
## 결과
Document(page_content='\ufeffThe Project Gutenberg eBook of Meditations\n    \nThis ebook is for the use of anyone anywhere in the United States and\nmost other parts of the world at no cost and with almost no restrictions\nwhatsoever. You may copy it, give it away or re-use it under the terms\nof the Project Gutenberg License included with this ebook or online\nat www.gutenberg.org. If you are not located in the United States,\nyou will have to check the laws of the country where you are located\nbefore using this eBook.\n\nTitle: Meditations\n\n\nAuthor: Emperor of Rome Marcus Aurelius\n\nRelease date: June 1, 2001 [eBook #2680]\n                Most recently updated: March 9, 2021\n\nLanguage: English\n\n\n\n*** START OF THE PROJECT GUTENBERG EBOOK MEDITATIONS ***\n\n\n\nMEDITATIONS\n\nBy Marcus Aurelius\n\n\n\n\nCONTENTS\n\n\n     NOTES\n\n     INTRODUCTION\n\n     FIRST BOOK\n\n     SECOND BOOK\n\n     THIRD BOOK\n\n     FOURTH BOOK\n\n     FIFTH BOOK\n\n     SIXTH BOOK\n\n     SEVENTH BOOK\n\n     EIGHTH BOOK\n\n     NINTH BOOK\n\n     TENTH BOOK', metadata={'source': 'Meditations_pg.txt'})

이렇게 split_documents를 이용하면 Document로 구성된 리스트를 분할할 수 있습니다. 문자열로 구성된 리스트를 분할하고 싶다면 create_documents를, 그냥 문자열을 분할하고 싶다면 split_text 메소드를 이용하면 됩니다.

with open("Meditations_pg.txt",'r') as fh:
    txt = fh.read()
texts2 = text_splitter.create_documents([txt])

len(texts2) # 595
type(texts2) # list

texts2[10]
## 출력
Document(page_content='from him, and he was assassinated. Marcus now went to the east, and\nwhile there the murderers brought the head of Cassius to him; but the\nemperor indignantly refused their gift, nor would he admit the men to\nhis presence.')

create_documents의 결과도 Document 자료형의 리스트로 나오는 것을 볼 수 있습니다. 반면에, split_text를 이용해 문자열을 분할하면 그냥 문자열의 리스트가 나옵니다.

texts3 = text_splitter.split_text(txt)

len(texts3) # 595
type(texts3) # list

texts3[10]
## 출력
'from him, and he was assassinated. Marcus now went to the east, and\nwhile there the murderers brought the head of Cassius to him; but the\nemperor indignantly refused their gift, nor would he admit the men to\nhis presence.'

LangChain: Document Loaders로 문서 읽어들이기

파일로 된 문서를 가지고 LLM으로 작업하기 위해서는 먼저 문서의 내용을 읽어들여야겠죠. 파이썬에서 직접 문서의 내용을 텍스트로 읽어서 LLM에 전달하는 것도 가능합니다. 하지만 LangChain에서 제공하는 Document loader를 이용하면 편리하게 다양한 문서를 통일된 방식으로 읽어들일 수 있습니다.

LangChain에는 PDF, HTML, Text, Markdown, Json, CSV 파일 외에도 다양한 종류의 로컬 파일들과 클라우드 드라이브의 파일들을 읽을 수 있는 Document loader들이 있습니다. Document loader를 이용해 문서를 읽으면 Document 객체의 리스트를 반환합니다. 분할 여부에 따라 하나의 객체만 있는 리스트가 반환될 수도 있고, 페이지마다 하나씩 객체가 생성되어 여러 개의 원소를 가진 리스트가 반환될 수도 있습니다. 문서의 내용은 Document 객체의 page_content 속성에 들어가고, 원본 파일, 문서 제목, 페이지 번호 등의 정보는 metadata 속성에 저장됩니다. 예제를 살펴보겠습니다.

HTML 문서 읽기

import requests

url = "https://www.gutenberg.org/cache/epub/2680/pg2680-images.html"

response = requests.get(url)

with open("Meditations_pg.html",'wb') as fh:
    fh.write(response.content)

연습을 위해 프로젝트 구텐베르크 사이트에서 페이지를 하나 다운받아 Meditations_pg.html 파일로 저장했습니다. 마르쿠스 아우렐리우스의 명상록입니다. 이 html 파일을 읽어보겠습니다. BSHTMLLoader를 이용할텐데, 이를 위해서는 BeautifulSoup4 패키지를 먼저 설치해야 합니다.

# 설치: pip install beautifulsoup4

from langchain.document_loaders import BSHTMLLoader

loader = BSHTMLLoader("Meditations_pg.html")
data = loader.load()

위의 data는 Document 객체가 하나 들어있는 리스트입니다. 메타데이터를 확인해보겠습니다.

data[0].metadata
## 출력
{'source': 'Meditations_pg.html',
 'title': 'The Project Gutenberg eBook of Meditations, by Marcus Aurelius'}

이번엔 내용을 일부만 확인해보겠습니다.

data[0].page_content[:1000]
## 출력
'\n\nThe Project Gutenberg eBook of Meditations, by Marcus Aurelius\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nThe Project Gutenberg eBook of Meditations\nThis ebook is for the use of anyone anywhere in the United States and\nmost other parts of the world at no cost and with almost no restrictions\nwhatsoever. You may copy it, give it away or re-use it under the terms\nof the Project Gutenberg License included with this ebook or online\nat www.gutenberg.org. If you are not located in the United States,\nyou will have to check the laws of the country where you are located\nbefore using this eBook.\nTitle: Meditations\n\nAuthor: Emperor of Rome Marcus Aurelius\n\nRelease date: June 1, 2001 [eBook #2680]\n                Most recently updated: March 9, 2021\nLanguage: English\n\n*** START OF THE PROJECT GUTENBERG EBOOK MEDITATIONS ***\n\n\n      MEDITATIONS\n    \n\n      By Marcus Aurelius\n    \n\n      MARCUS AURELIUS ANTONINUS THE ROMAN EMPEROR\n    \n\n \n\n\n\n      BOOKS\n    \n\n INTRODUCTION \n\n\n\n\n\nHIS FIRST BOOK\n\n\nTHE SECOND BOOK\n\n\nTHE THI'

전체 길이를 확인해보죠.

len(data[0].page_content)
## 출력
487994

공백 포함 48만자가 넘네요. 이 내용을 LLM에 전달하면 token 제한에 걸리겠죠? LLM에 전달할 때는 내용을 chunk로 분할해서 전달하게 됩니다.

PDF 문서 읽기

이번에는 위 웹 페이지를 pdf로 출력한 파일을 읽어보겠습니다. 이를 위해서는 pypdf 패키지를 미리 설치해야 합니다.

# 설치: pip install pypdf

from langchain.document_loaders import PyPDFLoader

loader = PyPDFLoader("Meditations.pdf")
pages = loader.load_and_split()
len(pages)
## 출력
151

load_and_split 명령으로 pdf 파일을 읽으면서 분할했습니다. 그랬더니 길이가 151인 리스트가 나왔습니다. 실제 pdf 파일의 페이지 수가 151입니다.

pages[0].metadata  ## {'source': 'Meditations.pdf', 'page': 0}
pages[150].metadata  ## {'source': 'Meditations.pdf', 'page': 150}

Metadata에 들어있는 항목이 html 파일의 경우와 달라졌죠? 문서에 따라 metadata의 내용이 달라진다는 것을 알 수 있습니다.

pages[0]
## 출력
Document(page_content='10/6/23, 10:40 PM The Project Gutenberg eBook of Meditations, by Marcus Aurelius\nhttps://www.gutenberg.org/cache/epub/2680/pg2680-images.html 1/151The Project Gutenberg eBook of Meditations\nThis ebook is for the use of anyone anywhere in the United\nStates and most other parts of the world at no cost and with\nalmost no restrictions whatsoever . You may copy it, give it\naway or re-use it under the terms of the Project Gutenber g\nLicense included with this ebook or online at\nwww .gutenber g.org. If you are not located in the United\nStates, you will have to check the laws of the country where\nyou are located before using this eBook.\nTitle: Meditations\nAuthor : Emperor of Rome Marcus Aurelius\nRelease date : June 1, 2001 [eBook #2680]\nMost recently updated: March 9, 2021\nLanguage : English\n*** ST ART OF THE PROJECT  GUTENBERG EBOOK\nMEDIT ATIONS ***\nMEDIT A TIONS\nBy Mar cus Aurelius\nMARCUS AURELIUS ANTONINUS\nTHE ROMAN EMPEROR\nBOOKS\nINTRODUCTION\nHIS FIRST  BOOK\nTHE SECOND BOOK\nTHE THIRD BOOK\nTHE FOUR TH BOOK\nTHE FIFTH BOOK\nTHE SIXTH BOOK\nTHE SEVENTH BOOK\nTHE EIGHTH BOOK\nTHE NINTH BOOK', metadata={'source': 'Meditations.pdf', 'page': 0})

리스트의 첫 번째 원소를 확인해보니 Document 객체가 들어있고, 페이지 내용은 page_content 속성에, 추가 정보는 metadata 속성에 들어가 있는 것을 확인할 수 있습니다.

이렇게 LangChain의 Document loader를 이용하면 다양한 유형의 파일들을 편리하게 읽어들일 수 있습니다.

LangChain: 조건문처럼 쓸 수 있는 RouterChain

라우터는 네트워크 중계장치로, 여러 통신망들 중 최적의 경로(route)를 지정해주는 장비입니다. RouterChain은 라우터처럼 입력 query와 목적지 Chain들의 정보를 분석하여 최적의 Chain으로 연결해주는 Chain입니다.

몇 가지 개념들을 살펴봅시다.

  • destination_chains: RouterChain 다음 단계로 연결될 수 있는 목적지 Chain들로, 각각의 destination chain은 LLMChain이나 다른 복합적인 Chain으로 만듭니다.
  • default_chain: 입력 query를 보낼만한 적합한 destination chain이 없을 때 연결하는 Chain입니다.
  • RouterChain: destination_chains 정보와 입력 query를 비교하여 가장 적합한 Chain을 선택합니다. 적절한 Chain이 없으면 default chain을 선택합니다.
  • RouterOutputParser: RouterChain의 출력 문자열을 다음 Chain에서 입력으로 사용할 수 있도록 딕셔너리로 바꿔줍니다. 딕셔너리는 목적지 Chain 정보와 목적지 Chain의 입력 프롬프트 정보를 포함합니다.

Destination Chains

코드를 봅시다. 예제를 위해 철학, 물리학, 컴퓨터, 문학 네 분야의 전문가들을 모셨습니다.

philosophy_expert = """
당신은 플라톤입니다. 철학 전문가입니다. 모르는 질문에는 모른다고 답합니다.
질문: {input}
"""
physics_expert = """
당신은 아인슈타인입니다. 물리학 전문가입니다. 모르는 질문에는 모른다고 답합니다.
질문: {input}
"""
computer_expert = """
당신은 폰 노이만입니다. 컴퓨터 전문가입니다. 모르는 질문에는 모른다고 답합니다.
질문: {input}
"""
literature_expert = """
당신은 셰익스피어입니다. 문학 전문가입니다. 모르는 질문에는 모른다고 답합니다.
질문: {input}
"""

위의 문자열들은 목적지 Chain들의 프롬프트 템플릿으로 사용할겁니다.

prompt_info = [
    {
        "name":"philosophy",
        "description": "철학 관련 질문에 대답",
        "prompt_template": philosophy_expert
    },
    {
        "name":"physics",
        "description": "물리학 관련 질문에 대답",
        "prompt_template": physics_expert
    },
    {
        "name":"computer",
        "description": "컴퓨터 관련 질문에 대답",
        "prompt_template": computer_expert
    },
    {
        "name":"literature",
        "description": "문학 관련 질문에 대답",
        "prompt_template": literature_expert
    }
]

위 리스트에는 목적지 Chain들의 정보를 담았습니다. 각 Chain 정보는 딕셔너리로, name, description, prompt_template을 포함하고 있습니다. 목적지 Chain들에서 사용할 LLM을 정의하겠습니다.

from langchain.chat_models import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain.chains import LLMChain

llm = ChatOpenAI(temperature=0)

이제 목적지 Chain들을 만들며 {“name”:Chain} 형태의 딕셔너리를 준비합니다. RouterChain에서 목적지 Chain의 이름을 지정해주면 해당 Chain으로 연결하기 위한 사전작업입니다.

destination_chains = {}
for p in prompt_info:
    name = p["name"]
    prompt = ChatPromptTemplate.from_template(template=p["prompt_template"])
    destination_chains[name] = LLMChain(llm=llm, prompt=prompt)

RouterChain의 프롬프트에 넣을 목적지 Chain들의 정보를 준비합니다. 간단하게 “name”: “description” 형태의 문자열을 만들었습니다.

destinations = [f"{p['name']}: {p['description']}" for p in prompt_info]
destinations_str = "\n".join(destinations)
print(destinations_str)

### 결과
philosophy: 철학 관련 질문에 대답
physics: 물리학 관련 질문에 대답
computer: 컴퓨터 관련 질문에 대답
literature: 문학 관련 질문에 대답

Default Chain

적절한 목적지 Chain이 없을 때 사용할 Default Chain도 준비합니다. 사용자 입력을 그대로 넘기도록 했습니다.

default_prompt = ChatPromptTemplate.from_template("{input}")
default_chain = LLMChain(llm=llm, prompt=default_prompt)

RouterChain

RouterChain을 만드는데 필요한 클래스들을 import 하겠습니다.

from langchain.chains.router import MultiPromptChain
from langchain.chains.router.llm_router import LLMRouterChain,RouterOutputParser
from langchain.prompts import PromptTemplate

이제 RouterChain의 프롬프트를 준비합시다.

MULTI_PROMPT_ROUTER_TEMPLATE = """
입력받은 내용을 바탕으로 가장 적절한 모델 프롬프트를 선택하세요.
모델 프롬프트 정보는 다음과 같이 주어집니다.

"프롬프트 이름": "프롬프트 설명"

<< FORMATTING >>
Return a markdown code snippet with a JSON object formatted to look like:
```json
{{{{
    "destination": string \ name of the prompt to use or "DEFAULT"
    "next_inputs": string \ a potentially modified version of the original input
}}}}
```

REMEMBER: "destination"은 아래 주어진 프롬프트 설명을 바탕으로 프롬프트 이름 중 하나를 선택하거나,
적절한 프롬프트가 없으면 "DEFAULT"를 선택할 수 있습니다.

REMEMBER: "next_inputs"은 원본 INPUT을 넣으세요.

<< CANDIDATE PROMPTS >>
{destinations}

<< INPUT >>
{{input}}

<< OUTPUT (remember to include the ```json)>>"""

router_template = MULTI_PROMPT_ROUTER_TEMPLATE.format(
    destinations=destinations_str
)

위에서 MULTI_PROMPT_ROUTER_TEMPLATE는 기본 문자열입니다. format을 거치며 destinations만 치환하고 input은 남기기 위해 input에 중괄호를 두 개 사용했습니다. 하나만 쓰면 치환할 때 input 정보도 넣으라고 에러가 납니다. json은 치환하지 않을 것이기 때문에 중괄호를 중복해서 썼습니다. format을 거치면 {{{로 바뀌는데 여기서 한 번, 뒤에서 PromptTemplate에서 또 한 번 치환이 일어나므로 json의 중괄호를 끝까지 남겨두기 위해 중괄호를 네 개 썼습니다.

이제 RouterChain의 PromptTemplate과 RouterChain을 만들 차례입니다.

router_prompt = PromptTemplate(
    template=router_template,
    input_variables=["input"],
    output_parser=RouterOutputParser(),
)

router_chain = LLMRouterChain.from_llm(llm, router_prompt)

MultiPromptChain

다음에는 RouterChain, 목적지 Chain, Default Chain을 모두 묶어서 MultiPromptChain을 만듭니다.

chain = MultiPromptChain(router_chain=router_chain, 
                         destination_chains=destination_chains, 
                         default_chain=default_chain,
                         verbose=True
                        )

중간에 어떤 목적지 Chain을 선택했는지 확인하기 위해 verbose=True로 입력했습니다. 이제 준비가 끝났습니다. 호출해볼까요?

Run

chain.run("상대성 이론이 뭐에요?")

### 결과
> Entering new MultiPromptChain chain...
physics: {'input': '상대성 이론이 뭐에요?'}
> Finished chain.
'상대성 이론은 알버트 아인슈타인이 제시한 물리학 이론으로, 시간, 공간, 그리고 중력에 대한 새로운 이해를 제공합니다. 이론은 두 가지 주요한 부분으로 나뉩니다. 첫 번째는 특수 상대성 이론으로, 서로 다른 관측자들이 서로 다른 속도로 움직이더라도 물리적 법칙은 동일하게 적용된다는 것을 설명합니다. 두 번째는 일반 상대성 이론으로, 중력을 공간과 시간의 곡률로 설명하며, 질량이나 에너지가 공간과 시간을 구부리는 것을 설명합니다. 이론은 많은 실험적 검증을 거쳐 현대 물리학의 기초로 자리잡았습니다.'

목적지 Chain으로 물리학 Chain이 선택되며 아인슈타인에게 질문이 전달된 것을 볼 수 있습니다.

chain.run("삶의 의미는?")

### 결과
> Entering new MultiPromptChain chain...
philosophy: {'input': '삶의 의미는?'}
> Finished chain.
'플라톤: 삶의 의미는 인간의 존재와 관련된 복잡한 주제입니다. 제가 생각하기에 삶의 의미는 개인에 따라 다를 수 있으며, 각각의 사람들은 자신만의 의미를 발견하고 이해해야 합니다. 삶의 의미는 인간의 가치, 목적, 성장, 연결, 창조 등 다양한 측면을 포함할 수 있습니다. 그러나 이 질문에 대한 절대적인 답은 없으며, 이는 개인의 철학적, 종교적, 문화적 배경에 따라 다를 수 있습니다. 따라서, 삶의 의미에 대한 최종적인 답은 모른다고 말씀드릴 수밖에 없습니다.'

철학적인 질문은 철학 전문가 플라톤에게 전달되었네요.

chain.run("청년과 떠오르는 태양을 주제로 시를 써줘")

### 결과
> Entering new MultiPromptChain chain...
literature: {'input': '청년과 떠오르는 태양을 주제로 시를 써줘'}
> Finished chain.
'나는 셰익스피어가 아니지만, 당신을 위해 청년과 떠오르는 태양을 주제로 한 시를 써보겠습니다.\n\n청년의 마음은 태양과 같아\n뜨겁고 밝게 빛나는 것 같아\n그의 꿈과 희망은 무궁무진하고\n떠오르는 태양처럼 높이 떠오르는 것 같아\n\n그의 눈은 태양과 같아\n찬란하고 아름다운 것 같아\n그의 시선은 멀리 향해\n떠오르는 태양처럼 빛을 비추는 것 같아\n\n청년의 에너지는 태양과 같아\n끊임없이 흘러나오는 것 같아\n그의 열정은 불타오르고\n떠오르는 태양처럼 모두를 따뜻하게 하는 것 같아\n\n청년과 떠오르는 태양은\n새로운 시작과 희망을 상징해\n그들은 우리에게 빛과 영감을 주며\n떠오르는 태양처럼 우리를 이끌어가는 것 같아\n\n이렇게 청년과 떠오르는 태양에 대한 시를 써봤습니다. 셰익스피어의 수준은 아니지만, 조금이나마 당신의 질문에 대한 답변이 되었기를 바랍니다.'

시를 써달라고 하니 문학 Chain으로 연결되긴 했는데, LLM에서 자신은 셰익스피어가 아니라고 부인합니다. 그냥 PromptTemplate 대신 ChatPromptTemplate을 써서 시스템 메시지로 당신은 셰익스피어라고 하면 인정할까요? 연습해보세요.

chain.run("비트와 바이트는 무슨 관계지?")

### 결과
> Entering new MultiPromptChain chain...
computer: {'input': '비트와 바이트는 무슨 관계지?'}
> Finished chain.
'비트와 바이트는 컴퓨터에서 데이터를 표현하는 단위입니다. 비트는 0 또는 1의 값을 가지는 가장 작은 단위이고, 바이트는 8개의 비트로 이루어진 단위입니다. 즉, 바이트는 비트의 그룹이라고 볼 수 있습니다. 따라서, 1바이트는 8비트와 같고, 2바이트는 16비트와 같은 관계를 가지게 됩니다.'

비트와 바이트에 관한 질문은 컴퓨터 Chain에 전달되었습니다. 이렇게 RouterChain을 쓰면 자연어로 if문과 유사한 조건 분기 프로그램을 만들 수 있습니다.

LangChain: Chain들을 다양하게 연결하는 SequentialChain

앞서 살펴본 SimpleSequentialChain작은 Chain들이 한 줄로 연결된 형태였습니다.

SequentialChain은 아래와 같이 작은 Chain들을 좀 더 복잡한 형태로 연결할 수 있는 Chain입니다.

Example

예제를 살펴보겠습니다. 간단하게 만들 수도 있지만 예시를 위해 조금 복잡하게 만들었습니다. 외국어로 된 사용자 리뷰를 받아서 한글로 번역하고, 번역한 내용을 한 문장으로 요약한 다음 요약을 보고 답변을 달려고 합니다. 답변 언어는 원래 리뷰의 언어로 하고, 추가로 감정 분석을 통해 긍정/부정 점수를 매기려고 합니다. 각각의 작업에 대한 Chain들을 다음과 같이 만들어 연결하겠습니다.

  • Chain 1: 외국어 리뷰를 한글로 번역, input = review, output = translation
  • Chain 2: 한글 리뷰를 한 문장으로 요약, input = translation, output = summary
  • Chain 3: 리뷰를 읽고 감정 분석 점수 매기기, input = translation, output = sentiment_score
  • Chain 4: 리뷰의 언어 알아내기, input = review, output = language
  • Chain 5: 리뷰의 한글 요약에 대해 원래 언어로 답변 작성, input = language & summary, output = reply

LLM

코드를 봅시다. 먼저 필요한 모듈을 import하고, 공통으로 사용할 llm 변수를 생성했습니다.

from langchain.chat_models import ChatOpenAI
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain

llm = ChatOpenAI(temperature=0.5)

PromptTemplate, LLMChain

다음에는 위의 Chain 1~5에 해당하는 PromptTemplateLLMChain을 만들겠습니다.

prompt1 = PromptTemplate(
    input_variables=['review'],
    template="다음 사용자 리뷰를 한글로 번역하세요.\n\n{review}"
)
chain1 = LLMChain(llm=llm, prompt=prompt1, output_key="translation")

prompt2 = PromptTemplate.from_template(
    "다음 리뷰를 한 문장으로 요약하세요.\n\n{translation}"
)
chain2 = LLMChain(llm=llm, prompt=prompt2, output_key="summary")

prompt3 = PromptTemplate.from_template(
    "다음 리뷰를 읽고 0점부터 10점 사이에서 부정/긍정 점수를 매기세요. 숫자만 대답하세요.\n\n{translation}"
)
chain3 = LLMChain(llm=llm, prompt=prompt3, output_key="sentiment_score")

prompt4 = PromptTemplate.from_template(
    "다음 리뷰에 사용된 언어가 무엇인가요? 언어 이름만 답하세요.\n\n{review}"
)
chain4 = LLMChain(llm=llm, prompt=prompt4, output_key="language")

prompt5 = PromptTemplate.from_template(
    "다음 리뷰 요약에 대해 공손한 답변을 작성하세요.\n답변 언어:{language}\n리뷰 요약:{summary}"
)
chain5 = LLMChain(llm=llm, prompt=prompt5, output_key="reply")

PromptTemplate을 만들 때 from_template을 사용한 것도 있고 사용하지 않은 것도 있는데, 연습을 위해 섞어서 사용한 것입니다. 하나로 통일해도 괜찮습니다. LLMChain을 만들면서 output_key를 사용했습니다. 이어지는 Chain들의 PromptTemplate에서는 output_key에 지정한 변수들을 사용한 것을 볼 수 있습니다. 이렇게 앞 Chain의 출력을 다음 Chain의 입력으로 지정할 수 있습니다. 이제 이 Chain들을 연결하겠습니다.

SequentialChain

from langchain.chains import SequentialChain

all_chain = SequentialChain(
    chains=[chain1, chain2, chain3, chain4, chain5],
    input_variables=['review'],
    output_variables=['translation','summary','sentiment_score','language','reply'],
)

review를 입력하고 결과가 어떻게 나오는지 봅시다.

review="""
I'm somewhat satisfied with this product.
Considering its cost-effectiveness, it's not bad.
However, regardless of the product itself,
the shipping took too long, which was disappointing.
"""
all_chain(review)

### 결과
{'review': "\nI'm somewhat satisfied with this product.\nConsidering its cost-effectiveness, it's not bad.\nHowever, regardless of the product itself,\nthe shipping took too long, which was disappointing.\n",
 'translation': '이 제품에 대해는 어느 정도 만족한 편입니다.\n비용 대비로 생각해보면 그렇게 나쁘지 않아요.\n하지만 제품 자체와는 상관없이,\n배송이 너무 오래 걸려서 실망스러웠습니다.',
 'summary': '제품은 만족스럽지만 배송이 너무 오래 걸려 실망스러웠습니다.',
 'sentiment_score': '7',
 'language': '영어',
 'reply': 'Thank you for your feedback. We are glad to hear that you are satisfied with the product. We apologize for the disappointment caused by the delayed delivery. We will definitely take your feedback into consideration and work towards improving our shipping process. Thank you for bringing this to our attention.'}

input_variablesoutput_variables에 지정한 key들이 포함된 딕셔너리가 반환되었습니다. 각각의 key에 해당하는 value들이 prompt에 따라 원하는 결과로 나온 것을 볼 수 있습니다. 이렇게 SequentialChain을 사용하면 여러 Chain들을 다양한 방식으로 연결해 사용할 수 있습니다.

LangChain: Chain들을 차례로 연결하는 SimpleSequentialChain

LangChain의 Chain에는 몇 가지 종류가 있습니다. 앞에서 PromptTemplate, LLM, OutputParser를 연결한 LLMChain과 LLMChain에 Memory를 추가해 대화가 가능한 ConversationChain을 살펴봤습니다. (사실 LLMChain에서 ChatModels를 사용하고 Memory를 할당하면 ConversationChain과 유사합니다.)

이번에는 LLMChain들을 간단하게 연결할 수 있는 SimpleSequentialChain을 살펴보겠습니다. 아래 그림처럼 단순하게 이전 Chain의 출력을 다음 Chain의 입력으로 연결해주는 Chain입니다.

코드를 봅시다.

from langchain.chat_models import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain.chains import LLMChain
from langchain.chains import SimpleSequentialChain

llm = ChatOpenAI(temperature=0.9)

prompt1 = ChatPromptTemplate.from_template(
    "{area} 분야에서 역사적으로 가장 중요한 사람은?"
)

prompt2 = ChatPromptTemplate.from_template(
    "{name}: 이 사람이 속했던 기관은?"
)

prompt3 = ChatPromptTemplate.from_template(
    "{org}: 이 기관의 주요 업적은?"
)

세 개의 Chain을 만들고 연결하기 위해 세 개의 PromptTemplate를 만들었습니다. 각각의 Chain은 위의 LLM과 PromptTemplate을 연결해 만들겠습니다.

chains = []
for prompt in [prompt1, prompt2, prompt3]:
    chains.append(LLMChain(llm=llm, prompt=prompt))

이렇게 Chain들의 리스트를 만들었습니다. SimpleSequentialChain은 이 리스트를 이용해 만듭니다.

simple_chain = SimpleSequentialChain(chains=chains, verbose=True)
simple_chain.run("중력 이론")

위에서 verbose는 중간 과정도 출력하기 위해 True로 넣었습니다. verbose=False로 놓으면(기본값) 최종 응답만 출력됩니다. 출력된 내용은 다음과 같습니다.

> Entering new SimpleSequentialChain chain...

### 첫 번째 Chain의 결과
중력 이론 분야에서 역사적으로 가장 중요한 사람은 아이작 뉴턴(Isaac Newton)입니다. 뉴턴은 17세기에 중력 이론을 개발하였으며, 그의 "형성력학"은 현대 물리학의 기초를 세우는 데 큰 영향을 주었습니다. 뉴턴의 중력 이론은 이후 약 200년 동안 가장 효과적이고 정확한 설명이었으며, 그의 업적은 고전 물리학의 기초와 현대 과학의 발전에 큰 영감을 주었습니다.

### 두 번째 Chain의 결과
아이작 뉴턴은 케임브리지 대학교에서 교수로 활동하였습니다.

### 세 번째 Chain의 결과
아이작 뉴턴은 케임브리지 대학교에서 교수로 활동하였으며, 그의 주요 업적은 다음과 같습니다:

1. 만유인력의 법칙과 중력: 뉴턴은 중력의 법칙을 제시하고 지구의 운동을 설명하는 뉴턴의 운동 법칙을 개발했습니다. 그는 또한 만유인력의 법칙을 제시하여 물체 간의 인력을 설명하였습니다.

2. 광학: 뉴턴은 광학 연구를 수행하여 "광방사"와 "광굴절"의 원리를 밝혀내었습니다. 그는 빛이 다양한 색상으로 구성되어 있다는 것을 발견하였고, 이는 후에 뉴턴의 색채 이론으로 알려지게 되었습니다.

3. 미적분학: 뉴턴은 미적분학의 기초를 개발하였습니다. 그의 연구는 운동, 탄성, 적분 등 다양한 수학적 문제들을 해결하는데 사용되었으며, 이는 현대 과학 및 공학의 핵심 개념 중 하나가 되었습니다.

4. 역학: 뉴턴의 연구는 역학의 발전에 큰 기여를 했습니다. 그는 뉴턴의 운동 법칙을 통해 물리학적 시스템의 움직임과 운동을 설명하였으며, 이를 통해 천문학, 공학, 물리학 등에서 다양한 응용분야에 사용되었습니다.

이러한 업적들은 뉴턴을 현대 과학의 선구자 중 한 명으로 여기게 되었으며, 그의 연구는 현대과학의 기반을 다지는 데 큰 역할을 한 것으로 평가되고 있습니다.

결과를 보면 질문에 대한 답만 말한 것이 아니라 부연 설명이 들어가 있습니다. 최종 응답에서는 기관의 업적이 아니라 뉴턴의 업적이 결과로 나왔네요. OutputParser를 추가하면 첫 번째 응답은 “뉴턴”, 두 번째 응답은 “케임브리지 대학교”로 만들어 세 번째에 기관의 업적이 나오도록 만들 수 있을겁니다. 해봅시다.

from langchain.output_parsers import ResponseSchema
from langchain.output_parsers import StructuredOutputParser

schema = ResponseSchema(name='name', description="json object: key=name, value=person's name")
response_schemas = [schema]
output_parser1 = StructuredOutputParser.from_response_schemas(response_schemas)

schema = ResponseSchema(name='org', description="json object: key=org, value=organization's name")
response_schemas = [schema]
output_parser2 = StructuredOutputParser.from_response_schemas(response_schemas)

첫 번째와 두 번째 LLM 출력을 위한 OutputParser들을 만들었습니다.

prompt1 = ChatPromptTemplate.from_template(
    "{area} 분야에서 역사적으로 가장 중요한 사람은?\n{format_instructions}",
    partial_variables={"format_instructions":output_parser1.get_format_instructions()}
)

prompt2 = ChatPromptTemplate.from_template(
    "{name}: 이 사람이 속했던 기관은?\n{format_instructions}",
        partial_variables={"format_instructions":output_parser2.get_format_instructions()}
)

prompt3 = ChatPromptTemplate.from_template(
    "{org}: 이 기관의 주요 업적은?"
)

첫 번째와 두 번째 프롬프트에 OutputParser의 format_instructions를 추가했습니다.

new_chains=[]
new_chains.append(LLMChain(llm=llm, prompt=prompt1, output_parser=output_parser1))
new_chains.append(LLMChain(llm=llm, prompt=prompt2, output_parser=output_parser2))
new_chains.append(LLMChain(llm=llm, prompt=prompt3))

new_simple_chain = SimpleSequentialChain(chains=new_chains, verbose=True)

new_simple_chain.run("중력 이론")

OutputParser를 추가한 LLMChain들을 이용해 SimpleSequentialChain을 만들고 실행했습니다. 결과를 보겠습니다.

> Entering new SimpleSequentialChain chain...

### 첫 번째 Chain의 결과
{'name': '아이작 뉴턴'}

### 두 번째 Chain의 결과
{'org': '옥스퍼드 대학교'}

### 세 번째 Chain의 결과
옥스퍼드 대학교는 많은 주요 업적을 가지고 있습니다. 이 중 일부는 다음과 같습니다:

1. 학문적 우수성: 옥스퍼드 대학교는 국제적으로 인정받는 세계적인 연구 및 교육 기관으로, 학문적 우수성과 혁신적인 연구로 유명합니다. 이는 다양한 분야에서 많은 세계적인 수상을 받았음을 증명하고 있습니다.

2. 역사적인 유산: 옥스퍼드 대학교는 12세기에 설립되어 역사적인 유산을 가지고 있습니다. 옥스퍼드의 대학 조직 구조는 근대 대학의 모델로 사용되었으며, 다른 대학들에 큰 영향을 미쳤습니다.

3. 알럼니 네트워크: 옥스퍼드 대학교는 강력하고 영향력 있는 알럼니 네트워크를 가지고 있습니다. 많은 옥스퍼드 졸업생들이 세계적으로 성공한 사업가, 정치인, 문화인으로 알려져 있습니다.

4. 문화적 영향력: 옥스퍼드 대학교는 창의적이고 혁신적인 사고 방식을 장려하며, 학문의 자유를 존중합니다. 이러한 문화적인 영향력이 전 세계에서 영향을 미치고 있으며, 다양한 분야에서 지식과 이해를 증진시키는 역할을 합니다.

5. 국제 협력: 옥스퍼드 대학교는 다양한 국제 협력 프로그램을 통해 세계의 다른 연구기관과 협력하고 있습니다. 이를 통해 옥스퍼드는 국제적인 역량을 키우고, 글로벌 문제에 대한 해결책을 모색하는 데 기여하고 있습니다.

첫 번째와 두 번째 Chain에서 원하는 출력 형식을 얻었으나, 두 번째 응답의 결과가 잘못 되었네요. 케임브리지 대학이 맞습니다. 이렇게 틀린 응답을 할 때가 종종 있으니 결과를 확인해볼 필요가 있습니다. 정확도가 중요할 때는 Retrieval Augmented Generation과 같은 방법을 쓰는게 좋겠죠.

LangChain: 대화를 위한 ConversationChain

LLMChain

앞에서 LangChain의 LLMChain을 살펴봤습니다. PromptTemplate, LLM, OutputParser를 연결해서 편리하게 사용할 수 있는 기능이었죠. 그림으로 표현해봅시다.

  • 사용자가 질문을 던집니다.
  • 질문은 PromptTemplate을 통과하며 최종 프롬프트가 됩니다.
  • 최종 프롬프트는 LLM(완성형 또는 대화형)에 전달되고, LLM에서 Output을 출력합니다.
  • 출력은 OutputParser를 거쳐 최종 응답이 되고, 사용자에게 전달됩니다

이 Chain은 한 번 입력을 받아서 한 번 출력하는 형태의 Chain입니다. LLMChain은 여러 개를 연결해서 더 복잡한 구조의 Chain을 만들 수 있지만, 기본적으로 대화용은 아닙니다. 챗봇과 같이 대화에 사용할 수 있는 Chain으로 ConversationChain이 있습니다.

ConversationChain

ChatGPT와 달리 REST API로 이용하는 LLM에는 대화를 기억하는 기능이 없습니다. 따라서 이전 대화 내용을 바탕으로 계속 대화를 이어가기 위해서는 대화하고 있는 내용을 저장해뒀다가 매번 프롬프트를 만들 때 추가해줘야 합니다. 그래야 LLM이 지금까지의 대화 내용을 기억하고 있는 것처럼 대답할 수 있죠. 이렇게 이전 대화를 기억하도록 하는 LangChain의 기능을 Memory라고 했습니다. 대화를 위한 ConversationChain을 만들 때는 이 Memory가 필요합니다. 그림으로 보면 다음과 같습니다.

  • 사용자가 질문을 던집니다.
  • 질문은 Memory에 저장된 이전 대화 내용과 함께 PromptTemplate에 전달되고, PromptTemplate를 거쳐 최종 프롬프트가 됩니다.
  • 최종 프롬프트는 대화형 LLM에 전달되고, 대화형 LLM에서 Output을 출력합니다.
  • Output은 사용자의 Query와 함께 Memory에 저장되고, OutputParser에도 전달됩니다.
  • OutputParser를 거쳐 최종 응답이 사용자에게 전달됩니다.

코드를 보겠습니다.

from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory

llm = ChatOpenAI(temperature=0.0)

memory = ConversationBufferMemory()

conversation = ConversationChain(
    llm=llm, 
    memory = memory
)
conversation.predict(input="안녕, 나는 토니 스타크라고 해.")

### 결과(응답)
'안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?'

위 코드에서는 간단한 대화를 위해 PromptTemplate과 OutputParser를 생략했습니다. 이 경우 프롬프트에는 대화를 위한 기본 SystemMessage, 대화를 계속해 나가면서 누적된 대화 내용, 그리고 사용자의 새로운 질문이 들어가게 됩니다. OutputParser가 없으니 출력은 LLM의 대답이 그대로 문자열로 나옵니다.

LangChain: Memory로 대화 내용 저장하기

LangChain에는 LLM과 대화시 대화 내용을 저장하기 위한 Memory 기능이 있습니다. OpenAI API와 같은 REST API는 상태를 저장하지 않습니다(stateless). 따라서 API 실행시 이전 대화 내용을 전달하지 않고 새로운 메시지만 던지면 처음 받는 질문으로 판단하고 대답하게 됩니다. 대화를 이어가기 위해서는 지난 대화 내용을 프롬프트에 포함해 전달하면서 새로운 메시지를 추가해야 합니다. 지난 대화 내용을 저장할 때 사용하는 것이 바로 Memory입니다.

그런데, 대화가 길어지면 문제가 생깁니다. API를 통해 한 번에 입출력할 수 있는 토큰 수가 정해져 있죠. 지난 대화가 길면 토큰 수를 넘어가서 답변을 듣지 못하게 됩니다. 물론 API 사용료도 더 많이 나가죠. 그래서 LangChain의 Memory 중에는 지난 대화 내용을 줄이는 Memory도 있습니다. 기본적인 메모리들을 몇 가지 살펴보겠습니다.

  • ConversationBufferMemory
  • ConversationBufferWindowMemory
  • ConversationTokenBufferMemory
  • ConversationSummaryBufferMemory

ConversationBufferMemory

ConversationBufferMemory는 지난 대화 내용을 그대로 저장하는 메모리입니다.

from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory

llm = ChatOpenAI(temperature=0.0)

memory = ConversationBufferMemory()

conversation = ConversationChain(
    llm=llm, 
    memory = memory
)
conversation.predict(input="안녕, 나는 토니 스타크라고 해.")

### 결과(응답)
'안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?'

코드에서 사용한 ConversationChain은 한 번 응답을 받는 LLMChain과 달리 여러 번 대화를 주고 받을 때 사용하는 Chain입니다. Chain을 생성할 때 memory 인자에 LangChain의 Memory 객체를 입력하는 방식으로 Memory를 사용합니다. Chain 내부에서는 사용자가 input 메시지를 입력하면 메모리에 메시지를 추가한 후 LLM에 전달하고, LLM이 응답하면 메모리에 응답을 추가한 후 사용자에게 응답을 반환합니다. 대화를 이어나가겠습니다.

conversation.predict(input="내 인공지능 비서 이름이 뭐지?")
# 응답: '당신의 인공지능 비서의 이름은 "지니"입니다. 어떤 일을 도와드릴까요?'
conversation.predict(input="내 이름이 뭐였지?")
# 응답: '당신의 이름은 "토니 스타크"입니다.'

이렇게 이전 대화 내용을 기억하며 대화를 이어나갈 수 있습니다. 메모리에 저장된 내용은 다음과 같이 확인할 수 있습니다.

print(memory.buffer)

### 결과
Human: 안녕, 나는 토니 스타크라고 해.
AI: 안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?
Human: 내 인공지능 비서 이름이 뭐지?
AI: 당신의 인공지능 비서의 이름은 "지니"입니다. 어떤 일을 도와드릴까요?
Human: 내 이름이 뭐였지?
AI: 당신의 이름은 "토니 스타크"입니다.

load_memory_variables를 이용할 수도 있습니다.

memory.load_memory_variables({})

### 결과
{'history': 'Human: 안녕, 나는 토니 스타크라고 해.\nAI: 안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?\nHuman: 내 인공지능 비서 이름이 뭐지?\nAI: 당신의 인공지능 비서의 이름은 "지니"입니다. 어떤 일을 도와드릴까요?\nHuman: 내 이름이 뭐였지?\nAI: 당신의 이름은 "토니 스타크"입니다.'}

save_context를 이용해 메모리에 직접 내용을 추가할 수도 있습니다.

memory = ConversationBufferMemory()
memory.save_context({"input": "안녕, 나는 토니 스타크라고 해."}, 
                    {"output": "안녕하세요, 토니 스타크님! 반갑습니다."})
print(memory.buffer)

### 결과
Human: 안녕, 나는 토니 스타크라고 해.
AI: 안녕하세요, 토니 스타크님! 반갑습니다.

더 추가해볼까요?

memory.save_context({"input": "내 비서 이름이 뭐지?"}, 
                    {"output": "JARVIS입니다."})
memory.load_memory_variables({})

### 결과
{'history': 'Human: 안녕, 나는 토니 스타크라고 해.\nAI: 안녕하세요, 토니 스타크님! 반갑습니다.\nHuman: 내 비서 이름이 뭐지?\nAI: JARVIS입니다.'}

ConversationBufferWindowMemory

ConversationBufferWindowMemory는 윈도우 크기 k를 지정하면 최근 k개의 대화만 기억하고 이전 대화는 삭제합니다. 여기서 1개의 대화는 한 번 주고 받는 대화를 말합니다.

from langchain.memory import ConversationBufferWindowMemory
memory = ConversationBufferWindowMemory(k=1) 
memory.save_context({"input": "안녕, 나는 토니 스타크라고 해."},
                    {"output": "안녕하세요, 토니 스타크님! 반갑습니다."})
memory.save_context({"input": "내 비서 이름이 뭐지?"},
                    {"output": "JARVIS입니다."})
memory.load_memory_variables({})

### 결과
{'history': 'Human: 내 비서 이름이 뭐지?\nAI: JARVIS입니다.'}

k=1로 했더니 마지막 대화만 기억하고 그 앞의 대화는 잊어버렸습니다.

memory = ConversationBufferWindowMemory(k=1)
conversation = ConversationChain(
    llm=llm, 
    memory = memory
)
conversation.predict(input="안녕, 나는 토니 스타크라고 해.")
# 응답: '안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?'
conversation.predict(input="내 인공지능 비서 이름은 뭐지?")
# 응답: '당신의 인공지능 비서의 이름은 "제이비"입니다.'
conversation.predict(input="내 이름이 뭐라고?")
# 응답: '죄송합니다, 저는 당신의 이름을 알 수 없습니다.'

이전 대화를 잊어버렸으니 이전 대화에 있던 정보를 물으면 답할 수가 없습니다. 보통 대화에서는 k=1보다 큰 값을 사용합니다.

ConversationTokenBufferMemory

ConversationTokenBufferMemory는 기억되는 토큰 수를 제한합니다. 최대 토큰 수를 넘지 않는 범위에서 최근 대화들을 기억합니다.

from langchain.memory import ConversationTokenBufferMemory
memory = ConversationTokenBufferMemory(llm=llm, max_token_limit=50)
memory.save_context({"input": "안녕, 나는 토니 스타크라고 해."},
                    {"output": "안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?"})
memory.save_context({"input": "내 인공지능 비서 이름은 뭐지?"},
                    {"output": "당신의 인공지능 비서의 이름은 JARVIS입니다."})
memory.save_context({"input": "내 이름이 뭐라고?"}, 
                    {"output": "당신의 이름은 '토니 스타크'입니다."})
memory.load_memory_variables({})

### 결과
{'history': "Human: 내 이름이 뭐라고?\nAI: 당신의 이름은 '토니 스타크'입니다."}

최대 토큰 수를 50으로 작게 잡았기 때문에 마지막 대화밖에 안 남았습니다.

ConversationSummaryBufferMemory

ConversationSummaryMemory는 이전 대화 내용 그대로 기억하지 않고 요약해서 기억합니다. 요약은 LLM이 하기 때문에 Memory 생성시 LLM을 지정해줍니다. ConversationSummaryBufferMemory는 ConversationSummaryMemory의 확장판이라고 볼 수 있습니다. 이전 대화를 요약해 기억하면서 최근 대화는 최대 토큰 범위 내에서 유지합니다. 이를 위해 최대 토큰 길이도 지정해줍니다. 코드에서는 ConversationSummaryBufferMemory를 사용하겠습니다.

from langchain.memory import ConversationSummaryBufferMemory

schedule = "내일 아침 8시에는 퓨리랑 미팅이 있습니다. \
어벤져스와 가디언즈 오브 갤럭시 팀의 관계에 대해 상의할 예정입니다. \
12시에는 페퍼와 점심식사가 있습니다. 치즈버거를 먹기로 했습니다. \
3시에는 해피와 스파이더맨 교육 계획 회의가 있습니다 \
5시에는 아크 원자로 개선 버전 공개 행사가 있습니다. \
간단한 소개 자료를 준비해 놓았습니다."

memory = ConversationSummaryBufferMemory(llm=llm, max_token_limit=100)
memory.save_context({"input": "자비스"}, {"output": "네, 말씀하세요."})
memory.save_context({"input": "자기 전에 확인할게 있어."},
                    {"output": "무엇을 확인해드릴까요?"})
memory.save_context({"input": "내일 일정에 대해 알려줘."}, 
                    {"output": f"{schedule}"})
memory.load_memory_variables({})

### 결과
{'history': 'System: The human asks the AI to check something before they go to bed. The AI asks what they would like to check. The human asks the AI to inform them about their schedule for tomorrow. The AI informs the human that they have a meeting with Fury at 8 am to discuss the relationship between the Avengers and the Guardians of the Galaxy teams. At 12 pm, they have a lunch with Pepper, where they will be having a cheeseburger. At 3 pm, they have a meeting with Happy and Spiderman to discuss educational plans. At 5 pm, there is a public event for the unveiling of an improved version of the Arc Reactor, and the AI has prepared a brief introduction material.'}

대화 내용을 요약해서 기억하는 것을 볼 수 있습니다. 대화를 이어가면 메모리 내용이 어떻게 달라지는가 봅시다.

conversation = ConversationChain(
    llm=llm, 
    memory = memory,
    verbose=True
)
conversation.predict(input="행사 전 4시에 닥터 스트레인지와 약속을 추가해줘.")
# 응답: '행사 전 4시에 닥터 스트레인지와 약속을 추가했습니다.'
memory.load_memory_variables({})

### 결과
{'history': 'System: The human asks the AI to check something before they go to bed. The AI asks what they would like to check. The human asks the AI to inform them about their schedule for tomorrow. The AI informs the human that they have a meeting with Fury at 8 am to discuss the relationship between the Avengers and the Guardians of the Galaxy teams. At 12 pm, they have a lunch with Pepper, where they will be having a cheeseburger. At 3 pm, they have a meeting with Happy and Spiderman to discuss educational plans. At 5 pm, there is a public event for the unveiling of an improved version of the Arc Reactor, and the AI has prepared a brief introduction material.\nHuman: 행사 전 4시에 닥터 스트레인지와 약속을 추가해줘.\nAI: 행사 전 4시에 닥터 스트레인지와 약속을 추가했습니다.'}

앞의 내용은 요약되어 있지만 최근 대화는 그대로 들어가 있습니다. 대화를 더 이어가봅시다.

conversation.predict(input="내일 행사 참석 인원은 100명 정도야.")
# 응답: '내일 행사에는 약 100명 정도의 참석자가 있을 것으로 예상됩니다.'
memory.load_memory_variables({})

### 결과
{'history': 'System: The human asks the AI to check something before they go to bed. The AI asks what they would like to check. The human asks the AI to inform them about their schedule for tomorrow. The AI informs the human that they have a meeting with Fury at 8 am to discuss the relationship between the Avengers and the Guardians of the Galaxy teams. At 12 pm, they have a lunch with Pepper, where they will be having a cheeseburger. At 3 pm, they have a meeting with Happy and Spiderman to discuss educational plans. At 5 pm, there is a public event for the unveiling of an improved version of the Arc Reactor, and the AI has prepared a brief introduction material. The human requests to add a meeting with Doctor Strange at 4 pm before the event.\nAI: 행사 전 4시에 닥터 스트레인지와 약속을 추가했습니다.\nHuman: 내일 행사 참석 인원은 100명 정도야.\nAI: 내일 행사에는 약 100명 정도의 참석자가 있을 것으로 예상됩니다.'}

앞에서 그대로 들어가 있던 대화의 일부가 요약에 포함된 것을 볼 수 있습니다.

conversation.predict(input="내일 원자로 소개할 때 뭐라고 시작할까?")
# 응답
'내일 원자로 소개할 때, "안녕하세요 여러분, 오늘 저희는 개선된 아크 리액터의 공개 행사에 참석하게 되어 매우 기쁩니다. 이번 개발은 이전 버전에 비해 더욱 효율적이고 안전한 에너지 공급을 제공합니다. 이 아크 리액터는 우리의 노력과 협력으로 만들어진 결과물이며, 앞으로의 미래에 대한 희망과 발전을 상징합니다. 이제부터 자세한 소개를 시작하도록 하겠습니다."라고 시작하시면 좋을 것 같습니다.'

마지막 대화가 길었습니다. 토큰 수 제한이 있기 때문에 마지막에 주고 받은 대화가 길어지면 다음과 같이 마지막 대화까지 요약에 포함될 수도 있습니다.

memory.load_memory_variables({})

### 결과
{'history': 'System: The human asks the AI to check their schedule for tomorrow. The AI informs the human that they have a meeting with Fury at 8 am, a lunch with Pepper at 12 pm, a meeting with Happy and Spiderman at 3 pm, and a public event for the unveiling of an improved version of the Arc Reactor at 5 pm. The human requests to add a meeting with Doctor Strange at 4 pm before the event. The AI adds the meeting to the schedule. The human mentions that there will be around 100 attendees at the event. The AI estimates that there will be about 100 attendees. The human asks for suggestions on how to start the introduction for the Arc Reactor unveiling. The AI suggests starting with "Hello everyone, we are delighted to have you all here today for the unveiling of the improved Arc Reactor. This development provides more efficient and safe energy supply compared to the previous version. It is a result of our efforts and collaboration, symbolizing hope and progress for the future. Now, let\'s begin with a detailed introduction."'}

이전 대화 내용을 요약하는 메모리의 경우 요약을 위해 내부적으로 LLM 호출이 추가된다는 점을 기억합시다.

(추가) OpenAI Thread

2023년 11월 6일 OpenAI에서 새로운 API들을 발표했습니다. 그중 Assistants API의 Thread가 있는데, Thread에 Message를 추가하면 OpenAI 서버에 대화 내용을 저장해주고, 프롬프트에 이전 대화 내용을 넣는 과정도 알아서 진행해줍니다. 참고: OpenAI Assistants API로 대화 내용 저장하기

LangChain: LLMChain으로 PromptTemplate, LLM, OutputParser 연결하기

앞에서 LangChain의 거대 언어 모델 유형으로 문장 완성을 위한 LLMs와 대화를 위한 ChatModels가 있다는 것을 확인했습니다. 프롬프트 작성을 위한 PromptTemplate들도 봤고, 출력 형식 지정을 위한 OutputParser도 봤습니다.

LangChain의 Chain 기능은 여러 기능들을 연결해서(chain) 편리하게 이용할 수 있는 기능입니다. 이 기능을 이용하면 사용자의 입력을 받아 PromptTemplate을 이용해 프롬프트를 만들고 LLM에 전달한 후 OutputParser를 이용해 결과를 반환하는 하나의 Chain을 만들 수 있습니다. 필요하면 Chain들끼리 연결하는 것도 가능합니다. 여러 Chain들 중 가장 기본이 되는 Chain은 LLMChain입니다.

LLMChain: PromptTemplate + LLM

예제를 위해 먼저 LLM과 프롬프트 템플릿을 만들었습니다.

from langchain.chat_models import ChatOpenAI
from langchain.prompts import PromptTemplate

llm = ChatOpenAI(temperature=0.9)

prompt = PromptTemplate(
    input_variables=["what"],
    template="{what} 이름 세 개를 추천해줘.",
)

위에서 ChatOpenAI (gpt-3.5-turbo)를 사용했는데, 문장 완성 모델인 OpenAI (text-davinci-003)를 사용해도 됩니다. 여기서는 성능이 더 좋아서 ChatOpenAI를 선택했습니다. 이제 둘을 LLMChain으로 연결합니다.

from langchain.chains import LLMChain
chain = LLMChain(llm=llm, prompt=prompt)

print(chain.run("새로 만든 거대 언어 모델"))

### 결과
1. GigaMinds
2. MegaLinguo
3. SuperVerse

LLMChain 실행 방법

이렇게 LLMChain으로 llmprompt를 연결한 후 chain.run으로 한 번에 실행할 수 있습니다. 참고로, chain을 실행하는 방법에는 여러 가지가 있습니다.

chain.run("새로 만든 거대 언어 모델")
chain.run({"what":"새로 만든 거대 언어 모델"})
chain.predict(what="새로 만든 거대 언어 모델")

위 세 가지 방법은 앞의 결과처럼 문자열을 반환합니다.

chain("새로 만든 거대 언어 모델")

### 결과
{'what': '새로 만든 거대 언어 모델',
 'text': '1. 인지어 (CogniLang)\n2. 심대어 (MenteLingua)\n3. 우주어 (CosmoLexis)'}

위 경우에는 딕셔너리를 반환했고, 결과는 ‘text’에 들어 있습니다.

input_list = [
    {"what":"새로 만든 거대 언어 모델"},
    {"what":"새로 만든 그림 생성 AI"}
]
chain.apply(input_list)

### 결과
[{'text': '1. "Aether"\n2. "Luminex"\n3. "Omnilingua"'},
 {'text': '1. PaintPal\n2. CreativeCanvas\n3. ArtGenius'}]

리스트를 만들어 입력했고, 결과를 담은 딕셔너리들의 리스트를 받았습니다. LLM으로 ChatOpenAI를 사용했지만, 기본적으로 LLMChain은 대화를 이어가는 기능이 아닙니다. 위에서 응답 결과는 input_list 내에 있는 각각의 질문에 대한 대답입니다. 대화를 위해서는 ConversationChain이 별도로 존재합니다.

chain.generate(input_list)

### 결과
LLMResult(
generations=[
[ChatGeneration(text='1. 바나나AI\n2. 루나\n3. 메가클라우드', generation_info={'finish_reason': 'stop'}, message=AIMessage(content='1. 바나나AI\n2. 루나\n3. 메가클라우드', additional_kwargs={}, example=False))], 
[ChatGeneration(text='1. CreARTist\n2. AImagination\n3. ArtiVision', generation_info={'finish_reason': 'stop'}, message=AIMessage(content='1. CreARTist\n2. AImagination\n3. ArtiVision', additional_kwargs={}, example=False))]
],
llm_output={'token_usage': {'prompt_tokens': 62, 'completion_tokens': 40, 'total_tokens': 102}, 'model_name': 'gpt-3.5-turbo'}, 
run=[RunInfo(run_id=UUID('144c8d77-c1a6-48d3-a65a-983db734b6e9')), RunInfo(run_id=UUID('939a315a-b71c-48f1-9814-0abf6bfca23a'))]
)

generate를 사용하면 LLMResult 객체를 반환하는데, 여기에는 토큰수와 같은 추가 정보가 들어가 있습니다.

PromptTemplate에 변수를 여러 개 넣는 것도 가능하죠.

prompt = PromptTemplate(
    input_variables=["desc", "what"],
    template="{desc} {what} 이름 세 개를 추천해줘.",
)
chain2 = LLMChain(llm=llm, prompt=prompt)
print(chain2.run({
    'desc': "한국적인",
    'what': "거대 언어 모델"
    }))

PromptTemplate 대신 ChatPromptTemplate을 사용하는 것도 가능합니다.

from langchain.chat_models import ChatOpenAI
from langchain.prompts.chat import (
    ChatPromptTemplate,
    HumanMessagePromptTemplate,
)
human_message_prompt = HumanMessagePromptTemplate(
        prompt=PromptTemplate(
            template="{what} 이름 세 개를 추천해줘.",
            input_variables=["what"],
        )
    )
chat_prompt_template = ChatPromptTemplate.from_messages([human_message_prompt])
chat = ChatOpenAI(temperature=0.9)

chain = LLMChain(llm=chat, prompt=chat_prompt_template)
print(chain.run("새로 만든 거대 언어 모델"))

LLMChain: PromptTemplate + LLM + OutputParser

OutputParser도 chain으로 연결할 수 있습니다. 여기서는 CommaSeparatedListOutputParser를 사용하겠습니다. 문자열을 쉼표로 분리하여 리스트로 만들어주는 파서입니다.

from langchain.output_parsers import CommaSeparatedListOutputParser

output_parser=CommaSeparatedListOutputParser()
prompt = PromptTemplate(
    input_variables=["what"],
    template="{what} 이름 세 개를 추천해줘. \n{format_instructions}",
    partial_variables={"format_instructions": output_parser.get_format_instructions()}
)

위 코드에서 partial_variables를 사용했는데, 이는 템플릿 안의 변수들 중 일부만 미리 치환할 수 있는 기능입니다. PromptTemplate에 output_parser의 포맷 명령을 넣었는데, chain에 output_parser를 안 넣고 실행해보겠습니다.

chain = LLMChain(llm=chat, prompt=prompt)
chain.predict(what="새로 만든 거대 언어 모델")

### 결과
'GigaNLP, MegaLang, SuperGPT'

프롬프트에 출력 포맷을 지정했기 때문에 결과가 쉼표로 구분되 나오기는 했는데, 문자열이 반환되어 나왔습니다. 이번에는 chain에 output_parser를 추가해보겠습니다.

chain = LLMChain(llm=chat, prompt=prompt, output_parser=output_parser)
chain.predict(what="새로 만든 거대 언어 모델")

### 결과
['GLOMO', 'MEGALINGUA', 'LEXICON3000']

결과가 리스트로 나왔습니다! 이렇게 Chain을 이용하면 PromptTemplate과 LLM, OutputParser를 연결해 사용할 수 있습니다. Chain은 LangChain 이름에서 알 수 있는 것처럼 LangChain의 핵심 기능입니다.