GraphRAG

Neo4j와 LangChain으로 구현하는 '로컬에서 글로벌로' GraphRAG: 그래프 구축하기

hsystems 2026. 5. 3. 10:46
728x90
반응형

RAG 정확도 향상을 위해 텍스트 추출, 네트워크 분석, LLM 프롬프트 및 요약을 결합합니다.

저는 종종 GraphRAG라고 불리는 그래프를 통한 Retrieval-Augmented Generation(RAG) 구현에 대한 새로운 접근 방식에 항상 흥미를 느껴요. 하지만 GraphRAG라는 용어를 들으면 모든 사람이 서로 다른 구현을 염두에 두는 것 같아요. 이번 블로그 게시물에서는 '로컬에서 글로벌로 GraphRAG' Microsoft 연구원의 기사 및 구현을 살펴볼게요. Knowledge Graph 구성 및 요약 부분을 다루고 검색기는 다음 블로그 게시물에 남겨 두겠습니다. 연구원들이 코드 저장소를 제공해줘서 정말 고마워요. 프로젝트 페이지도 있답니다.

위에서 언급한 기사에서 취한 접근 방식은 정말 흥미로워요. 제가 알기로는, 여러 소스의 정보를 압축하고 결합하기 위한 파이프라인의 한 단계로 Knowledge Graph를 사용하는 것이 포함되는데요. 텍스트에서 entity와 relationship을 추출하는 것은 새로운 것은 아니죠. 하지만 저자들은 압축된 그래프 구조와 정보를 다시 자연어 텍스트로 요약하는 참신한(적어도 저에게는) 아이디어를 소개해요. 파이프라인은 그래프를 생성하기 위해 처리되는 문서의 입력 텍스트로 시작돼요. 그런 다음 그래프는 다시 자연어 텍스트로 변환되죠. 생성된 텍스트에는 이전에 여러 문서에 분산된 특정 entity 또는 그래프 커뮤니티에 대한 요약 정보가 포함되어 있어요.

정말 간단하게 말하면, GraphRAG 파이프라인에 대한 입력은 다양한 정보가 포함된 소스 문서예요. 문서는 LLM을 사용하여 처리되어 해당 relationship과 함께 문서에 나타나는 entity에 대한 구조화된 정보를 추출하죠. 이렇게 추출된 구조화된 정보는 Knowledge Graph를 구성하는 데 사용돼요.

Knowledge Graph 데이터 표현을 사용하면 특정 entity에 대한 여러 문서나 데이터 소스의 정보를 빠르고 직접적으로 결합할 수 있다는 장점이 있어요. 앞서 언급했듯이 Knowledge Graph가 유일한 데이터 표현은 아니에요. Knowledge Graph가 구성된 후 그래프 알고리즘과 LLM 프롬프트의 조합을 사용하여 Knowledge Graph에서 발견된 entity 커뮤니티의 자연어 요약을 생성해요.

이러한 요약에는 특정 entity 및 커뮤니티에 대한 여러 데이터 소스와 문서에 분산된 요약 정보가 포함되어 있답니다.

파이프라인을 더 자세히 이해하려면 원본 문서에 제공된 단계별 설명을 참조하세요.

Neo4j와 LangChain을 사용해서 이 접근 방식을 재현하는 데 사용할 파이프라인을 간단하게 요약해볼게요.

Indexing - 그래프 생성

  • 소스 문서를 텍스트 덩어리로: 소스 문서를 처리하기 쉽도록 더 작은 텍스트 덩어리로 나눠요.
  • 요소 인스턴스에 대한 텍스트 청크: 각 텍스트 청크를 분석해서 엔터티와 관계를 추출하고, 이 요소들을 나타내는 튜플 목록을 만들어요.
  • 요소 인스턴스에서 요소 요약까지: 추출된 항목과 관계는 LLM을 사용해서 각 요소에 대한 설명 텍스트 블록으로 요약돼요.
  • 그래프 커뮤니티에 대한 요소 요약: 이 엔터티 요약들은 그래프를 형성하고, Leiden 같은 알고리즘을 사용해서 커뮤니티로 분할되죠.
  • 커뮤니티를 커뮤니티 요약으로 그래프 표시: 데이터 세트의 전체적인 주제 구조와 의미를 이해하기 위해 LLM을 통해 각 커뮤니티의 요약이 생성돼요.

Retrieval - 응답

  • 글로벌 답변에 대한 커뮤니티 요약: 커뮤니티 요약은 중간 답변을 생성해서 사용자 쿼리에 응답하는 데 사용되고, 이 답변은 최종 글로벌 답변으로 합쳐져요.

코드를 바로 사용할 수는 없지만, 구현이 완료되었으니 사용되는 기본적인 접근 방식이나 LLM 프롬프트에 약간의 차이가 있을 수 있어요. 진행하면서 이런 차이점을 설명해 드릴게요.

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

Neo4j 환경 설정

Neo4j를 기본 그래프 저장소로 사용할 거예요. 시작하는 가장 쉬운 방법은 무료 인스턴스를 사용하는 건데요, Neo4j 샌드박스는 Graph Data Science 플러그인이 설치된 Neo4j 데이터베이스의 클라우드 인스턴스를 제공해줘요. 아니면 Neo4j 데스크탑 애플리케이션을 다운로드해서 Neo4j 데이터베이스의 로컬 인스턴스를 설정할 수도 있어요. 로컬 버전을 사용하는 경우에는 APOC 및 GDS 플러그인을 모두 설치해야 해요. 프로덕션 환경에서는 GDS 플러그인을 제공하는 유료 관리형 AuraDS(데이터 과학) 인스턴스를 사용할 수 있죠.

저희는 Neo4j그래프를 사용할 건데요, 이건 LangChain에 추가한 편리한 래퍼예요.

from langchain_community.graphs import Neo4jGraph

os.environ["NEO4J_URI"] = "bolt://44.202.208.177:7687"
os.environ["NEO4J_USERNAME"] = "neo4j"
os.environ["NEO4J_PASSWORD"] = "mast-codes-trails"

graph = Neo4jGraph(refresh_schema=False)

데이터세트

이번에는 제가 얼마 전에 만든 뉴스 기사 데이터 세트를 사용할 거예요. Diffbot의 API를 이용했죠. 재사용하기 편하도록 GitHub에 올려놨어요.

news = pd.read_csv(
    "https://raw.githubusercontent.com/tomasonjo/blog-datasets/main/news_articles.csv"
)
news["tokens"] = [
    num_tokens_from_string(f"{row['title']} {row['text']}")
    for i, row in news.iterrows()
]
news.head()

데이터세트의 처음 몇 줄을 한번 살펴볼까요?

여기에는 tiktoken 라이브러리를 사용해서 계산한 토큰 수와 함께, 게시 날짜와 기사 제목, 텍스트가 들어있어요.

