GraphRAG

Neo4j에 Microsoft GraphRAG 통합하기

hsystems 2026. 5. 25. 09:36
728x90
반응형

MSFT GraphRAG 출력을 Neo4j에 저장하고 LangChain 또는 LlamaIndex를 사용하여 로컬 및 글로벌 검색기를 구현합니다.

Microsoft의 GraphRAG 구현이 요즘 엄청난 관심을 받고 있죠. 제 지난 블로그 게시물에서는 그래프가 어떻게 구성되는지, 그리고 연구 논문에서 강조된 혁신적인 측면들을 살펴봤어요. 간단히 말해서, GraphRAG 라이브러리의 입력값은 다양한 정보가 담긴 소스 문서인데요. 이 문서는 LLM(Large Language Model)을 사용해서 처리되고, 문서에 나타나는 엔터티와 그 관계에 대한 구조화된 정보를 추출하는 데 사용돼요. 이렇게 추출된 구조화된 정보는 Knowledge Graph를 구성하는 데 쓰인답니다.

Microsoft의 GraphRAG 문서에 구현된 높은 수준의 인덱싱 파이프라인 — 작성자의 이미지

Knowledge Graph가 구성되면, GraphRAG 라이브러리는 그래프 알고리즘, 특히 Leiden 커뮤니티 감지 알고리즘과 LLM 프롬프트의 조합을 사용해서 Knowledge Graph에서 발견된 엔터티 및 관계 커뮤니티의 자연어 요약을 생성해요.

이번 포스팅에서는 GraphRAG 라이브러리를 사용해서 Neo4j에 저장한 다음, LangChain 및 LlamaIndex 오케스트레이션 프레임워크를 사용해서 Neo4j에서 직접 검색기를 설정하는 방법을 알아볼 거예요.

코드 및 GraphRAG 출력은 에서 확인할 수 있고, GraphRAG 추출 프로세스를 건너뛸 수도 있어요.

데이터세트

이 블로그 게시물에서 소개할 데이터세트는 Charles Dickens의 "A Christmas Carol"이고, Gutenberg Project를 통해 무료로 액세스할 수 있습니다.

찰스 디킨스의 크리스마스 캐롤

이 책이 강조 표시되어 있어서 원본 문서로 선택했는데, 소개 문서를 보면 추출을 쉽게 수행할 수 있다는 걸 알 수 있을 거예요.

그래프 구성

그래프 추출 부분을 건너뛰어도 괜찮지만, 제가 중요하다고 생각하는 몇 가지 구성 옵션에 대해 이야기해볼게요. 예를 들어, 그래프 추출은 토큰을 많이 사용하고 비용이 많이 들 수 있어요. 그래서 gpt-4o-mini처럼 비교적 저렴하면서도 성능이 좋은 LLM을 사용해서 추출을 테스트하는 게 합리적이죠. gpt-4-turbo를 사용하면 여기에 설명된 것처럼 뛰어난 정확도를 유지하면서 비용을 상당히 절감할 수 있답니다. 블로그 게시물을 참고해보세요.

GRAPHRAG_LLM_MODEL=gpt-4o-mini

가장 중요한 구성은 추출하려는 엔터티의 유형이에요. 기본적으로 조직, 사람, 이벤트 및 지역이 추출되죠.

GRAPHRAG_ENTITY_EXTRACTION_ENTITY_TYPES=organization,person,event,geo

이러한 기본 엔터티 유형은 책에 적합할 수 있지만, 특정 사용 사례에 대해 처리하려는 문서의 도메인에 따라 변경해야 할 수도 있어요.

또 다른 중요한 구성 요소는 최대 수집 값이에요. 저자는 LLM이 단일 추출 단계에서 사용 가능한 모든 정보를 추출하지 않는다는 것을 확인했고, 별도로 검증했대요.

텍스트 청크 크기에 따른 추출 엔터티 수 - 이미지 GraphRAG paper, CC BY 4.0에 따라 라이센스가 부여됨

수집 구성을 통해 LLM은 여러 추출 과정을 수행할 수 있어요. 위 이미지에서 다중 패스(줍기)를 수행할 때 더 많은 정보를 추출한다는 것을 명확하게 볼 수 있죠. 다중 패스는 토큰 집약적이므로 gpt-4o-mini와 같은 저렴한 모델은 비용을 낮게 유지하는 데 도움이 될 거예요.

