728x90
반응형

Neo4j와 LangChain을 사용해서 RAG 애플리케이션의 Knowledge Graph에서 정보를 구성하고 검색하는 실용적인 가이드예요.

Graph Retrieval-Augmented Generation(GraphRAG)이 점점 더 주목받고 있고, 기존의 벡터 검색 방식에 강력한 대안이 되고 있죠. 이 접근 방식은 데이터를 Nodes와 Relationships로 구성하는 Graph Database의 구조적인 특성을 활용해서 검색된 정보의 깊이와 맥락을 향상시켜요.

Knowledge Graph의 예시

그래프는 서로 다른 유형의 정보를 구조화된 방식으로 표현하고 저장하는 데 아주 효과적이에요. 다양한 데이터 유형에 걸쳐 복잡한 Relationships와 속성을 쉽게 포착할 수 있죠. 반면에 벡터 데이터베이스는 고차원 벡터를 사용해서 비정형 데이터를 처리하는 데 강점이 있지만, 이렇게 구조화된 정보는 다루기 어려워하는 경우가 많아요. RAG 애플리케이션에서는 구조화된 그래프 데이터와 구조화되지 않은 텍스트를 통한 벡터 검색을 결합해서 두 가지 장점을 모두 얻을 수 있어요. 이번 블로그 포스팅에서 바로 이 내용을 다룰 거예요.

Knowledge Graph는 정말 유용하지만, 어떻게 만들 수 있을까요?

Knowledge Graph를 구축하는 건 보통 가장 어려운 단계로 여겨져요. 데이터를 수집하고 구조화하는 작업이 필요한데, 이를 위해서는 해당 도메인과 그래프 모델링에 대한 깊은 이해가 필수적이죠.

이 과정을 단순화하기 위해서 저희는 LLM을 활용하는 실험을 진행해 왔어요. LLM은 언어와 맥락에 대한 깊은 이해를 바탕으로 Knowledge Graph 생성 프로세스의 중요한 부분을 자동화할 수 있거든요. 이런 모델은 텍스트 데이터를 분석해서 엔터티를 식별하고, 엔터티 간의 Relationships를 파악하고, 엔터티가 그래프 구조에서 가장 잘 표현될 수 있는 방법을 제안할 수 있어요.

이러한 실험의 결과로 저희는 그래프 구성 모듈의 첫 번째 버전을 LangChain에 추가했고, 이번 블로그 포스팅에서 이걸 직접 보여드릴 거예요.

코드는 에서 확인할 수 있어요.

Neo4j 환경 설정

먼저 Neo4j 인스턴스를 설정해야 해요. 이 블로그 포스팅의 예시를 따라해 보세요. 가장 쉬운 방법은 Neo4j AuraDB에서 무료 인스턴스를 시작하는 거예요. Neo4j 데이터베이스의 클라우드 인스턴스를 제공하거든요. 아니면 Neo4j Desktop을 다운로드해서 Neo4j 데이터베이스의 로컬 인스턴스를 설정할 수도 있어요. 애플리케이션을 생성하고 로컬 데이터베이스 인스턴스를 만들면 돼요.

os.environ["OPENAI_API_KEY"] = "sk-"
os.environ["NEO4J_URI"] = "bolt://localhost:7687"
os.environ["NEO4J_USERNAME"] = "neo4j"
os.environ["NEO4J_PASSWORD"] = "password"

graph = Neo4jGraph()

그리고 OpenAI 키도 필요해요. 이 포스팅에서는 OpenAI 모델을 사용할 예정이거든요.

데이터 수집

이번 데모에서는 엘리자베스 1세 위키피디아 페이지를 사용할 거예요. LangChain Loader를 사용하면 Wikipedia에서 문서를 가져오고 분할하는 작업을 아주 쉽게 할 수 있어요.

# Read the wikipedia article
raw_documents = WikipediaLoader(query="Elizabeth I").load()
# Define chunking strategy
text_splitter = TokenTextSplitter(chunk_size=512, chunk_overlap=24)
documents = text_splitter.split_documents(raw_documents[:3])

이제 검색된 문서를 기반으로 그래프를 구성해 볼게요. 이를 위해서 Knowledge Graph를 Graph Database에 구성하고 저장하는 과정을 훨씬 간단하게 만들어주는 LLMGraphTransformer 모듈을 구현했어요.

llm=ChatOpenAI(temperature=0, model_name="gpt-4-0125-preview")
llm_transformer = LLMGraphTransformer(llm=llm)

# Extract graph data
graph_documents = llm_transformer.convert_to_graph_documents(documents)
# Store to neo4j
graph.add_graph_documents(
  graph_documents, 
  baseEntityLabel=True, 
  include_source=True
)