텍스트 청킹

텍스트 청킹 단계는 정말 중요하고, 다운스트림 결과에 큰 영향을 미친답니다. 논문 저자들은 텍스트 덩어리를 작게 만들수록 더 많은 항목을 추출할 수 있다는 걸 발견했어요.

보시다시피, 2,400개 토큰의 텍스트 청크를 사용하면 600개 토큰을 사용할 때보다 추출되는 엔티티 수가 줄어들어요. 또한 LLM이 첫 번째 실행에서 모든 엔티티를 추출하지 못할 수도 있다는 점도 확인했죠. 이 경우 추출을 여러 번 수행하는 휴리스틱을 도입할 거예요. 이에 대해서는 다음 섹션에서 자세히 알아볼게요.

하지만 항상 절충점이 있기 마련이죠. 더 작은 텍스트 청크를 사용하면 문서 전체에 분산된 특정 엔티티의 컨텍스트와 상호 참조가 손실될 수 있어요. 예를 들어 문서에서 "John"과 "he"가 별도의 문장으로 언급된 경우, 텍스트를 더 작은 덩어리로 나누면 "he"가 John을 가리키는지 명확하지 않을 수 있죠. 일부 상호 참조 문제는 중첩 텍스트 청킹 전략을 사용하여 해결할 수 있지만, 모든 문제가 해결되는 건 아니에요.

기사 텍스트의 크기를 한번 살펴볼까요?

sns.histplot(news["tokens"], kde=False)
plt.title('Distribution of chunk sizes')
plt.xlabel('Token count')
plt.ylabel('Frequency')
plt.show()

기사 토큰 수 분포를 보니 거의 정규 분포를 따르고 최댓값이 약 400개 토큰 정도 되네요. 청크 빈도는 이 최고점까지 점점 증가하다가 대칭적으로 감소하는 걸 확인할 수 있어요. 이는 대부분의 텍스트 청크가 400 토큰 근처에 있다는 걸 보여주죠.

이 분포 때문에 상호 참조 문제를 피하려고 여기서는 텍스트 청크를 따로 진행하지 않았어요. 기본적으로 GraphRAG 프로젝트는 청크 크기를 300개 토큰으로 설정하고 100개 토큰을 겹치게 설정해 뒀답니다.

Nodes 및 Relationships 추출

다음 단계는 텍스트 청크에서 지식을 구성하는 거예요. 이번 사용 사례에서는 LLM을 사용해서 텍스트에서 Nodes 및 Relationships 형태로 구조화된 정보를 추출할 건데요. 여러분은 LLM 프롬프트를 논문에 사용된 저자와 같이 사용할 수 있어요. 필요한 경우, Node 레이블을 미리 정의할 수 있는 LLM 프롬프트도 있지만 기본적으로는 선택 사항이에요. 또한 원본 문서에서 추출된 Relationships에는 유형이 없고 설명만 있더라고요. 제 생각에는 LLM이 Relationship으로서 더 풍부하고 미묘한 정보를 추출하고 유지할 수 있도록 하기 위한 선택인 것 같아요. 하지만 Relationship 유형 지정(설명은 속성에 넣을 수 있겠죠!) 없이 깔끔한 Knowledge Graph를 갖기는 어렵겠죠?

저희 구현에서는 LLMGraphTransformer를 사용했어요. LangChain 라이브러리에서 사용할 수 있는 기능이죠. 기사 문서 구현처럼 순수한 Prompt Engineering을 사용하는 대신, LLMGraphTransformer는 지원을 호출하는 내장 함수를 사용해서 구조화된 정보(LangChain의 구조화된 출력 LLM)를 추출해요. 시스템 프롬프트를 한번 살펴보세요:

from langchain_experimental.graph_transformers import LLMGraphTransformer
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(temperature=0, model_name="gpt-4o")

llm_transformer = LLMGraphTransformer(
  llm=llm, 
  node_properties=["description"],
  relationship_properties=["description"]
)

def process_text(text: str) -> List[GraphDocument]:
    doc = Document(page_content=text)
    return llm_transformer.convert_to_graph_documents([doc])

이 예시에서는 그래프 추출에 GPT-4o를 사용하고 있어요. 작성자는 LLM에게 구체적으로 엔터티, 관계, 설명 추출을 지시하죠. LangChain 구현을 통해 node_propertiesrelationship_properties를 사용해서 LLM이 추출할 node 또는 relationship 속성을 지정할 수 있어요.

LLMGraphTransformer 구현과의 차이점은 모든 node 또는 relationship 속성이 선택 사항이라는 점이에요. 그래서 모든 node가 description property를 가지는 건 아니죠. 원한다면 필수 추출을 포함하도록 사용자 정의 추출을 정의해서 description 속성을 필수로 만들 수도 있지만, 이 구현에서는 건너뛸게요.

그래프 추출 속도를 높이고 결과를 Neo4j에 저장하기 위해 요청을 병렬화하고 있어요.

MAX_WORKERS = 10
NUM_ARTICLES = 2000
graph_documents = []

with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
    # Submitting all tasks and creating a list of future objects
    futures = [
        executor.submit(process_text, f"{row['title']} {row['text']}")
        for i, row in news.head(NUM_ARTICLES).iterrows()
    ]

    for future in tqdm(
        as_completed(futures), total=len(futures), desc="Processing documents"
    ):
        graph_document = future.result()
        graph_documents.extend(graph_document)

graph.add_graph_documents(
    graph_documents,
    baseEntityLabel=True,
    include_source=True
)

이 예시에서는 2,000개의 기사에서 그래프 정보를 추출하고 결과를 Neo4j에 저장하고 있어요. 약 13,000개의 엔터티와 16,000개의 관계를 추출했죠. 다음은 그래프에서 추출된 문서의 예시입니다.

The document (blue) points to extracted entities and relationships
문서(파란색)는 추출된 엔터티 및 관계를 가리킵니다.

추출을 완료하는 데 약 35(+/- 5)분이 걸리고, GPT-4o를 사용하는 경우 비용은 약 $30 정도 들어요.

이 단계에서 작성자는 두 번 이상의 패스에서 그래프 정보를 추출할지 여부를 결정하기 위해 휴리스틱을 도입하고 있어요. 단순화를 위해 한 번의 패스만 수행할게요. 하지만 여러 패스를 수행하려는 경우 첫 번째 추출 결과를 대화 기록으로 두고 간단히 많은 엔터티가 누락된 LLM에게 더 많은 것을 추출해야 한다고 지시할 수 있어요. GraphRAG 작성자처럼요.

이전에는 텍스트 청크 크기가 얼마나 중요한지, 그리고 이것이 추출된 엔터티 수에 어떤 영향을 미치는지 언급했었죠. 추가 텍스트 청크를 수행하지 않았으므로 텍스트 청크 크기를 기준으로 추출된 항목의 분포를 평가해 볼 수 있어요.