GRAPHRAG_ENTITY_EXTRACTION_MAX_GLEANINGS=1

또한 기본적으로 청구 또는 공변량 정보는 추출되지 않아요. GRAPHRAG_CLAIM_EXTRACTION_ENABLED 구성을 설정하여 활성화할 수 있답니다.

GRAPHRAG_CLAIM_EXTRACTION_ENABLED=False
GRAPHRAG_CLAIM_EXTRACTION_MAX_GLEANINGS=1

모든 구조화된 정보가 단일 패스에서 추출되지는 않는다는 것이 반복되는 주제인 것 같아요. 따라서 여기에도 수집 구성 옵션이 있는 거죠.

흥미로운 점은 프롬프트 튜닝 섹션에 대해 더 깊이 파고들 시간이 없었다는 거예요. Prompt Tuning은 선택 사항이지만 정확성을 향상시킬 수 있으므로 적극 권장된답니다.

Prompt Tuning ⚙️

구성이 설정된 후 다음을 따를 수 있어요. 그래프 추출 파이프라인을 실행하기 위한 지침, 이는 다음 단계로 구성되어 있대요.

파이프라인의 단계 — 다음의 이미지 GraphRAG paper, CC BY 4.0에 따라 라이센스가 부여됨

추출 파이프라인은 위 이미지의 파란색 단계를 모두 실행해요. 제 검토 이전 블로그 게시물에서 그래프 구성 및 커뮤니티 요약에 대해 자세히 알아보세요. MSFT GraphRAG 라이브러리의 그래프 추출 파이프라인의 출력은 다음과 같이 Parquet 파일 세트입니다. 덜스 작전의 예.

이러한 Parquet 파일은 다운스트림 분석, 시각화 및 검색을 위해 Neo4j Graph Database로 쉽게 가져올 수 있어요. 우리는 무료 클라우드 Aura 인스턴스를 사용하거나 로컬 Neo4j 환경을 설정할 수 있죠. 제 친구 마이클 헝거가 Parquet 파일을 Neo4j로 가져오는 대부분의 작업을 수행했어요. 이번 블로그 게시물에서는 가져오기 설명을 건너뛰겠지만, 5~6개의 CSV 파일에서 Knowledge Graph를 가져오고 구성하는 것으로 구성되어 있답니다. CSV 가져오기에 대해 자세히 알아보려면 다음을 확인하세요. Neo4j 그래프 아카데미 과정.

가져오기 코드는 GitHub의 Jupyter 노트북에 GraphRAG 출력 예시와 함께 있답니다.

마스터의 블로그/msft_graphrag/ms_graphrag_import.ipynb · tomasonjo/blogs

가져오기가 완료되면 Neo4j 브라우저를 열어서 가져온 그래프의 일부를 확인하고 시각화할 수 있어요.

가져온 그래프의 일부 모습이에요. (이미지 출처: 작성자)

그래프 분석

이제 검색기를 구현하기 전에 간단한 그래프 분석을 통해 추출된 데이터에 익숙해져 보도록 해요. 먼저 데이터베이스 연결과 Cypher 구문 (Graph Database 쿼리 언어)을 실행하고 Pandas DataFrame을 출력하는 함수를 정의할게요.

NEO4J_URI="bolt://localhost"
NEO4J_USERNAME="neo4j"
NEO4J_PASSWORD="password"

driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USERNAME, NEO4J_PASSWORD))

def db_query(cypher: str, params: Dict[str, Any] = {}) -> pd.DataFrame:
    """Executes a Cypher statement and returns a DataFrame"""
    return driver.execute_query(
        cypher, parameters_=params, result_transformer_=Result.to_df
    )

그래프 추출을 수행할 때 청크 크기를 300으로 설정했었죠. 이후 작성자는 기본 청크 크기를 1200으로 변경했는데, 다음 Cypher 구문을 사용해서 청크 크기를 확인할 수 있어요.

db_query(
  "MATCH (n:__Chunk__) RETURN n.n_tokens as token_count, count(*) AS count"
)
# token_count count
# 300         230
# 155         1

결과를 보면 230개의 청크에는 300개의 토큰이 있고, 마지막 청크에는 155개의 토큰만 있는 것을 확인할 수 있어요. 그럼 이제 예제 엔터티와 해당 설명을 한번 살펴볼까요?

db_query(
  "MATCH (n:__Entity__) RETURN n.name AS name, n.description AS description LIMIT 1"
)

