langchain / / 2024. 6. 27. 06:52

[langchain] 상위 문서 검색기(Parent Document Retriever)

검색을 위해 문서를 분할할 때 다음과 같이 상충되는 요구사항이 있을 수 있다.

  1. 문서를 작게 나눠서 의미를 정확하게 반영될 필요가 있다. 너무 길다면 임베딩 결과는 그 의미를 잃어버릴 수 있다.
  2. 각 청크의 문맥이 포함할 만큼 충분히 길어야 할 필요가 있다.

ParentDocumentRetriever는 문서를 분할하고 작게 유지한다. 우선, 작은 청크를 가져와서 부모 문서를 찾는다. 부모 문서는 짜르기 전의 원본 문서를 말한다.

ParentDocumentRetriever의 기본 개념은 같은 문서 내용이라도 해당 내용만 포함되는 경우와 다른 내용과 같이 있는 경우 검색의 유사도가 달라질 수 있다. 그래서 필요한 내용이 검색이 잘 되게 하기 위해 문서를 작은 청크로 나누고 실제 검색된 내용의 부모 문서를 찾아서 LLM에 전달한다는 내용이다. 즉, 검색할 때는 작은 청크의 문서(child document)로 검색을 하고 LLM에 요청할 때는 전체 문서(parent)를 넘긴다는 것이다.

청크 개수에 따른 유사도 비교

ParentDocumentRetriever를 사용하기 전에 청크의 길이에 따라 유사도 점수가 어떻게 달라지는지 한번 확인해보자.

조선 건국에 대한 내용인데 첫 번째 청크에 해당 내용이 있다. 이 내용을 500자의 청크로 나누는 경우와 1000자의 청크로 나누는 경우 청크의 개수가 달라진다. 전자는 3개의 청크로 나눠지고 후자는 1개의 청크로 나눠진다. 두개의 케이스에 대해 score가 어떻게 달라지는지 비교해보자.

[history.txt]

대략 조선이 건국된 1392년부터 대한제국이 성립된 1897년까지를 조선시대로 잡고 있지만 사실 조선이 건국되었다는 1392년부터 1393년 3월 27일까지는 여전히 전 왕조의 국명인 고려를 국호로 유지하고 있었고, 1897년에 성립된 대한제국도 왕조 자체는 조선 왕조를 그대로 이어가고 있기 때문에 사실상 조선의 연장선상으로 보고 있다. 따라서 엄밀하게 따지면 조선시대는 '조선'이라는 국호가 쓰인 1393년부터 1897년까지이나 사실상 세간의 인식은 이성계가 공양왕으로부터 선위받아 전주 이씨 왕조가 성립된 1392년부터 일본제국에게 강제병합되어 전주 이씨 왕조가 멸망한 1910년까지로 여겨지며 본 문서도 그런 시각에서 작성되어 있다.

건국 초기, 명 중심의 패권 질서 확립과 일본의 남북조시대[1]의 종식[2] 등에 힙입어 태조부터 태종, 세종, 세조 치세에는 강력한 왕권과 더불어 고려말기의 여러 폐단 및 혼란상을 개혁했다.[3] 특히 이 시기에는 한국어 고유문자인 언문(한글) 창제와 함께 혼일강리역대국도지도 제작, 천상열차분야지도 제작, 세계 최초의 납 활자인 병진자 제작, 한국사 최초의 역법인 칠정산 제작, '4군 6진' 등으로 표상되는 영역 개발-확장, 인구의 꾸준한 증가[4] 등 여러 긍정적인 발전들이 계속 이루어졌다.

이후 성종 시기에도 정치체제의 안정화를 바탕으로 많은 업적을 달성하였다.[5] 하지만 성종 사후 연산군의 폭정으로 중종반정이 발발하는 등 일부 혼란과 폐단이 야기되기도 하였다. 다만, 그럼에도 불구하고 큰 변동없이 평화적으로 국가가 계속 운영되었다. 그러나 역설적으로 이 기나긴 평화 가운데 일본에서는 아즈치모모야마 시대가 도래했고, 북방에서는 여진 세력들이 성장했다.[6] 결국 왕조 중반에 임진왜란(남왜) 및 정묘·병자호란(북로)이 발발하면서 국가가 전복될 큰 위기를 맞고 그 과정에서 인조반정 및 명청조의 간섭을 겪었다.

코드 내용

import langchain
from dotenv import load_dotenv
from langchain_community.document_loaders import TextLoader
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import (
    CharacterTextSplitter,
)

langchain.debug = True