entity_dist = graph.query(
    """
MATCH (d:Document)
RETURN d.text AS text,
       count {(d)-[:MENTIONS]->()} AS entity_count
"""
)
entity_dist_df = pd.DataFrame.from_records(entity_dist)
entity_dist_df["token_count"] = [
    num_tokens_from_string(str(el)) for el in entity_dist_df["text"]
]
# Scatter plot with regression line
sns.lmplot(
    x="token_count",
    y="entity_count",
    data=entity_dist_df, 
    line_kws={"color": "red"}
)
plt.title("Entity Count vs Token Count Distribution")
plt.xlabel("Token Count")
plt.ylabel("Entity Count")
plt.show()

산점도를 보면 빨간색 선으로 표시된 양의 추세가 보이긴 하지만, 관계가 하위 선형이라는 걸 알 수 있어요. 토큰 수가 증가해도 대부분의 데이터 포인트는 더 낮은 엔티티 수로 몰려있죠. 이는 추출된 엔티티 수가 텍스트 청크 크기에 비례해서 늘어나지 않는다는 걸 나타내요. 일부 이상값이 있긴 하지만, 일반적으로 토큰 수가 많다고 해서 엔티티 수가 계속 늘어나진 않아요. 텍스트 청크 크기가 작을수록 더 많은 정보를 추출한다는 저자의 발견을 뒷받침하는 내용이죠.

구성된 그래프의 `Node` 차수 분포를 살펴보는 것도 흥미로울 것 같아요. 다음 코드는 `Node` 차수 분포를 검색하고 시각화하는 코드예요.

degree_dist = graph.query(
    """
MATCH (e:__Entity__)
RETURN count {(e)-[:!MENTIONS]-()} AS node_degree
"""
)
degree_dist_df = pd.DataFrame.from_records(degree_dist)

# 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 차수 분포는 멱함수 패턴을 따르는데, 이는 대부분의 Node가 연결 수가 매우 적은 반면 일부 Node는 연결 정도가 높다는 것을 나타내요. 평균 차수는 2.45, 중앙값은 1.00으로 절반 이상의 Node에 연결이 하나만 있다는 것을 나타내죠. 대부분의 Node(75%)에는 2개 이하의 연결이 있고 90%는 5개 이하의 연결이 있어요. 이러한 분포는 소수의 허브에 많은 연결이 있고 대부분의 Node에는 연결이 거의 없는 실제 네트워크의 일반적인 현상이에요.

Node와 Relationship 설명은 모두 필수 속성이 아니니까, 추출된 개수도 한번 살펴볼까요?

graph.query("""
MATCH (n:`__Entity__`)
RETURN "node" AS type,
       count(*) AS total_count,
       count(n.description) AS non_null_descriptions
UNION ALL
MATCH (n)-[r:!MENTIONS]->()
RETURN "relationship" AS type,
       count(*) AS total_count,
       count(r.description) AS non_null_descriptions
""")

결과는 12,994개 Node 중 5,926개(45.6%) Node에 description 속성이 있는 것으로 나타났어요. 반면에 15,921개 Relationship 중 5,569개(35%)만이 그러한 속성을 가지고 있네요.

LLM의 확률적 특성으로 인해 숫자는 실행 및 소스 데이터, LLM, 프롬프트에 따라 달라질 수 있다는 점 참고해주세요.

Entity Resolution

Entity Resolution(중복 제거)은 Knowledge Graph를 구성할 때 각 Entity가 고유하고 정확하게 표현되도록 보장하고 중복을 방지하고 동일한 실제 Entity를 참조하는 레코드를 병합하므로 중요해요. 이 프로세스는 그래프 내에서 데이터 무결성과 일관성을 유지하는 데 필수적이죠. Entity Resolution이 없으면 Knowledge Graph는 조각화되고 일관되지 않은 데이터로 인해 오류가 발생하고 신뢰할 수 없는 통찰력을 얻게 될 거예요.

이 이미지는 하나의 실제 엔티티가 여러 문서에서, 그리고 결과적으로 그래프에서 약간 다른 이름으로 표현될 수 있는 상황을 보여주고 있어요.

게다가 희소 데이터는 엔티티 해결 없이는 정말 큰 문제가 될 수 있어요. 다양한 소스의 불완전하거나 부분적인 데이터 때문에 정보가 여기저기 흩어지고 연결이 끊겨서, 각 엔티티에 대한 일관성 있고 포괄적인 이해를 하기가 어렵게 되죠. 정확한 엔티티 식별은 데이터를 통합하고, 빈 곳을 채우고, 각 엔티티에 대한 통합된 뷰를 생성해서 이 문제를 해결해 준답니다.

시각화의 왼쪽 부분은 희박하고 연결되지 않은 그래프를 나타내요. 하지만 오른쪽에서 볼 수 있듯이, 이러한 그래프는 효율적인 엔터티 해결과 잘 연결될 수 있죠.

전반적으로 엔터티 확인은 데이터 검색 및 통합의 효율성을 향상시켜 다양한 소스에 걸쳐 정보에 대한 응집력 있는 보기를 제공해요. 궁극적으로 신뢰할 수 있고 완전한 Knowledge Graph를 기반으로 보다 효과적인 질의응답을 가능하게 합니다.

안타깝게도 GraphRAG 논문의 저자는 논문에서 언급은 했지만, 저장소에 엔터티 확인 코드를 포함하지 않았어요. 이 코드를 생략한 이유는 특정 도메인에 대해 강력하고 성능이 좋은 엔터티 확인을 구현하기가 어렵기 때문일 수 있어요. 사전 정의된 유형의 Node를 처리할 때 다양한 Node에 대한 사용자 정의 휴리스틱을 구현할 수 있죠 (사전 정의되지 않은 경우 회사, 조직, 비즈니스 등과 같이 일관성이 충분하지 않아요). 하지만 우리의 경우처럼 Node Label이나 유형을 미리 알 수 없다면 이는 더욱 어려운 문제가 돼요. 그럼에도 불구하고 우리는 텍스트 임베딩 및 그래프 알고리즘을 단어 거리 및 LLM과 결합하여 프로젝트에서 엔터티 해결 버전을 구현할 거예요.

법인 해결 프로세스에는 다음 단계가 포함돼요.

  1. Graph의 항목 — Graph 내의 모든 항목부터 시작해요.
  2. K-최근접 Graph — 텍스트 임베딩을 기반으로 유사한 엔터티를 연결하는 k-최근접 이웃 Graph를 구성합니다.
  3. 약하게 연결된 구성 요소 — k-가장 가까운 Graph에서 약하게 연결된 구성 요소를 식별하여 유사할 가능성이 있는 엔터티를 그룹화합니다. 이러한 구성 요소가 식별된 후 단어 거리 필터링 단계를 추가합니다.
  4. LLM 평가 — LLM을 사용하여 이러한 구성 요소를 평가하고 각 구성 요소 내의 엔터티를 병합해야 하는지 여부를 결정하여 엔터티 해결에 대한 최종 결정을 내립니다(예: 'Silicon Valley Bank'와 'Silicon_Valley_Bank'를 병합하고 '2023년 9월 16일' 및 '2023년 9월 2일'과 같은 다른 날짜에 대한 병합을 거부).