엔터티 이름 및 설명 예시 (이미지 출처: 작성자).

구텐베르크 프로젝트는 책의 어딘가, 아마도 처음 부분에 설명되어 있는 것 같아요. 설명을 통해 엔터티 이름보다 더 자세하고 복잡한 정보를 캡처할 수 있다는 점을 알 수 있죠. MSFT GraphRAG 문서는 텍스트에서 더 정교하고 미묘한 데이터를 유지하기 위해 도입되었어요.

예제 관계도 확인해 보도록 할게요.

db_query(
  "MATCH ()-[n:RELATED]->() RETURN n.description AS description LIMIT 5"
)

관계 설명의 예시 이미지예요.

MSFT GraphRAG는 자세한 관계 설명을 캡처해서 엔터티 간의 단순한 관계 유형을 추출하는 것 이상의 역할을 해요. 이 기능을 사용하면 단순한 관계 유형보다 훨씬 더 미묘한 정보를 캡처할 수 있다는 점이 매력적이죠.

단일 커뮤니티와 생성된 설명을 한번 살펴볼까요?

db_query("""
  MATCH (n:__Community__) 
  RETURN n.title AS title, n.summary AS summary, n.full_content AS full_content LIMIT 1
""")

커뮤니티 설명 예시 이미지예요.

커뮤니티에는 LLM을 사용해서 생성된 제목, 요약 및 전체 콘텐츠가 있어요. 작성자가 검색 중에 전체 맥락을 사용하는지, 아니면 요약만 사용하는지는 확인하지 못했지만 둘 중 하나를 선택할 수 있죠. 정보가 나온 엔터티와 관계를 가리키는 `full_content`에서 인용을 볼 수 있어요. 다음 예와 같이 LLM이 인용이 너무 길면 때때로 잘라내는 게 재밌어요.

[Data: Entities (11, 177); Relationships (25, 159, 20, 29, +more)]

"+more" 기호를 확장할 수 있는 방법이 없어서 LLM의 긴 인용을 처리하는 재미있는 방법이라고 생각해요.

이제 몇 가지 분포를 평가해 볼게요. 텍스트 청크에서 추출된 엔터티 수의 분포를 검사하는 것부터 시작해 볼까요?

entity_df = db_query(
    """
MATCH (d:__Chunk__)
RETURN count {(d)-[:HAS_ENTITY]->()} AS entity_count
"""
)
# Plot distribution
plt.figure(figsize=(10, 6))
sns.histplot(entity_df['entity_count'], kde=True, bins=15, color='skyblue')
plt.axvline(entity_df['entity_count'].mean(), color='red', linestyle='dashed', linewidth=1)
plt.axvline(entity_df['entity_count'].median(), color='green', linestyle='dashed', linewidth=1)
plt.xlabel('Entity Count', fontsize=12)
plt.ylabel('Frequency', fontsize=12)
plt.title('Distribution of Entity Count', fontsize=15)
plt.legend({'Mean': entity_df['entity_count'].mean(), 'Median': entity_df['entity_count'].median()})
plt.show()

텍스트 청크에서 추출된 엔터티 수의 분포에요. 이미지 출처: 작성자.

텍스트 청크는 300개의 토큰으로 구성되어 있다는 점, 기억하시죠? 그래서 추출된 엔터티 수는 텍스트 청크당 평균 약 3개 정도로 비교적 적어요. 추출은 단일 추출 패스로 진행됐어요. 수집 횟수를 늘리면 분포가 어떻게 변할지 살펴보는 것도 흥미로울 것 같아요.

다음으로는 Node 차수 분포를 평가해볼게요. Node 등급은 해당 Node가 가지고 있는 관계의 수를 의미해요.