Knowledge Graph 생성 체인에서 사용할 LLM을 정의할 수 있어요. 현재 OpenAI 및 Mistral의 함수 호출 모델만 지원되지만, 앞으로는 LLM 선발을 확대할 계획이에요. 이 예에서는 최신 GPT-4를 사용하고 있는데, 생성된 그래프의 품질은 사용 중인 모델에 따라 크게 달라져요. 이론적으로는 항상 가장 유능한 것을 사용하고 싶겠죠? LLM 그래프 변환기는 add_graph_documents 메소드를 통해 Neo4j로 가져올 수 있는 그래프 문서를 반환해요. baseEntityLabel 매개변수는 각 Node에 추가 __Entity__ Label을 할당해서 Indexing 및 Query 성능을 향상시키고요. include_source 매개변수는 Node를 원래 문서에 연결해서 데이터 추적성과 컨텍스트 이해를 촉진해요.

Neo4j 브라우저에서 생성된 그래프를 검사할 수 있어요.

생성된 그래프의 일부입니다.

이 이미지는 생성된 그래프의 일부만을 나타내요.

RAG용 하이브리드 검색

그래프 생성 후에는 Vector 및 Keyword Index를 RAG 애플리케이션용 그래프 검색과 결합하는 하이브리드 검색 접근 방식을 사용할 거예요.

하이브리드(벡터 + 키워드)와 그래프 검색 방법을 결합합니다. 작성자의 이미지입니다.

다이어그램은 사용자가 질문을 제기하는 것으로 시작해서 RAG 검색기로 연결되는 검색 프로세스를 보여줘요. 이 검색기는 Keyword 및 Vector 검색을 사용해서 구조화되지 않은 텍스트 데이터를 검색하고 이를 Knowledge Graph에서 수집한 정보와 결합해요. Neo4j는 Keyword와 Vector Index를 모두 갖추고 있으므로 단일 데이터베이스 시스템으로 세 가지 검색 옵션을 모두 구현할 수 있어요. 이러한 소스에서 수집된 데이터는 LLM에 입력되어 최종 답변을 생성하고 전달되죠.

구조화되지 않은 데이터 검색기

Neo4jVector.from_existing_graph 메소드를 사용해서 문서에 Keyword와 Vector 검색을 모두 추가할 수 있어요. 이 방법은 Document라는 Label이 붙은 Node를 대상으로 하는 하이브리드 검색 접근 방식을 위한 Keyword 및 Vector 검색 Index를 구성해요. 또한 텍스트 Embedding 값이 누락된 경우 이를 계산하죠.

vector_index = Neo4jVector.from_existing_graph(
    OpenAIEmbeddings(),
    search_type="hybrid",
    node_label="Document",
    text_node_properties=["text"],
    embedding_node_property="embedding"
)

그런 다음 유사성_검색 메소드를 사용해서 Vector Index를 호출할 수 있어요.

그래프 리트리버

반면, 그래프 검색 구성은 더 복잡하지만 더 많은 자유를 제공해요. 이 예에서는 전체 텍스트 Index를 사용해서 관련 Node를 식별하고 직접적인 이웃을 반환해요.

그래프 리트리버. 작성자의 이미지입니다.

그래프 검색기는 입력에서 관련 엔터티를 식별하는 것부터 시작해요. 단순화를 위해 LLM에게 사람, 조직 및 위치를 식별하도록 지시할게요. 이를 달성하기 위해 우리는 LCEL을 사용하는데, 이를 달성하려면 새로 추가된 with_structured_output 메소드를 사용하면 돼요.

# Extract entities from text
class Entities(BaseModel):
    """Identifying information about entities."""

    names: List[str] = Field(
        ...,
        description="All the person, organization, or business entities that "
        "appear in the text",
    )

prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You are extracting organization and person entities from the text.",
        ),
        (
            "human",
            "Use the given format to extract information from the following "
            "input: {question}",
        ),
    ]
)

entity_chain = prompt | llm.with_structured_output(Entities)

테스트해 볼게요.

entity_chain.invoke({"question": "Where was Amelia Earhart born?"}).names
# ['Amelia Earhart']

좋아요! 이제 질문에서 엔터티를 감지할 수 있으므로 전체 텍스트 Index를 사용해서 이를 Knowledge Graph에 매핑할게요. 먼저, 전체 텍스트 Index와 약간의 철자 오류를 허용하는 전체 텍스트 Query를 생성하는 함수를 정의해야 해요. 여기서는 자세히 다루지 않겠지만요.

graph.query(
    "CREATE FULLTEXT INDEX entity IF NOT EXISTS FOR (e:__Entity__) ON EACH [e.id]")

def generate_full_text_query(input: str) -> str:
    """
    Generate a full-text search query for a given input string.

    This function constructs a query string suitable for a full-text search.
    It processes the input string by splitting it into words and appending a
    similarity threshold (~2 changed characters) to each word, then combines 
    them using the AND operator. Useful for mapping entities from user questions
    to database values, and allows for some misspelings.
    """
    full_text_query = ""
    words = [el for el in remove_lucene_chars(input).split() if el]
    for word in words[:-1]:
        full_text_query += f" {word}~2 AND"
    full_text_query += f" {words[-1]}~2"
    return full_text_query.strip()