먼저 엔터티의 이름 및 설명 속성에 대한 텍스트 임베딩을 계산합니다. 우리는 from_existing_graph의 방법Neo4jVector이를 달성하기 위해 LangChain에 통합합니다.

이러한 임베딩을 사용하여 이러한 임베딩의 코사인 거리를 기반으로 유사한 잠재적 후보를 찾을 수 있어요. 우리는 다음에서 사용 가능한 그래프 알고리즘을 사용할 거예요.Graph Data Science(GDS) 라이브러리; 그러므로 우리는GDS Python 클라이언트Python 방식으로 쉽게 사용할 수 있도록 다음을 수행합니다.

from graphdatascience import GraphDataScience

gds = GraphDataScience(
    os.environ["NEO4J_URI"],
    auth=(os.environ["NEO4J_USERNAME"], os.environ["NEO4J_PASSWORD"])
)

GDS 라이브러리에 익숙하지 않은 경우 Graph 알고리즘을 실행하기 전에 먼저 메모리 내 Graph를 투영해야 합니다.

첫째, Neo4j에 저장된 그래프는 더 빠른 처리 및 분석을 위해 인메모리 그래프에 투영됩니다. 다음으로, 메모리 내 그래프에 그래프 알고리즘이 실행되는 거죠. 선택적으로 알고리즘 결과를 Neo4j 데이터베이스에 다시 저장할 수도 있어요. 이에 대한 자세한 내용은 선적 서류 비치에서 확인해보세요.

k-최근접 이웃 그래프를 생성하기 위해 텍스트 Vector Embedding과 함께 모든 엔터티를 투영합니다.

G, result = gds.graph.project(
    "entities",                   # Graph name
    "__Entity__",                 # Node projection
    "*",                          # Relationship projection
    nodeProperties=["embedding"]  # Configuration parameters
)

이제 그래프가 아래에 투영되었으므로 entities 이름으로 그래프 알고리즘을 실행할 수 있어요. 우리는 k-Nearest Neighbor 그래프를 사용할 거예요. k-Nearest Neighbor 그래프의 희소성 또는 밀도에 영향을 미치는 가장 중요한 두 가지 매개변수는 similarityCutoff and topK입니다. topK는 각 Node에 대해 찾을 이웃의 수이며 최소값은 1이에요. similarityCutoff는 이 임계값 미만의 유사성을 갖는 Relationship을 필터링하죠. 여기서는 기본값을 사용해볼게요. topK는 10이고 similarity cutoff가 0.95로 상대적으로 높습니다. 0.95와 같은 높은 similarity cutoff를 사용하면 매우 유사한 쌍만 일치 항목으로 간주되어 거짓 긍정을 최소화하고 정확도를 향상시킵니다.

우리는 Knowledge Graph 대신에 투영된 인 메모리 그래프에 결과를 다시 저장하고 싶기 때문에 mutate 알고리즘 모드를 사용할 거예요:

similarity_threshold = 0.95

gds.knn.mutate(
  G,
  nodeProperties=['embedding'],
  mutateRelationshipType= 'SIMILAR',
  mutateProperty= 'score',
  similarityCutoff=similarity_threshold
)

다음 단계는 새로 추론된 유사성 관계와 연결된 엔터티 그룹을 식별하는 것이에요. 연결된 Node 그룹을 식별하는 것은 네트워크 분석에서 흔히 수행되는 프로세스인데, 이걸 또는 클러스터링이라고 부르죠. 조밀하게 연결된 Node의 하위 그룹을 찾는 것과 관련이 있어요. 이 예에서는 약하게 연결된 구성요소 알고리즘을 사용하는데, 연결 방향을 무시하더라도 그래프에서 모든 Node가 연결된 부분을 찾는 데 도움이 된답니다.

결과를 다시 데이터베이스에 저장하기 위해 write 모드의 알고리즘을 사용할 거예요 (저장된 그래프):

gds.wcc.write(
    G,
    writeProperty="wcc",
    relationshipTypes=["SIMILAR"]
)

텍스트 포함 비교는 잠재적인 중복 항목을 찾는 데 도움이 되지만, 엔터티 해결 프로세스의 일부일 뿐이에요. 예를 들어 Google과 Apple은 임베딩 공간에서 매우 가깝죠 (0.96 코사인 유사도 사용, ada-002 임베딩 모델). BMW와 Mercedes Benz도 마찬가지고요 (코사인 유사도 0.97). 높은 텍스트 임베딩 유사성은 좋은 시작이지만, 이를 개선할 수 있어요. 따라서 텍스트 거리가 3 이하인 단어 쌍만 허용하는 추가 필터를 추가할 거예요 (문자만 변경할 수 있다는 의미).

word_edit_distance = 3
potential_duplicate_candidates = graph.query(
    """MATCH (e:`__Entity__`)
    WHERE size(e.id) > 3 // longer than 3 characters
    WITH e.wcc AS community, collect(e) AS nodes, count(*) AS count
    WHERE count > 1
    UNWIND nodes AS node
    // Add text distance
    WITH distinct
      [n IN nodes WHERE apoc.text.distance(toLower(node.id), toLower(n.id)) < $distance 
                  OR node.id CONTAINS n.id | n.id] AS intermediate_results
    WHERE size(intermediate_results) > 1
    WITH collect(intermediate_results) AS results
    // combine groups together if they share elements
    UNWIND range(0, size(results)-1, 1) as index
    WITH results, index, results[index] as result
    WITH apoc.coll.sort(reduce(acc = result, index2 IN range(0, size(results)-1, 1) |
            CASE WHEN index <> index2 AND
                size(apoc.coll.intersection(acc, results[index2])) > 0
                THEN apoc.coll.union(acc, results[index2])
                ELSE acc
            END
    )) as combinedResult
    WITH distinct(combinedResult) as combinedResult
    // extra filtering
    WITH collect(combinedResult) as allCombinedResults
    UNWIND range(0, size(allCombinedResults)-1, 1) as combinedResultIndex
    WITH allCombinedResults[combinedResultIndex] as combinedResult, combinedResultIndex, allCombinedResults
    WHERE NOT any(x IN range(0,size(allCombinedResults)-1,1)
        WHERE x <> combinedResultIndex
        AND apoc.coll.containsAll(allCombinedResults[x], combinedResult)
    )
    RETURN combinedResult
    """, params={'distance': word_edit_distance})