degree_dist_df = db_query(
    """
MATCH (e:__Entity__)
RETURN count {(e)-[:RELATED]-()} AS node_degree
"""
)
# Calculate mean and median
mean_degree = np.mean(degree_dist_df['node_degree'])
percentiles = np.percentile(degree_dist_df['node_degree'], [25, 50, 75, 90])
# Create a histogram with a logarithmic scale
plt.figure(figsize=(12, 6))
sns.histplot(degree_dist_df['node_degree'], bins=50, kde=False, color='blue')
# Use a logarithmic scale for the x-axis
plt.yscale('log')
# Adding labels and title
plt.xlabel('Node Degree')
plt.ylabel('Count (log scale)')
plt.title('Node Degree Distribution')
# Add mean, median, and percentile lines
plt.axvline(mean_degree, color='red', linestyle='dashed', linewidth=1, label=f'Mean: {mean_degree:.2f}')
plt.axvline(percentiles[0], color='purple', linestyle='dashed', linewidth=1, label=f'25th Percentile: {percentiles[0]:.2f}')
plt.axvline(percentiles[1], color='orange', linestyle='dashed', linewidth=1, label=f'50th Percentile: {percentiles[1]:.2f}')
plt.axvline(percentiles[2], color='yellow', linestyle='dashed', linewidth=1, label=f'75th Percentile: {percentiles[2]:.2f}')
plt.axvline(percentiles[3], color='brown', linestyle='dashed', linewidth=1, label=f'90th Percentile: {percentiles[3]:.2f}')
# Add legend
plt.legend()
# Show the plot
plt.show()

Node 등급 분포. 이미지 출처: 작성자.

대부분의 실제 네트워크는 멱법칙(power law) Node 차수 분포를 따르는데, 이는 대부분의 Node가 비교적 작은 차수를 갖고, 일부 중요한 Node는 많은 차수를 갖는다는 의미에요. 이 그래프는 크기가 작지만 Node 차수는 멱법칙을 따르고 있네요. 어떤 엔터티가 120개의 관계(전체 엔터티의 43%에 연결)를 가지고 있는지 확인해 보는 것도 재밌을 것 같아요.

db_query("""
  MATCH (n:__Entity__) 
  RETURN n.name AS name, count{(n)-[:RELATED]-()} AS degree
  ORDER BY degree DESC LIMIT 5""")

관계가 가장 많은 엔터티를 보여주고 있어요. 이미지 출처는 작성자입니다.

단연 스크루지가 책의 주인공이라고 짐작할 수 있겠죠? Ebenezer Scrooge와 Scrooge가 실제로 동일한 엔터티라고 추측해 볼 수도 있지만, MSFT GraphRAG에는 엔터티 확인 단계가 없어서 병합되지는 않았어요.

그리고 Project Gutenberg에는 책 내용과는 관련 없는 13개의 관계가 있네요. 데이터를 분석하고 정리하는 과정이 노이즈 정보를 줄이는 데 얼마나 중요한지 보여주는 부분이에요.

마지막으로, 레벨별 커뮤니티 규모 분포를 한번 살펴볼까요?

community_data = db_query("""
  MATCH (n:__Community__)
  RETURN n.level AS level, count{(n)-[:IN_COMMUNITY]-()} AS members
""")

stats = community_data.groupby('level').agg(
    min_members=('members', 'min'),
    max_members=('members', 'max'),
    median_members=('members', 'median'),
    avg_members=('members', 'mean'),
    num_communities=('members', 'count'),
    total_members=('members', 'sum')
).reset_index()

# Create box plot
plt.figure(figsize=(10, 6))
sns.boxplot(x='level', y='members', data=community_data, palette='viridis')
plt.xlabel('Level')
plt.ylabel('Members')

# Add statistical annotations
for i in range(stats.shape[0]):
    level = stats['level'][i]
    max_val = stats['max_members'][i]
    text = (f"num: {stats['num_communities'][i]}n"
            f"all_members: {stats['total_members'][i]}n"
            f"min: {stats['min_members'][i]}n"
            f"max: {stats['max_members'][i]}n"
            f"med: {stats['median_members'][i]}n"
            f"avg: {stats['avg_members'][i]:.2f}")
    plt.text(level, 85, text, horizontalalignment='center', fontsize=9)

plt.show()

레벨별 커뮤니티 규모 분포예요. 이미지 출처는 작성자입니다.

Leiden 알고리즘은 세 가지 레벨의 커뮤니티를 식별했는데, 레벨이 높을수록 평균적으로 규모가 더 크네요. 하지만 all_members 수를 확인해 보면, 이론적으로는 동일해야 함에도 불구하고 각 레벨마다 노드 수가 다른 걸 확인할 수 있어요. 제가 알지 못하는 기술적인 부분이 있는 것 같아요. 그리고 커뮤니티가 더 높은 레벨에서 병합된다면, 왜 레벨 0에는 19개의 커뮤니티가 있고 레벨 1에는 22개의 커뮤니티가 있을까요? 작성자분이 여기서 몇 가지 최적화와 요령을 사용했지만, 아직 자세히 살펴볼 시간이 없었다고 하네요.