load_dotenv()

embeddings = OpenAIEmbeddings()
vector_store = Chroma(persist_directory="chroma_news", embedding_function=embeddings)

loader = TextLoader("./data/history.txt")
documents = loader.load()
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
texts = text_splitter.split_documents(documents)
Chroma.from_documents(texts, embeddings, persist_directory="chroma_news")

question = "조선 건국은 언제이며 대한제국에서 조선 왕조를 이어가는 시기는 언제인가?"
results = vector_store.similarity_search_with_score(question)
for result in results:
    print("\n")
    print(result[1])
    print(result[0].page_content)

1. 청크 500으로 나누는 경우

text_splitter = CharacterTextSplitter(chunk_size=500, chunk_overlap=0)

실행결과

0.19549670471119315
대략 조선이 건국된 1392년부터 대한제국이 성립된 1897년까지를 조선시대로 잡고 있지만 사실 조선이 건국되었다는 1392년부터 1393년 3월 27일까지는 여전히 전 왕조의 국명인 고려를 국호로 유지하고 있었고, 1897년에 성립된 대한제국도 왕조 자체는 조선 왕조를 그대로 이어가고 있기 때문에 사실상 조선의 연장선상으로 보고 있다. 따라서 엄밀하게 따지면 조선시대는 '조선'이라는 국호가 쓰인 1393년부터 1897년까지이나 사실상 세간의 인식은 이성계가 공양왕으로부터 선위받아 전주 이씨 왕조가 성립된 1392년부터 일본제국에게 강제병합되어 전주 이씨 왕조가 멸망한 1910년까지로 여겨지며 본 문서도 그런 시각에서 작성되어 있다.


0.23640665249266926
건국 초기, 명 중심의 패권 질서 확립과 일본의 남북조시대[1]의 종식[2] 등에 힙입어 태조부터 태종, 세종, 세조 치세에는 강력한 왕권과 더불어 고려말기의 여러 폐단 및 혼란상을 개혁했다.[3] 특히 이 시기에는 한국어 고유문자인 언문(한글) 창제와 함께 혼일강리역대국도지도 제작, 천상열차분야지도 제작, 세계 최초의 납 활자인 병진자 제작, 한국사 최초의 역법인 칠정산 제작, '4군 6진' 등으로 표상되는 영역 개발-확장, 인구의 꾸준한 증가[4] 등 여러 긍정적인 발전들이 계속 이루어졌다.


0.27058273256421855
이후 성종 시기에도 정치체제의 안정화를 바탕으로 많은 업적을 달성하였다.[5] 하지만 성종 사후 연산군의 폭정으로 중종반정이 발발하는 등 일부 혼란과 폐단이 야기되기도 하였다. 다만, 그럼에도 불구하고 큰 변동없이 평화적으로 국가가 계속 운영되었다. 그러나 역설적으로 이 기나긴 평화 가운데 일본에서는 아즈치모모야마 시대가 도래했고, 북방에서는 여진 세력들이 성장했다.[6] 결국 왕조 중반에 임진왜란(남왜) 및 정묘·병자호란(북로)이 발발하면서 국가가 전복될 큰 위기를 맞고 그 과정에서 인조반정 및 명청조의 간섭을 겪었다.

가장 높은 score가 0.24410709277021087이다.

2. 청크 1000으로 나누는 경우

text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)

실행결과

0.20640604707263357
대략 조선이 건국된 1392년부터 대한제국이 성립된 1897년까지를 조선시대로 잡고 있지만 사실 조선이 건국되었다는 1392년부터 1393년 3월 27일까지는 여전히 전 왕조의 국명인 고려를 국호로 유지하고 있었고, 1897년에 성립된 대한제국도 왕조 자체는 조선 왕조를 그대로 이어가고 있기 때문에 사실상 조선의 연장선상으로 보고 있다. 따라서 엄밀하게 따지면 조선시대는 '조선'이라는 국호가 쓰인 1393년부터 1897년까지이나 사실상 세간의 인식은 이성계가 공양왕으로부터 선위받아 전주 이씨 왕조가 성립된 1392년부터 일본제국에게 강제병합되어 전주 이씨 왕조가 멸망한 1910년까지로 여겨지며 본 문서도 그런 시각에서 작성되어 있다.