이 Cypher 설명은 조금 복잡해서 이 블로그 게시물에서 자세히 다루기는 어려울 것 같아요. 언제든지 LLM에게 설명을 요청할 수 있다는 점 참고해주세요.

단어 거리 컷오프는 단순한 숫자가 아니라 단어 길이의 함수로 설정하면 구현이 더 확장 가능해질 수 있어요.

중요한 점은, 이제 병합하려는 잠재적인 엔터티 그룹이 출력된다는 거예요. 다음은 병합할 수 있는 `node` 목록입니다.

 {'combinedResult': ['Sinn Fein', 'Sinn Féin']},
 {'combinedResult': ['Government', 'Governments']},
 {'combinedResult': ['Unreal Engine', 'Unreal_Engine']},
 {'combinedResult': ['March 2016', 'March 2020', 'March 2022', 'March_2023']},
 {'combinedResult': ['Humana Inc', 'Humana Inc.']},
 {'combinedResult': ['New York Jets', 'New York Mets']},
 {'combinedResult': ['Asia Pacific', 'Asia-Pacific', 'Asia_Pacific']},
 {'combinedResult': ['Bengaluru', 'Mangaluru']},
 {'combinedResult': ['U.S. Securities And Exchange Commission',
   'Us Securities And Exchange Commission']},
 {'combinedResult': ['Jp Morgan', 'Jpmorgan']},
 {'combinedResult': ['Brighton', 'Brixton']},

보시다시피, 우리의 접근 방식이 모든 종류의 `Node`에 다 잘 작동하는 건 아니에요. 잠깐 살펴보면 사람이나 조직에는 더 잘 맞는 것 같고, 날짜에는 꽤 안 좋은 것 같죠? 미리 정의된 `Node` 유형을 사용하면 다양한 `Node` 유형에 대해 서로 다른 경험적 방법을 준비할 수 있어요. 이 예에서는 미리 정의된 `Node` 라벨이 없으므로 `Large Language Model`을 통해 항목 병합 여부에 대한 최종 결정을 내릴 거예요.

자, 그럼 `Node` 병합에 관한 최종 결정을 효과적으로 안내하고 알리기 위해 `Large Language Model` `Prompt`를 만들어봐야겠죠?

system_prompt = """You are a data processing assistant. Your task is to identify duplicate entities in a list and decide which of them should be merged.
The entities might be slightly different in format or content, but essentially refer to the same thing. Use your analytical skills to determine duplicates.

Here are the rules for identifying duplicates:
1. Entities with minor typographical differences should be considered duplicates.
2. Entities with different formats but the same content should be considered duplicates.
3. Entities that refer to the same real-world object or concept, even if described differently, should be considered duplicates.
4. If it refers to different numbers, dates, or products, do not merge results
"""
user_template = """
Here is the list of entities to process:
{entities}

Please identify duplicates, merge them, and provide the merged list.
"""

저는 항상 with_structured_output을 사용하는 걸 좋아해요. 출력을 수동으로 파싱할 필요가 없도록 구조화된 데이터 출력을 기대할 때 LangChain의 메서드죠.

여기서는 출력을 다음과 같은 list of lists로 정의할게요. 여기서 각 내부 목록에는 병합해야 하는 엔터티가 포함되어 있어요. 이 구조는 예를 들어 입력이 다음과 같은 시나리오를 처리하는 데 사용돼요. [Sony, Sony Inc, Google, Google Inc]. 이러한 경우 'Google' 및 'Google Inc'와 별도로 'Sony'와 'Sony Inc'를 병합하는 게 좋겠죠.

class DuplicateEntities(BaseModel):
    entities: List[str] = Field(
        description="Entities that represent the same object or real-world entity and should be merged"
    )


class Disambiguate(BaseModel):
    merge_entities: Optional[List[DuplicateEntities]] = Field(
        description="Lists of entities that represent the same object or real-world entity and should be merged"
    )


extraction_llm = ChatOpenAI(model_name="gpt-4o").with_structured_output(
    Disambiguate
)

다음으로 `Large Language Model` `Prompt`를 구조화된 출력과 통합해서 LCEL(LangChain Expression Language) 구문을 사용해서 체인을 생성하고, 이를 disambiguate 기능으로 캡슐화할 거예요.

extraction_chain = extraction_prompt | extraction_llm


def entity_resolution(entities: List[str]) -> Optional[List[List[str]]]:
    return [
        el.entities
        for el in extraction_chain.invoke({"entities": entities}).merge_entities
    ]

우리는 모든 잠재적 후보 `Node`를 entity_resolution 기능을 통해 실행해서 병합할지 여부를 결정해야 해요. 프로세스 속도를 높이기 위해 `Large Language Model` 호출을 다시 병렬화할게요.

merged_entities = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
    # Submitting all tasks and creating a list of future objects
    futures = [
        executor.submit(entity_resolution, el['combinedResult'])
        for el in potential_duplicate_candidates
    ]

    for future in tqdm(
        as_completed(futures), total=len(futures), desc="Processing documents"
    ):
        to_merge = future.result()
        if to_merge:
            merged_entities.extend(to_merge)

엔터티 해결의 마지막 단계는 entity_resolution LLM의 결과를 가져와서, 지정된 nodes를 병합해서 Graph Database에 다시 쓰는 거예요.

graph.query("""
UNWIND $data AS candidates
CALL {
  WITH candidates
  MATCH (e:__Entity__) WHERE e.id IN candidates
  RETURN collect(e) AS nodes
}
CALL apoc.refactor.mergeNodes(nodes, {properties: {
    description:'combine',
    `.*`: 'discard'
}})
YIELD node
RETURN count(*)
""", params={"data": merged_entities})

이 엔터티 해결 방법이 완벽하진 않지만, 개선할 수 있는 출발점을 제공한다는 점이 중요해요. 어떤 엔터티를 유지해야 할지 결정하는 로직을 개선하는 것도 좋겠죠?

요소 요약

다음 단계에서 작성자는 요소 요약 단계를 수행하는데요. 기본적으로 모든 node와 relationship은 엔터티 요약 프롬프트를 사용해요. 저자는 이 접근 방식이 참신하고 흥미롭다고 언급하고 있어요.

"전반적으로, 잠재적으로 잡음이 많은 그래프 구조에서 동질적인 nodes에 대한 풍부한 설명 텍스트를 사용하는 것은 LLM의 기능과 전역 쿼리 중심 요약의 요구 사항 모두에 부합합니다. 이러한 특성은 또한 다운스트림 추론 작업을 위해 간결하고 일관된 지식 트리플(주제, 술어, 객체)에 의존하는 일반적인 Knowledge Graph와 그래프 인덱스를 차별화합니다."