검색기 구현

이번 블로그 포스팅의 마지막 부분에서는 MSFT GraphRAG에 지정된 로컬 및 글로벌 검색기에 대해 알아볼 거예요. 검색기는 LangChain 및 LlamaIndex와 함께 구현되고 통합되죠.

로컬 리트리버

로컬 검색기는 Vector Search를 사용해서 관련된 Nodes를 식별한 다음, 연결된 정보를 수집해서 LLM 프롬프트에 넣어줘요.

로컬 리트리버 아키텍처. 이미지 출처:

이 다이어그램, 복잡해 보일 수 있지만 쉽게 구현할 수 있어요. 먼저 엔터티 설명의 텍스트 임베딩을 기반으로 하는 벡터 유사성 검색을 사용해서 관련 엔터티를 식별하는 거죠. 관련 엔터티가 식별되면 관련 텍스트 덩어리, 관계, 커뮤니티 요약 등을 탐색할 수 있어요. 벡터 유사성 검색을 사용한 다음 그래프 전체를 탐색하는 패턴은 LangChain과 LlamaIndex 모두의 retrieval_query 기능을 사용해서 쉽게 구현할 수 있답니다.

가장 먼저 벡터 index를 구성해야 해요.

index_name = "entity"

db_query(
    """
CREATE VECTOR INDEX """
    + index_name
    + """ IF NOT EXISTS FOR (e:__Entity__) ON e.description_embedding
OPTIONS {indexConfig: {
 `vector.dimensions`: 1536,
 `vector.similarity_function`: 'cosine'
}}
"""
)

그리고 커뮤니티의 엔터티가 나타나는 고유한 텍스트 청크의 수로 정의되는 커뮤니티 가중치를 계산하고 저장할 거예요.

db_query(
    """
MATCH (n:`__Community__`)<-[:IN_COMMUNITY]-()<-[:HAS_ENTITY]-(c)
WITH n, count(distinct c) AS chunkCount
SET n.weight = chunkCount"""
)

각 섹션의 후보자 수(텍스트 단위, 커뮤니티 보고서 등)는 구성 가능해요. 원래 구현에는 토큰 수를 기반으로 한 필터링이 약간 더 포함되어 있지만 여기서는 단순화할게요. 기본 구성 값을 기반으로 다음과 같은 단순화된 상위 후보 필터 값을 개발했어요.

topChunks = 3
topCommunities = 3
topOutsideRels = 10
topInsideRels = 10
topEntities = 10

LangChain 구현부터 시작해볼게요. 우리가 정의해야 할 유일한 것은 더 복잡한 retrieval_query 랍니다.

lc_retrieval_query = """
WITH collect(node) as nodes
// Entity - Text Unit Mapping
WITH
collect {
    UNWIND nodes as n
    MATCH (n)<-[:HAS_ENTITY]->(c:__Chunk__)
    WITH c, count(distinct n) as freq
    RETURN c.text AS chunkText
    ORDER BY freq DESC
    LIMIT $topChunks
} AS text_mapping,
// Entity - Report Mapping
collect {
    UNWIND nodes as n
    MATCH (n)-[:IN_COMMUNITY]->(c:__Community__)
    WITH c, c.rank as rank, c.weight AS weight
    RETURN c.summary 
    ORDER BY rank, weight DESC
    LIMIT $topCommunities
} AS report_mapping,
// Outside Relationships 
collect {
    UNWIND nodes as n
    MATCH (n)-[r:RELATED]-(m) 
    WHERE NOT m IN nodes
    RETURN r.description AS descriptionText
    ORDER BY r.rank, r.weight DESC 
    LIMIT $topOutsideRels
} as outsideRels,
// Inside Relationships 
collect {
    UNWIND nodes as n
    MATCH (n)-[r:RELATED]-(m) 
    WHERE m IN nodes
    RETURN r.description AS descriptionText
    ORDER BY r.rank, r.weight DESC 
    LIMIT $topInsideRels
} as insideRels,
// Entities description
collect {
    UNWIND nodes as n
    RETURN n.description AS descriptionText
} as entities
// We don't have covariates or claims here
RETURN {Chunks: text_mapping, Reports: report_mapping, 
       Relationships: outsideRels + insideRels, 
       Entities: entities} AS text, 1.0 AS score, {} AS metadata
"""