건국 초기, 명 중심의 패권 질서 확립과 일본의 남북조시대[1]의 종식[2] 등에 힙입어 태조부터 태종, 세종, 세조 치세에는 강력한 왕권과 더불어 고려말기의 여러 폐단 및 혼란상을 개혁했다.[3] 특히 이 시기에는 한국어 고유문자인 언문(한글) 창제와 함께 혼일강리역대국도지도 제작, 천상열차분야지도 제작, 세계 최초의 납 활자인 병진자 제작, 한국사 최초의 역법인 칠정산 제작, '4군 6진' 등으로 표상되는 영역 개발-확장, 인구의 꾸준한 증가[4] 등 여러 긍정적인 발전들이 계속 이루어졌다.

이후 성종 시기에도 정치체제의 안정화를 바탕으로 많은 업적을 달성하였다.[5] 하지만 성종 사후 연산군의 폭정으로 중종반정이 발발하는 등 일부 혼란과 폐단이 야기되기도 하였다. 다만, 그럼에도 불구하고 큰 변동없이 평화적으로 국가가 계속 운영되었다. 그러나 역설적으로 이 기나긴 평화 가운데 일본에서는 아즈치모모야마 시대가 도래했고, 북방에서는 여진 세력들이 성장했다.[6] 결국 왕조 중반에 임진왜란(남왜) 및 정묘·병자호란(북로)이 발발하면서 국가가 전복될 큰 위기를 맞고 그 과정에서 인조반정 및 명청조의 간섭을 겪었다.

score가 0.25452070218047745이다. 전자보다는 score가 높아진 것을 알 수 있다. score가 낮을수록 유사도가 높다는 것을 나타낸다.

ParentDocumentRetriever 임베딩

이제 문서 내용을 임베딩 하여 Chroma에 저장을 해보자.

from dotenv import load_dotenv
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import LocalFileStore, create_kv_docstore
from langchain_community.document_loaders import TextLoader
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

load_dotenv()

# 두 개의 문서를 로딩한다.
loaders = [
    TextLoader("./data/news.txt"),
    TextLoader("./data/news2.txt"),
]
docs = []
for loader in loaders:
    docs.extend(loader.load())

# 부모 문서는 청크수를 900
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=900)
# 자식 문서는 청크수를 300
child_splitter = RecursiveCharacterTextSplitter(chunk_size=300)

vectorstore = Chroma(
    persist_directory="chroma_parent_doc",
    collection_name="split_parents",
    embedding_function=OpenAIEmbeddings(),
)

fs = LocalFileStore(root_path="./parent_document_store")
store = create_kv_docstore(fs)
# 메모리에 저장하려면 InMemoryStore를 사용
# store = InMemoryStore()

retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=store,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# ids=None이면 id를 자동생성
# add_to_docstore는 중복으로 생성 여부
retriever.add_documents(docs, ids=None, add_to_docstore=True)

부모 문서도 커질수 있으므로 chunk_size를 900으로 짜른다. 그리고 자식 문서는 score를 높일 수 있도록 더 작은 단위인 300으로 짜른다. 자식 문서는 Chroma DB에 저장을 하고 부모문서는 파일 시스템에 저장을 한다. 이것이 의미하는 것은 실제 검색은 자식문서 기준으로 검색을 하고 실제 결과를 리턴을 할 때는 부모문서 기준으로 리턴하기 위함이다.

ParentDocumentRetriever 검색

from dotenv import load_dotenv
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import LocalFileStore, create_kv_docstore
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

load_dotenv()


parent_splitter = RecursiveCharacterTextSplitter(chunk_size=900)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=300)

vectorstore = Chroma(
    persist_directory="chroma_parent_doc",
    collection_name="split_parents",
    embedding_function=OpenAIEmbeddings(),
)

fs = LocalFileStore(root_path="./parent_document_store")
store = create_kv_docstore(fs)

# Retriever 를 생성합니다.
retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=store,
    child_splitter=child_splitter,
)

question = "비타민은 우리몸에 어떤 역할을 하는가?"
sub_docs = vectorstore.similarity_search(question)

print(f"자식 문서에서의 결과 개수: {len(sub_docs)}")

retrieved_docs = retriever.invoke(question)
print(f"부모 문서에서의 결과 개수: {len(retrieved_docs)}")

실행결과

자식 문서에서의 결과 개수: 4
부모 문서에서의 결과 개수: 2

위의 로직은 ParentDocumentRetriever를 통해 검색을 수행하는 로직이다. similarity_search를 통해 벡터DB를 검색을 하면 4건이 검색이 되지만 Retriever를 통해 실행을 하면 2건이 검색이 된다. 전자의 4건은 자식 문서를 검색한 결과이고 뒤의 2건은 부모 문서의 2건이 검색이 된 것이다.

참고

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유