아이디어가 정말 흥미롭죠? 우리는 여전히 텍스트에서 주체 및 개체 ID 또는 이름을 추출하기 때문에, 엔터티가 여러 텍스트 덩어리에 걸쳐 나타나는 경우에도 올바른 엔터티에 relationship을 연결할 수 있어요. 하지만 relationship은 단일 유형으로 축소되지 않아요. 대신 relationship 유형은 실제로 더 풍부하고 미묘한 정보를 유지할 수 있는 자유 형식 텍스트가 될 수 있다는 점!

게다가 엔터티 정보는 LLM을 사용해서 요약되기 때문에, 더 정확한 검색을 위해 이 정보와 엔터티를 보다 효율적으로 포함하고 인덱싱할 수 있어요.

임의의 node 및 relationship 속성을 추가해서 이러한 더 풍부하고 미묘한 정보를 유지할 수도 있다고 주장할 수도 있겠죠. 하지만 임의 node 및 relationship 속성의 한 가지 문제는 LLM이 다른 속성 이름을 사용하거나 모든 실행에서 다양한 세부 정보에 집중할 수 있기 때문에 정보를 일관되게 추출하기 어려울 수 있다는 점이에요.

이러한 문제 중 일부는 추가 유형 및 설명 정보와 함께 미리 정의된 속성 이름을 사용해서 해결될 수 있어요. 이 경우 해당 속성을 정의하는 데 도움을 줄 해당 분야 전문가가 필요하므로 LLM이 사전 정의된 설명 이외의 중요한 정보를 추출할 여지가 거의 없어요.

Knowledge Graph에 더욱 풍부한 정보를 표시하는 것은 정말 흥미로운 접근 방식인 것 같아요.

요소 요약 단계의 한 가지 잠재적인 문제는 그래프의 모든 엔터티와 relationship에 대해 LLM 호출이 필요하기 때문에 확장이 잘 되지 않는다는 거예요. 우리의 그래프는 13,000개의 nodes와 16,000개의 relationships로 비교적 작은 편인데요. 이런 작은 그래프의 경우에도 29,000개의 LLM 호출이 필요하고, 각 호출에는 몇 백 개의 토큰이 사용되므로 상당히 비용이 많이 들고 시간이 오래 걸려요. 따라서 여기서는 이 단계를 생략하고, 초기 텍스트 처리 중에 추출된 설명 속성을 계속 사용할 수 있어요.

커뮤니티 구성 및 요약

그래프 구성 및 인덱스 프로세스의 마지막 단계에는 그래프 내에서 커뮤니티를 식별하는 작업이 포함돼요. 여기서 커뮤니티는 그래프의 나머지 부분보다 서로 더 촘촘하게 연결된 nodes 그룹으로, 더 높은 수준의 상호 작용 또는 유사성을 나타내죠. 다음 시각화는 커뮤니티 감지 결과의 예를 보여줍니다.

이러한 엔터티 커뮤니티가 클러스터링 알고리즘으로 식별되면 LLM은 각 커뮤니티에 대한 요약을 생성해서 개별 특성과 관계에 대한 통찰력을 제공해요.

이번에도 Graph Data Science 라이브러리를 사용하는데요. 먼저 인메모리 그래프를 투영하는 것부터 시작해볼게요. 원본 기사를 정확하게 따르기 위해 엔터티 그래프를 무방향 가중치 네트워크로 투영할 거예요. 여기서 네트워크는 두 엔터티 간의 연결 수를 나타내죠.

G, result = gds.graph.project(
    "communities",  #  Graph name
    "__Entity__",  #  Node projection
    {
        "_ALL_": {
            "type": "*",
            "orientation": "UNDIRECTED",
            "properties": {"weight": {"property": "*", "aggregation": "COUNT"}},
        }
    },
)

저자들은 Leiden algorithm, 즉 그래프 내에서 커뮤니티를 식별하기 위한 계층적 클러스터링 방법을 사용했어요. 계층적 커뮤니티 검색 알고리즘을 사용하면 여러 수준의 세분성에서 커뮤니티를 검사할 수 있다는 장점이 있죠. 저자는 각 수준의 모든 커뮤니티를 요약해서 그래프 구조에 대한 포괄적인 이해를 제공할 것을 제안하고 있어요.

먼저 WCC(Weakly Connected Components) 알고리즘을 사용해서 그래프의 연결성을 평가해볼게요. 이 알고리즘은 그래프 내에서 격리된 섹션을 식별하는데요. 즉, 그래프의 나머지 부분에는 연결되어 있지 않지만 서로 연결되어 있는 Node 또는 구성 요소의 하위 집합을 감지하는 거죠. 이러한 구성 요소는 네트워크 내의 단편화를 이해하고 다른 Node와 독립적인 Node 그룹을 식별하는 데 도움이 돼요. WCC는 그래프의 전체 구조와 연결성을 분석하는 데 필수적이에요.

wcc = gds.wcc.stats(G)
print(f"Component count: {wcc['componentCount']}")
print(f"Component distribution: {wcc['componentDistribution']}")
# Component count: 1119
# Component distribution: {
#   "min":1,
#   "p5":1,
#   "max":9109,
#   "p999":43,
#   "p99":19,
#   "p1":1,
#   "p10":1,
#   "p90":7,
#   "p50":2,
#   "p25":1,
#   "p75":4,
#   "p95":10,
#   "mean":11.3 }

WCC 알고리즘 결과는 1,119개의 개별 구성 요소를 식별했어요. 특히 가장 큰 구성 요소는 9,109개의 Node로 구성되는데, 이는 단일 슈퍼 구성 요소가 수많은 작은 격리 구성 요소와 공존하는 실제 네트워크에서 흔히 볼 수 있는 모습이에요. 가장 작은 구성 요소에는 Node가 1개 있으며 평균 구성 요소 크기는 약 11.3 Node입니다.

다음으로 GDS 라이브러리에서도 사용할 수 있는 Leiden 알고리즘을 실행하고 includeIntermediateCommunities 파라미터로 모든 레벨의 커뮤니티를 반환하고 저장할 거예요. 또한 relationshipWeightProperty 파라미터로 Leiden 알고리즘의 가중치 변형을 실행할 거고요. write 알고리즘 모드를 사용해서 결과를 node property로 저장할게요.

gds.leiden.write(
    G,
    writeProperty="communities",
    includeIntermediateCommunities=True,
    relationshipWeightProperty="weight",
)

알고리즘은 5가지 레벨의 커뮤니티를 식별했는데, 가장 높은 레벨(커뮤니티가 가장 큰 최소 세부 레벨)에는 1,188개의 커뮤니티(1,119개 컴포넌트와 반대)가 있어요. 다음은 Gephi를 사용해서 마지막 레벨의 커뮤니티를 시각화한 결과예요.

1,000개가 넘는 커뮤니티를 시각화하는 건 쉽지 않죠. 각각의 색상을 고르는 것조차 사실상 불가능해요. 하지만 멋진 예술 작품처럼 보이긴 하네요!