lc_vector = Neo4jVector.from_existing_index(
    OpenAIEmbeddings(model="text-embedding-3-small"),
    url=NEO4J_URI,
    username=NEO4J_USERNAME,
    password=NEO4J_PASSWORD,
    index_name=index_name,
    retrieval_query=lc_retrieval_query
)

이 Cypher querynode 집합에서 여러 분석 작업을 수행해서 관련 텍스트 데이터를 추출하고 구성해요.

1. 엔터티-텍스트 단위 매핑: 각 node에 대해 query는 연결된 텍스트 청크(__Chunk__)를 식별하고 이를 각 청크와 연결된 개별 node 수로 집계하고 빈도별로 정렬해요. 최상위 청크는 'text_mapping'으로 반환되죠.

2. 엔터티-보고서 매핑: 각 node에 대해 query는 연결된 커뮤니티(__Community__)를 찾고 순위 및 가중치를 기준으로 최상위 커뮤니티의 요약을 반환해요.

3. 외부 관계: 이 섹션에서는 관련 엔터티(m)가 초기 node 집합의 일부가 아닌 관계(RELATED)에 대한 설명을 추출해요. 관계는 순위가 지정되며 최상위 외부 관계로 제한된답니다.

4. 내부 관계: 외부 관계와 유사하지만 이번에는 두 엔터티가 초기 node 집합 내에 있는 관계만 고려해요.

5. 엔터티 설명: 초기 세트의 각 node에 대한 설명을 간단히 수집합니다.

마지막으로 query는 수집된 데이터를 기본 점수 및 빈 메타데이터 개체와 함께 청크, 보고서, 내부 및 외부 관계, 엔터티 설명으로 구성된 구조화된 결과로 결합해요. 검색 부분 중 일부를 제거해서 결과에 어떤 영향을 미치는지 테스트할 수 있는 옵션도 있답니다.

이제 다음 코드를 사용해서 검색기를 실행할 수 있어요.

docs = lc_vector.similarity_search(
    "What do you know about Cratchitt family?",
    k=topEntities,
    params={
        "topChunks": topChunks,
        "topCommunities": topCommunities,
        "topOutsideRels": topOutsideRels,
        "topInsideRels": topInsideRels,
    },
)
# print(docs[0].page_content)

LlamaIndex를 사용해서 똑같은 검색 패턴을 구현할 수 있어요. LlamaIndex의 경우, Vector Index가 제대로 작동하려면 먼저 Node에 메타데이터를 추가해야 해요. 만약 Node에 기본 메타데이터가 추가되지 않으면 Vector Index는 에러를 뱉을 거예요.

# https://github.com/run-llama/llama_index/blob/main/llama-index-core/llama_index/core/vector_stores/utils.py#L32
from llama_index.core.schema import TextNode
from llama_index.core.vector_stores.utils import node_to_metadata_dict

content = node_to_metadata_dict(TextNode(), remove_text=True, flat_metadata=False)

db_query(
    """
  MATCH (e:__Entity__)
  SET e += $content""",
    {"content": content},
)

이번에도 LlamaIndex의 `retrieval_query` 기능을 사용해서 검색기를 정의할 수 있어요. LangChain과는 다르게, 쿼리 매개변수 대신 f-string을 사용해서 상위 후보 필터 매개변수를 전달하죠.

retrieval_query = f"""
WITH collect(node) as nodes
// Entity - Text Unit Mapping
WITH
nodes,
collect {{
    UNWIND nodes as n
    MATCH (n)<-[:HAS_ENTITY]->(c:__Chunk__)
    WITH c, count(distinct n) as freq
    RETURN c.text AS chunkText
    ORDER BY freq DESC
    LIMIT {topChunks}
}} AS text_mapping,
// Entity - Report Mapping
collect {{
    UNWIND nodes as n
    MATCH (n)-[:IN_COMMUNITY]->(c:__Community__)
    WITH c, c.rank as rank, c.weight AS weight
    RETURN c.summary 
    ORDER BY rank, weight DESC
    LIMIT {topCommunities}
}} AS report_mapping,
// Outside Relationships 
collect {{
    UNWIND nodes as n
    MATCH (n)-[r:RELATED]-(m) 
    WHERE NOT m IN nodes
    RETURN r.description AS descriptionText
    ORDER BY r.rank, r.weight DESC 
    LIMIT {topOutsideRels}
}} as outsideRels,
// Inside Relationships 
collect {{
    UNWIND nodes as n
    MATCH (n)-[r:RELATED]-(m) 
    WHERE m IN nodes
    RETURN r.description AS descriptionText
    ORDER BY r.rank, r.weight DESC 
    LIMIT {topInsideRels}
}} as insideRels,
// Entities description
collect {{
    UNWIND nodes as n
    RETURN n.description AS descriptionText
}} as entities
// We don't have covariates or claims here
RETURN "Chunks:" + apoc.text.join(text_mapping, '|') + "nReports: " + apoc.text.join(report_mapping,'|') +  
       "nRelationships: " + apoc.text.join(outsideRels + insideRels, '|') + 
       "nEntities: " + apoc.text.join(entities, "|") AS text, 1.0 AS score, nodes[0].id AS id, {{_node_type:nodes[0]._node_type, _node_content:nodes[0]._node_content}} AS metadata
"""