이제 모든 내용을 종합해 볼 거예요.

# Fulltext index query
def structured_retriever(question: str) -> str:
    """
    Collects the neighborhood of entities mentioned
    in the question
    """
    result = ""
    entities = entity_chain.invoke({"question": question})
    for entity in entities.names:
        response = graph.query(
            """CALL db.index.fulltext.queryNodes('entity', $query, {limit:2})
            YIELD node,score
            CALL {
              MATCH (node)-[r:!MENTIONS]->(neighbor)
              RETURN node.id + ' - ' + type(r) + ' -> ' + neighbor.id AS output
              UNION
              MATCH (node)<-[r:!MENTIONS]-(neighbor)
              RETURN neighbor.id + ' - ' + type(r) + ' -> ' +  node.id AS output
            }
            RETURN output LIMIT 50
            """,
            {"query": generate_full_text_query(entity)},
        )
        result += "n".join([el['output'] for el in response])
    return result

structured_retriever 함수는 사용자 질문에서 entity를 감지하는 것으로 시작해요. 다음으로, 감지된 entity를 반복하고 Cypher 템플릿을 사용해서 관련 node의 이웃을 검색하죠. 시험해 봐요!

print(structured_retriever("Who is Elizabeth I?"))
# Elizabeth I - BORN_ON -> 7 September 1533
# Elizabeth I - DIED_ON -> 24 March 1603
# Elizabeth I - TITLE_HELD_FROM -> Queen Of England And Ireland
# Elizabeth I - TITLE_HELD_UNTIL -> 17 November 1558
# Elizabeth I - MEMBER_OF -> House Of Tudor
# Elizabeth I - CHILD_OF -> Henry Viii
# and more...

파이널 리트리버

처음에 언급했듯이 구조화되지 않은 검색기와 그래프 검색기를 결합해서 LLM에 전달되는 최종 컨텍스트를 생성해요.

def retriever(question: str):
    print(f"Search query: {question}")
    structured_data = structured_retriever(question)
    unstructured_data = [el.page_content for el in vector_index.similarity_search(question)]
    final_data = f"""Structured data:
{structured_data}
Unstructured data:
{"#Document ". join(unstructured_data)}
    """
    return final_data

Python을 다룰 때 f-문자열을 사용해서 출력을 간단히 연결할 수 있어요.

RAG 체인 정의

우리는 RAG의 검색 구성 요소를 성공적으로 구현했어요. 다음으로, 통합 하이브리드 검색기가 제공하는 컨텍스트를 활용해서 응답을 생성하는 프롬프트를 도입해서 RAG 체인 구현을 완료할 거예요.

template = """Answer the question based only on the following context:
{context}

Question: {question}
"""
prompt = ChatPromptTemplate.from_template(template)

chain = (
    RunnableParallel(
        {
            "context": _search_query | retriever,
            "question": RunnablePassthrough(),
        }
    )
    | prompt
    | llm
    | StrOutputParser()
)

마지막으로 하이브리드 RAG 구현을 테스트할 수 있어요.

chain.invoke({"question": "Which house did Elizabeth I belong to?"})
# Search query: Which house did Elizabeth I belong to?
# 'Elizabeth I belonged to the House of Tudor.'

쿼리 재작성 기능도 추가해서, RAG chain이 후속 질문을 처리할 수 있도록 대화 설정에 맞춰 조정했어요. Vector Embedding과 키워드 검색 방법을 사용하니까, 검색 과정을 최적화하려면 후속 질문을 다시 작성해야 하거든요.

chain.invoke(
    {
        "question": "When was she born?",
        "chat_history": [("Which house did Elizabeth I belong to?", "House Of Tudor")],
    }
)
# Search query: When was Elizabeth I born?
# 'Elizabeth I was born on 7 September 1533.'

엘리자베스 1세가 언제 태어났는지 질문하는 것을 볼 수 있죠? 다시 작성된 쿼리를 사용해서 관련 맥락을 검색하고 질문에 답변했어요.

손쉬운 Knowledge Graph

LLMGraphTransformer가 도입되면서 이제 Knowledge Graph 생성 과정이 훨씬 더 쉬워지고 접근성이 좋아졌어요. Knowledge Graph가 제공하는 깊이와 맥락으로 RAG 애플리케이션을 향상시키고 싶은 분들에게 희소식이죠! 앞으로 더 많은 개선이 있을 예정이니 기대해주세요.

LLM을 사용한 그래프 생성에 대한 의견이나 질문이 있다면 언제든지 문의해주세요.

코드는 에서 확인할 수 있습니다.


  • ChatGPT
  • Langchain
  • OpenAI
  • RAG

에이치시스템즈LogTree는 Neo4j 기반 GraphRAG 플랫폼으로, 데이터를 자동으로 지식그래프화하고 자연어 질의로 즉시 답을 제공합니다.

👉 에이치시스템즈 홈페이지

728x90
반응형

+ Recent posts