이제 각 커뮤니티에 대해 고유한 node를 만들고, 계층 구조를 상호 연결된 그래프로 표현해볼게요. 나중에 커뮤니티 요약 및 기타 속성도 node property로 저장할 거예요.

graph.query("""
MATCH (e:`__Entity__`)
UNWIND range(0, size(e.communities) - 1 , 1) AS index
CALL {
  WITH e, index
  WITH e, index
  WHERE index = 0
  MERGE (c:`__Community__` {id: toString(index) + '-' + toString(e.communities[index])})
  ON CREATE SET c.level = index
  MERGE (e)-[:IN_COMMUNITY]->(c)
  RETURN count(*) AS count_0
}
CALL {
  WITH e, index
  WITH e, index
  WHERE index > 0
  MERGE (current:`__Community__` {id: toString(index) + '-' + toString(e.communities[index])})
  ON CREATE SET current.level = index
  MERGE (previous:`__Community__` {id: toString(index - 1) + '-' + toString(e.communities[index - 1])})
  ON CREATE SET previous.level = index - 1
  MERGE (previous)-[:IN_COMMUNITY]->(current)
  RETURN count(*) AS count_1
}
RETURN count(*)
""")

저자는 또한 community rank라는 개념을 소개하는데요, 이는 커뮤니티 내의 엔터티가 나타나는 고유한 텍스트 청크 수를 나타낸다고 해요.

graph.query("""
MATCH (c:__Community__)<-[:IN_COMMUNITY*]-(:__Entity__)<-[:MENTIONS]-(d:Document)
WITH c, count(distinct d) AS rank
SET c.community_rank = rank;
""")

이제 더 높은 수준에서 병합되는 많은 중간 커뮤니티가 있는 샘플 계층 구조를 살펴볼게요. 커뮤니티는 서로 겹치지 않아요. 즉, 각 항목은 각 수준에서 정확히 단일 커뮤니티에 속하죠.

이미지는 Leiden 커뮤니티 감지 알고리즘으로 인한 계층 구조를 나타내요. 보라색 노드는 개별 엔터티를 나타내고 주황색 노드는 계층적 커뮤니티를 나타내죠.

계층 구조는 이러한 개체의 조직을 다양한 커뮤니티로 보여주며, 더 작은 커뮤니티는 더 높은 수준에서 더 큰 커뮤니티로 병합돼요.

이제 소규모 커뮤니티가 더 높은 수준에서 어떻게 병합되는지 살펴볼까요?

이 이미지는 연결성이 낮은 엔터티와 결과적으로 소규모 커뮤니티가 수준 전반에 걸쳐 최소한의 변화를 경험한다는 것을 보여주고 있어요. 예를 들어 여기의 커뮤니티 구조는 처음 두 수준에서만 변경되지만 마지막 세 수준에서는 동일하게 유지되죠. 결과적으로 전체 조직이 서로 다른 계층에서 크게 변경되지 않기 때문에 이러한 엔터티에 대해 계층적 수준이 중복되는 경우가 많아요.

커뮤니티 수와 규모, 다양한 수준을 더 자세히 살펴볼까요?

community_size = graph.query(
    """
MATCH (c:__Community__)<-[:IN_COMMUNITY*]-(e:__Entity__)
WITH c, count(distinct e) AS entities
RETURN split(c.id, '-')[0] AS level, entities
"""
)
community_size_df = pd.DataFrame.from_records(community_size)
percentiles_data = []
for level in community_size_df["level"].unique():
    subset = community_size_df[community_size_df["level"] == level]["entities"]
    num_communities = len(subset)
    percentiles = np.percentile(subset, [25, 50, 75, 90, 99])
    percentiles_data.append(
        [
            level,
            num_communities,
            percentiles[0],
            percentiles[1],
            percentiles[2],
            percentiles[3],
            percentiles[4],
            max(subset)
        ]
    )

# Create a DataFrame with the percentiles
percentiles_df = pd.DataFrame(
    percentiles_data,
    columns=[
        "Level",
        "Number of communities",
        "25th Percentile",
        "50th Percentile",
        "75th Percentile",
        "90th Percentile",
        "99th Percentile",
        "Max"
    ],
)
percentiles_df

원래 구현에서는 모든 수준의 커뮤니티를 요약했어요. 우리 경우에는 8,590개의 커뮤니티가 있고, 결과적으로 8,590개의 LLM 호출이 발생하죠. 저는 계층적 커뮤니티 구조에 따라 모든 수준을 요약할 필요는 없다고 생각해요. 예를 들어, 마지막 수준과 마지막에서 다음 수준의 차이는 커뮤니티가 4개뿐이에요 (1,192 대 1,188). 따라서 우리는 중복된 요약을 많이 만들게 되는 거죠. 한 가지 해결책은 변경되지 않는 다양한 수준의 커뮤니티에 대한 단일 요약을 만들 수 있는 구현을 만드는 거예요. 또 다른 방법은 변경되지 않는 커뮤니티 계층을 축소하는 거고요.

또한 많은 가치나 정보를 제공하지 않을 수 있으므로 구성원이 한 명으로 구성된 커뮤니티를 요약하고 싶은지 잘 모르겠어요. 여기서는 레벨 0, 1, 4의 커뮤니티를 요약하는데, 먼저 데이터베이스에서 해당 정보를 검색해야 해요.

community_info = graph.query("""
MATCH (c:`__Community__`)<-[:IN_COMMUNITY*]-(e:__Entity__)
WHERE c.level IN [0,1,4]
WITH c, collect(e ) AS nodes
WHERE size(nodes) > 1
CALL apoc.path.subgraphAll(nodes[0], {
 whitelistNodes:nodes
})
YIELD relationships
RETURN c.id AS communityId, 
       [n in nodes | {id: n.id, description: n.description, type: [el in labels(n) WHERE el <> '__Entity__'][0]}] AS nodes,
       [r in relationships | {start: startNode(r).id, type: type(r), end: endNode(r).id, description: r.description}] AS rels
""")

현재 커뮤니티 정보의 구조는 다음과 같아요.

{'communityId': '0-6014',
 'nodes': [{'id': 'Darrell Hughes', 'description': None, type:"Person"},
  {'id': 'Chief Pilot', 'description': None, type: "Person"},
   ...
  }],
 'rels': [{'start': 'Ryanair Dac',
   'description': 'Informed of the change in chief pilot',
   'type': 'INFORMED',
   'end': 'Irish Aviation Authority'},
  {'start': 'Ryanair Dac',
   'description': 'Dismissed after internal investigation found unacceptable behaviour',
   'type': 'DISMISSED',
   'end': 'Aidan Murray'},
   ...
]}