그리고 반환 값이 살짝 다르다는 점도 기억해야 해요. Node 타입과 콘텐츠를 메타데이터로 반환해야 하는데, 그렇지 않으면 리트리버가 제대로 작동하지 않을 거예요. 이제 Neo4j Vector Store를 인스턴스화하고, 이걸 쿼리 엔진으로 사용해 봅시다.

neo4j_vector = Neo4jVectorStore(
    NEO4J_USERNAME,
    NEO4J_PASSWORD,
    NEO4J_URI,
    embed_dim,
    index_name=index_name,
    retrieval_query=retrieval_query,
)
loaded_index = VectorStoreIndex.from_vector_store(neo4j_vector).as_query_engine(
    similarity_top_k=topEntities, embed_model=OpenAIEmbedding(model="text-embedding-3-large")
)

자, 이제 GraphRAG 로컬 검색기를 테스트해 볼까요?

response = loaded_index.query("What do you know about Scrooge?")
print(response.response)
#print(response.source_nodes[0].text)
# Scrooge is an employee who is impacted by the generosity and festive spirit 
# of the Fezziwig family, particularly Mr. and Mrs. Fezziwig. He is involved 
# in the memorable Domestic Ball hosted by the Fezziwigs, which significantly 
# influences his life and contributes to the broader narrative of kindness 
# and community spirit.

바로 떠오르는 생각 중 하나는, Vector Search만 하는 대신 관련 엔티티를 찾기 위해 하이브리드 접근 방식(Vector + 키워드)을 사용해서 로컬 검색을 더 향상시킬 수 있다는 점이에요.

글로벌 리트리버

The 글로벌 리트리버 아키텍처는 조금 더 간단해요. 지정된 계층 수준에서 모든 커뮤니티 요약을 반복해서 중간 요약을 만들고, 이 중간 요약을 바탕으로 최종 응답을 생성하는 방식인 것 같아요.

글로벌 리트리버 아키텍처. 이미지 출처:

어떤 계층 수준을 반복할지 미리 정해야 하는데, 뭐가 더 잘 작동할지 모르니까 간단한 결정은 아니에요. 계층적 수준이 높아질수록 커뮤니티 규모는 커지지만, 커뮤니티 수는 줄어들죠. 요약을 직접 검사하지 않고는 얻을 수 있는 유일한 정보이기도 하고요.

다른 파라미터를 사용하면 순위나 가중치 임계값 미만의 커뮤니티를 무시할 수도 있지만, 여기서는 사용하지 않을 거예요. LangChain을 사용해서 글로벌 검색기를 구현할 건데요, map and reduce 프롬프트는 GraphRAG 논문에 나온 것과 같아요. 시스템 프롬프트가 엄청 길어서 여기에 넣거나 체인 구성을 포함하진 않을 거예요. 하지만 모든 코드는 다음 notebook에서 확인할 수 있어요.

def global_retriever(query: str, level: int, response_type: str = response_type) -> str:
    community_data = graph.query(
        """
    MATCH (c:__Community__)
    WHERE c.level = $level
    RETURN c.full_content AS output
    """,
        params={"level": level},
    )
    intermediate_results = []
    for community in tqdm(community_data, desc="Processing communities"):
        intermediate_response = map_chain.invoke(
            {"question": query, "context_data": community["output"]}
        )
        intermediate_results.append(intermediate_response)
    final_response = reduce_chain.invoke(
        {
            "report_data": intermediate_results,
            "question": query,
            "response_type": response_type,
        }
    )
    return final_response

이제 한번 테스트해 볼까요?

print(global_retriever("What is the story about?", 2))

이 이야기는 주로 삶에 대한 냉소적인 견해를 가지고 크리스마스를 싫어하는 스크루지 영감님을 중심으로 펼쳐져요. 그의 변화는 죽은 동업자 제이콥 말리의 유령이 나타나면서 시작되고, 이어서 크리스마스 과거, 현재, 미래를 상징하는 세 유령이 나타나죠. 이 만남들을 통해 스크루지는 자신의 삶과 행동의 결과를 되돌아보게 되고, 결국 크리스마스 정신을 받아들이면서 엄청난 개인적인 성장을 경험하게 된답니다 [데이터: 보고서 (32, 17, 99, 86, +more)].

### Jacob Marley와 영혼의 역할

Jacob Marley의 유령은 초자연적인 촉매제 역할을 하면서 스크루지에게 다가올 세 영혼의 방문을 경고하죠. 각 영혼은 스크루지를 자기 발견의 여정으로 이끌면서 그의 선택이 미치는 영향과 연민의 중요성을 보여줘요. 영혼은 스크루지에게 그의 행동이 자신의 삶뿐만 아니라 다른 사람들의 삶에도 어떤 영향을 미치는지 보여주면서, 특히 구원과 상호 연결이라는 주제를 강조하고 있어요 [데이터: 보고서 (86, 17, 99, +more)].

### 스크루지의 관계와 변화

스크루지와 Cratchit 가족, 특히 Bob Cratchit과 그의 아들 Tiny Tim과의 관계는 그의 변화에 아주 중요한 역할을 해요. 영혼이 제시한 비전을 통해 스크루지는 공감 능력을 키우고 Cratchit 가족의 상황을 개선하기 위한 실질적인 조치를 취하도록 영감을 얻죠. 이야기는 스크루지의 새롭게 발견된 관대함이 그의 공동체 내에서 동정심과 사회적 책임을 키우기 때문에 개인의 행동이 사회에 중대한 영향을 미칠 수 있다는 점을 강조하고 있어요 [데이터: 보고서 (25, 158, 159, +more)].

### 구원과 희망의 주제

전반적으로 이 이야기는 시대를 초월하는 희망의 상징이며, 공감, 성찰, 개인적인 변화의 가능성과 같은 주제를 강조하고 있어요. 외로운 구두쇠에서 자비로운 인물로 변모한 스크루지의 여정은 변화하기에 너무 늦지 않았다는 것을 보여주죠. 작은 친절의 행동은 개인과 더 넓은 지역 사회에 상당한 긍정적인 영향을 미칠 수 있어요 [데이터: 보고서(32, 102, 126, 148, 158, 159, +more)].

요약하자면, 이 이야기는 크리스마스의 변혁적인 힘과 인간 관계의 중요성을 보여주면서 구원과 휴가 기간 동안 한 개인이 다른 사람에게 미칠 수 있는 영향에 대한 감동적인 이야기가 되는 것 같아요.

지정된 수준의 모든 커뮤니티를 반복하는 글로벌 검색기에 적합하기 때문에 응답은 매우 길고 철저하죠. 커뮤니티 계층 수준을 변경하면 응답이 어떻게 바뀌는지 테스트해 볼 수 있어요.

요약

이번 블로그 포스팅에서는 Microsoft의 GraphRAG를 Neo4j에 통합하고 LangChain 및 LlamaIndex를 사용하여 검색기를 구현하는 방법을 보여드렸어요. 이렇게 하면 GraphRAG를 다른 검색기 또는 에이전트와 원활하게 통합할 수 있죠. 로컬 검색기는 Vector Embedding 유사성 검색과 그래프 순회를 결합하는 반면, 글로벌 검색기는 커뮤니티 요약을 반복하여 포괄적인 응답을 생성해요. 이 구현은 향상된 정보 검색 및 질문 답변을 위해 구조화된 Knowledge Graph와 언어 모델을 결합하는 기능을 보여주고 있어요. 이러한 Knowledge Graph에는 사용자 정의 및 실험의 여지가 있다는 점을 기억하는 것이 중요해요. 이에 대해서는 다음 블로그 포스팅에서 살펴볼게요.

언제나 그렇듯이 코드는 에서 확인할 수 있어요.

  • GraphRAG
  • Microsoft
  • RAG

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

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

728x90
반응형