이제 커뮤니티 요소에서 제공하는 정보를 기반으로 Natural Language 요약을 생성하는 LLM 프롬프트를 준비해야 해요. 다음에서 영감을 얻을 수 있어요. 연구자들이 사용한 프롬프트.

저자는 커뮤니티를 요약했을 뿐만 아니라 각 커뮤니티에 대한 결과도 생성했어요. 결과는 특정 이벤트 또는 정보에 관한 간결한 정보로 정의할 수 있는데, 예를 들면 이런 거죠.

"summary": "Abila City Park as the central location",
"explanation": "Abila City Park is the central entity in this community, serving as the location for the POK rally. This park is the common link between all other
entities, suggesting its significance in the community. The park's association with the rally could potentially lead to issues such as public disorder or conflict, depending on the
nature of the rally and the reactions it provokes. [records: Entities (5), Relationships (37, 38, 39, 40)]"

제 생각에는 단 한 번의 패스로 결과를 추출하는 것은 엔터티 및 관계를 추출하는 것만큼 포괄적이지 않을 수 있어요.

또한 로컬 또는 전역 검색 검색기의 코드에서 해당 사용에 대한 참조나 예를 찾지 못했어요. 따라서 이 경우에는 결과 추출을 하지 않으려고 해요. 아니면, 학자들이 자주 말하듯이 이 연습은 독자 여러분의 몫으로 남겨두겠습니다. 게다가, 다음 단계도 건너뛰었어요. 주장 또는 공변량 정보 추출인데, 얼핏 보면 결과와 비슷해 보이죠.

커뮤니티 요약을 생성하는 데 사용할 프롬프트는 매우 간단해요.

community_template = """Based on the provided nodes and relationships that belong to the same graph community,
generate a natural language summary of the provided information:
{community_info}

Summary:"""  # noqa: E501

community_prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "Given an input triples, generate the information summary. No pre-amble.",
        ),
        ("human", community_template),
    ]
)

community_chain = community_prompt | llm | StrOutputParser()

이제 남은 일은 커뮤니티 표현을 문자열로 변환해서 JSON 토큰 오버헤드를 피하고, 체인을 함수로 래핑해서 토큰 수를 줄이는 거예요.

def prepare_string(data):
    nodes_str = "Nodes are:n"
    for node in data['nodes']:
        node_id = node['id']
        node_type = node['type']
        if 'description' in node and node['description']:
            node_description = f", description: {node['description']}"
        else:
            node_description = ""
        nodes_str += f"id: {node_id}, type: {node_type}{node_description}n"

    rels_str = "Relationships are:n"
    for rel in data['rels']:
        start = rel['start']
        end = rel['end']
        rel_type = rel['type']
        if 'description' in rel and rel['description']:
            description = f", description: {rel['description']}"
        else:
            description = ""
        rels_str += f"({start})-[:{rel_type}]->({end}){description}n"

    return nodes_str + "n" + rels_str

def process_community(community):
    stringify_info = prepare_string(community)
    summary = community_chain.invoke({'community_info': stringify_info})
    return {"community": community['communityId'], "summary": summary}

이제 선택한 레벨에 대한 커뮤니티 요약을 생성할 수 있어요. 다시 한 번 더 빠른 실행을 위해 호출을 병렬화할게요.

summaries = []
with ThreadPoolExecutor() as executor:
    futures = {executor.submit(process_community, community): community for community in community_info}

    for future in tqdm(as_completed(futures), total=len(futures), desc="Processing communities"):
        summaries.append(future.result())

제가 언급하지 않은 한 가지 측면은 저자가 커뮤니티 정보를 입력할 때 컨텍스트 크기를 초과하는 잠재적인 문제도 다루고 있다는 점이에요. 그래프가 확장됨에 따라 커뮤니티도 크게 성장할 수 있죠. 우리의 경우 가장 큰 커뮤니티는 545명의 회원으로 구성되었어요. GPT-4o의 컨텍스트 크기가 토큰 100,000개를 초과한다는 점을 고려해서 이 단계를 건너뛰기로 결정했어요.

마지막 단계로 커뮤니티 요약을 데이터베이스에 다시 저장할 거예요.

graph.query("""
UNWIND $data AS row
MERGE (c:__Community__ {id:row.community})
SET c.summary = row.summary
""", params={"data": summaries})

최종 그래프 구조는 이렇게 생겼어요:

이제 그래프에는 원본 문서, 추출된 엔터티 및 관계는 물론 계층적 커뮤니티 구조와 요약까지 모두 포함되어 있어요.

요약

The 의 "From Local to Global" 논문은 GraphRAG에 대한 새로운 접근 방식을 보여주는 데 정말 큰 도움이 되었어요. 다양한 문서의 정보를 계층적인 Knowledge Graph 구조로 결합하고 요약하는 방법을 보여주죠.

한 가지 명시적으로 언급되지 않은 점은 구조화된 데이터 소스를 그래프에 통합할 수도 있다는 거예요. 입력이 구조화되지 않은 텍스트로만 제한될 필요는 없다는 거죠.

추출 방식에서 특히 좋았던 점은 `노드`와 `관계` 모두에 대한 설명을 캡처한다는 점이에요. 설명을 통해 LLM은 모든 것을 `노드` ID 및 `관계` 유형으로 축소하는 것보다 훨씬 더 많은 정보를 유지할 수 있거든요.

또한 텍스트에 대한 단일 추출 패스로는 모든 관련 정보를 캡처하지 못할 수도 있고, 필요한 경우 여러 패스를 수행하는 로직을 도입할 수 있다는 점을 보여줘요. 게다가 저자는 그래프 커뮤니티에 대한 요약을 수행하여 여러 데이터 소스에 압축된 주제 정보를 삽입하고 인덱싱할 수 있다는 흥미로운 아이디어를 제시하기도 했어요.

다음 블로그 게시물에서는 로컬 및 글로벌 검색 검색기 구현을 살펴보고, 주어진 그래프 구조를 기반으로 구현할 수 있는 다른 접근 방식에 대해 이야기해볼게요.

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

이번에도 데이터베이스 덤프를 올려놨으니, 결과를 탐색하고 다양한 검색기 옵션을 실험해 볼 수 있을 거예요.

이 덤프를 영원히 무료인 Neo4j AuraDB 인스턴스로 가져올 수도 있어요. 검색 탐색에는 그래프 데이터 과학 알고리즘이 필요하지 않으니까, 그래프 패턴 일치, `벡터` 및 전체 텍스트 `인덱스`만 있으면 검색을 탐색할 수 있답니다.

자세한 내용은 모든 GenAI 프레임워크와 Neo4j 통합에서 확인해보세요. 제 책 "데이터 과학을 위한 그래프 알고리즘"“Graph Algorithms for Data Science”도 참고해주세요.

  • ChatGPT
  • GraphRAG
  • Langchain
  • RAG

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

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

728x90
반응형