Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tiny GraphRAG (1부)

원래 Microsoft가 고안한 GraphRAG 알고리즘을 1000줄 정도의 작은 구현으로 만들어 보겠습니다. 밋업에서 이 알고리즘에 대한 이야기를 꾸준히 듣지만, 실제로 사용하거나 구현하는 사람보다 이야기하는 사람이 몇 자릿수는 더 많은 것 같습니다. 아마 참조 구현이 거대하고 꽤 복잡하기 때문일 것입니다. 그러니 이를 하나씩 뜯어보고, 이 접근법을 둘러싼 과대광고에 실제로 가치가 있는지 살펴봅시다.

배경을 조금 설명하자면, 나이브 RAG는 주로 벡터 유사도 검색에 의존하여 외부 정보로 LLM 출력을 보강하는 기본적인 접근법입니다. 이 과정은 문서를 텍스트로 변환하고, 그 텍스트를 청크로 나눈 뒤, 이 청크들을 비슷한 의미가 비슷한 벡터 위치로 표현되는 벡터 공간에 임베딩하는 단계로 이루어집니다. 사용자가 쿼리를 제출하면 그 쿼리도 같은 벡터 공간에 임베딩되고, 벡터 위치가 가장 가까운 텍스트 청크들이 검색되어 원래 쿼리와 함께 LLM의 컨텍스트 윈도에 추가됩니다.

GraphRAG는 단순한 의미 검색을 넘어 문서의 그래프 기반 인덱스를 사용하는 또 다른 구조화된 접근법입니다. 핵심 아이디어는 LLM을 사용해 원본 문서로부터 지식 그래프를 구축하고, 그 그래프 안에서 밀접하게 관련된 엔티티들의 커뮤니티를 탐지한 다음, 이 커뮤니티들에 대한 요약을 생성하는 것입니다. 이렇게 하면 특정 엔티티에 대한 로컬 쿼리와 문서 전체에 걸친 글로벌 분석 모두에 활용할 수 있는 또 다른 구조화된 인덱스가 만들어집니다. 일부 작업에서는 이 방식이 나이브 RAG보다 더 좋은 성능을 낼 수 있습니다.

상용 LLM 제공자에 의존하지 않고 완전히 로컬에서 실행되는 작은 버전을 만들어 보겠습니다. 우리 시스템은 다음을 사용합니다:

(Memgraph나 Neo4j 같은) 그래프 데이터베이스를 사용하는 것도 간단하지만, 단순함을 위해 networkx 그래프 데이터를 직렬화하여 디스크에 저장하기만 하겠습니다.

소스 코드는 MIT 라이선스로 Github에 공개되어 있습니다.

Core 아이디어

나이브 RAG를 할 때는 보통 관련 컨텍스트를 찾기 위해 벡터 유사도에 의존합니다. 이 방식은 단순한 경우에는 잘 작동하지만 중요한 맥락적 관계를 놓칠 수 있습니다. 예를 들어, sentence-transformers 라이브러리를 사용한 기본적인 벡터 유사도 비교를 살펴봅시다.

from sentence_transformers import SentenceTransformer

model = SentenceTransformer('all-MiniLM-L6-v2')

sentence1 = "I love programming"
sentence2 = "I enjoy writing code"

embedding1 = model.encode(sentence1)
embedding2 = model.encode(sentence2)

similarity = model.similarity(embedding1, embedding2)
print(f"Similarity score: {similarity:.4f}")

이 방식은 의미적으로 유사한 콘텐츠를 찾아낼 수 있지만, 몇 가지 핵심적인 한계가 있습니다. 첫째, 문서 안에 존재하는 엔터티와 개념 사이의 풍부한 관계를 포착하지 못합니다. 둘째, 흔히 “로컬 컨텍스트 문제“라고 부르는 상황에 취약합니다. 이는 관련 정보가 질의와 직접적인 의미적 유사성을 갖지 않은 채 한 문서의 여러 부분이나 여러 문서에 흩어져 있을 수 있는 문제를 말합니다. 예를 들어, 어떤 약물의 부작용을 이해하려면 한 섹션에 있는 화학 성분에 관한 정보와 다른 섹션에 서술된 환자 경과를 연결해야 할 수 있는데, 이러한 구절들은 부작용에 관한 질의와 의미적으로 유사하지 않을 수도 있습니다. GraphRAG는 엔터티와 개념 사이의 관계를 명시적으로 표현하는 구조화된 지식 그래프를 구축하여 이러한 한계를 해결합니다. 이를 통해 개별 조각이 질의와 의미적으로 유사하지 않더라도 이러한 연결을 추적하고 관련 컨텍스트를 모을 수 있습니다. GraphRAG에는 로컬 검색과 글로벌 검색이라는 두 가지 모드가 있습니다.

로컬 검색은 엔터티 중심의 질의에 최적화되어 있으며, 지식 그래프 안의 특정 엔터티와 그 관계에 초점을 맞추어 동작합니다. 로컬 검색은 지식 그래프의 구조화된 데이터와 입력 문서의 비구조화된 데이터를 결합하므로, “캐모마일의 치유 효능은 무엇입니까?“와 같이 특정 엔터티에 관한 질문에 특히 효과적입니다. 이 과정은 사용자 질의와 선택적인 대화 기록을 입력받는 것으로 시작하며, 이어서 지식 그래프에서 의미적으로 관련된 엔터티를 식별합니다. 그런 다음 연결된 엔터티, 관계, 엔터티 공변량, 커뮤니티 보고서, 그리고 원본 문서에서 가져온 관련 텍스트 청크를 포함한 관련 정보를 추출합니다. 이후 이 정보에 우선순위를 매기고 컨텍스트 윈도에 들어가도록 필터링한 다음, 필터링된 컨텍스트를 사용해 응답을 생성합니다.

반면 글로벌 검색은 데이터셋 전체에 걸친 분석을 위해 설계되었으며, 데이터셋 전체에서 정보를 집계해야 하는 질의를 더 잘 처리합니다. 글로벌 검색은 더 폭넓은 주제와 패턴을 식별하는 데 뛰어나므로, “데이터에서 가장 중요한 주제 5가지는 무엇입니까?“와 같은 상위 수준의 질문에 답하는 데 가장 적합합니다. 이 과정은 맵리듀스 방식을 사용하는데, 맵 단계에서는 커뮤니티 보고서를 텍스트 청크로 나누고 중요도 점수가 매겨진 요점이 담긴 중간 응답을 생성합니다. 그런 다음 리듀스 단계에서 가장 중요한 요점을 걸러 내고 집계하여 최종적인 종합 응답을 만들어 냅니다.

두 방식의 핵심적인 차이는, 로컬 검색이 특정 엔터티 기반의 추론에 초점을 맞추는 반면, 글로벌 검색은 지식 그래프의 커뮤니티 구조와 미리 요약된 의미 클러스터를 활용하여 데이터셋 수준의 더 폭넓은 통찰을 제공한다는 점입니다.

그래프 구축하기

GraphRAG는 문서 콘텐츠에 대한 그래프 기반 인덱스를 구축하는 방식으로 RAG에 새롭게 접근합니다. 주요 단계는 네 가지입니다.

  1. 그래프 구축: 먼저 원본 문서를 처리하여 엔터티와 관계를 추출하고, 콘텐츠를 지식 그래프로 표현합니다. 이 작업에는 다음이 사용됩니다.

    • 텍스트 청킹과 전처리
    • LLM을 사용한 엔터티 및 관계 추출
    • 추출된 요소를 이용한 그래프 구축
  2. 커뮤니티 탐지: 다음으로 시스템은 라이덴(Leiden) 알고리즘을 사용하여 그래프에서 서로 밀접하게 관련된 엔터티들의 커뮤니티를 탐지합니다.

  3. 커뮤니티 요약: 탐지된 각 커뮤니티에 대해, 그 커뮤니티 안의 핵심 엔터티와 관계를 설명하는 자연어 요약을 생성합니다. 이를 통해 각 클러스터에 담긴 주제와 개념을 상위 수준에서 개괄할 수 있습니다.

  4. 맵리듀스: 글로벌 검색 질의에는 맵리듀스 방식을 사용합니다. 먼저 각 커뮤니티 요약에 대한 응답을 생성하고(맵 단계), 이어서 이 응답들을 결합하고 종합하여(리듀스 단계) 그래프 전체의 정보를 아우르는 포괄적인 답변을 만들어 냅니다.

예제 데이터셋

이 예제에서는 버락 오바마에 관한 위키백과 문서 모음을 사용하여 시스템이 주제별 클러스터를 어떻게 자동으로 탐지하는지 보여 드리겠습니다. 라이덴 커뮤니티 탐지 알고리즘은 엔터티-관계 그래프를 분석하여 일관된 주제를 나타내는, 조밀하게 연결된 부분 그래프를 식별합니다. 예를 들어, 오바마의 전기를 처리하면 다음과 같은 클러스터를 자동으로 탐지합니다.

  • 유년기와 교육: 하와이에서 보낸 어린 시절, 컬럼비아 대학교와 하버드 로스쿨에서 받은 교육에 관한 엔터티
  • 정치 경력: 주 상원의원, 연방 상원의원 시절과 대통령 선거 운동
  • 대통령 재임기: 주요 정책, 행정 조치, 행정부 관료
  • 퇴임 이후: 출판 계약, 강연 활동, 오바마 재단 활동

그런 다음 각 클러스터는 그 안의 엔터티와 관계를 분석하여 요약됩니다. 예를 들어, 유년기 클러스터의 요약은 다음과 같을 수 있습니다. “이 섹션은 버락 오바마의 하와이와 인도네시아에서의 성장 과정, 컬럼비아 대학교에서의 정치학 학부 과정, 그리고 하버드 로 리뷰(Harvard Law Review)의 편집장을 지낸 하버드에서의 법학 학위를 다룹니다.”

시스템은 주제를 수동으로 지정할 필요 없이 엔터티 간 연결의 밀도를 기반으로 클러스터 경계를 자동으로 결정합니다. 이러한 유기적인 클러스터링은 서로 떨어져 있는 청크들에 걸쳐 문서의 자연스러운 주제 구조를 포착하는 데 도움이 됩니다.

데이터 모델 (db.py)

SQLAlchemy 데이터 모델을 사용하여 Postgres에 대한 영속성을 정의하겠습니다. Document 모델은 원본 문서 내용과 메타데이터를 저장합니다. 내용은 원시 텍스트로 저장되며, 선택적으로 제목과 타임스탬프를 가집니다.

class Document(Base):
    """Represents a document in the database."""
    __tablename__ = "documents"

    id = Column(Integer, primary_key=True)
    content = Column(Text, nullable=False)
    title = Column(String(256))
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())

    # Relationships
    chunks = relationship("DocumentChunk", back_populates="document")
    communities = relationship("Community", back_populates="document")

DocumentChunk 모델은 효율적인 의미 검색을 위해 문서의 조각을 벡터 임베딩과 함께 저장합니다. 각 청크는 원시 텍스트 내용, 원본 문서에서의 위치(chunk_index), 그리고 밀집 벡터 임베딩을 포함합니다. 이 모델은 임베딩에 대한 빠른 근사 최근접 이웃 검색을 위해 HNSW(Hierarchical Navigable Small World) 인덱싱을 사용하고, 내용에 대한 전문 검색을 위해 GIN 인덱스를 사용합니다. 이를 통해 임베딩을 이용한 의미 유사도 검색과 텍스트에 대한 키워드 기반 검색이 모두 가능해집니다.

class DocumentChunk(Base):
    """Represents a chunk of a document with vector embedding."""
    __tablename__ = "document_chunks"

    id = Column(Integer, primary_key=True)
    document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
    content = Column(Text, nullable=False)
    chunk_index = Column(Integer, nullable=False)
    embedding = mapped_column(Vector(384), nullable=False)  # Vector embedding

    # Indexes for efficient search
    __table_args__ = (
        Index(
            "ix_document_chunks_embedding",
            embedding,
            postgresql_using="hnsw",
            postgresql_with={"m": 16, "ef_construction": 64},
            postgresql_ops={"embedding": "vector_cosine_ops"},
        ),
        Index(
            "ix_document_chunks_content_fts",  # Full-text search index
            text("to_tsvector('english', content)"),
            postgresql_using="gin",
        ),
    )

Community 모델은 문서에서 탐지된 관련 엔터티들의 클러스터를 나타냅니다. 각 커뮤니티는 요약 설명(content)과 유사도 검색을 위한 벡터 임베딩을 가지며, 구성원 노드 ID를 JSON 문자열로 저장합니다. DocumentChunk와 마찬가지로, 효율적인 유사도 검색을 위해 임베딩에 HNSW 인덱싱을 사용합니다. 커뮤니티는 원본 문서와 다시 연결되며, 주제적으로 관련된 엔터티 그룹을 탐색할 수 있게 해 줍니다.

class Community(Base):
    """Represents a community of related entities."""
    __tablename__ = "communities"

    id = Column(Integer, primary_key=True)
    document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
    content = Column(Text, nullable=False)  # Community summary
    embedding = mapped_column(Vector(384), nullable=False)
    nodes = Column(Text, nullable=False)  # JSON string of node IDs

    # Indexes
    __table_args__ = (
        Index(
            "ix_communities_embedding",
            embedding,
            postgresql_using="hnsw",
            postgresql_ops={"embedding": "vector_cosine_ops"},
        ),
    )

Summary 모델은 커뮤니티에 대해 생성된 요약을 저장합니다. 각 요약은 텍스트 내용 필드와 유사도 검색을 위한 벡터 임베딩을 가집니다. 다른 모델들과 마찬가지로, 효율적인 유사도 검색을 위해 임베딩에 HNSW 인덱싱을 사용하며, 내용에 대해서는 전문 검색 인덱스도 사용합니다.

class Summary(Base):
    """Represents a document summary."""
    __tablename__ = "summaries"

    id = Column(Integer, primary_key=True)
    document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
    content = Column(Text, nullable=False)
    embedding = mapped_column(Vector(384), nullable=False)

    # Indexes
    __table_args__ = (
        Index(
            "ix_summaries_embedding",
            embedding,
            postgresql_using="hnsw",
            postgresql_ops={"embedding": "vector_cosine_ops"},
        ),
        Index(
            "ix_summaries_content_fts",
            text("to_tsvector('english', content)"),
            postgresql_using="gin",
        ),
    )

문서 처리 파이프라인 (build.py)

그래프 처리 파이프라인의 최상위는 build.py에서 시작하며, 이 파일은 텍스트 소스로부터 벡터 인덱스와 지식 그래프를 구축하는 작업을 담당합니다. 이 파일은 여러 Core 함수를 호출합니다.

  • process_document - 문서 파일 경로를 받아 텍스트를 청크로 나누고, 엔터티와 관계를 추출하고, 지식 그래프를 구축하는 방식으로 문서를 처리합니다. 임베딩이 포함된 청크 단위 문서와 구축된 그래프를 모두 반환합니다.
  • chunk_document - 경계를 넘어 문맥을 유지하기 위해 청크 사이에 일부 겹침을 두고, 문서를 크기를 설정할 수 있는 겹치는 텍스트 조각들로 분할합니다. 텍스트 청크의 리스트를 반환합니다.
  • build_communities - 커뮤니티 탐지 알고리즘을 사용하여 지식 그래프를 분석하고 밀접하게 관련된 엔터티들의 클러스터를 탐지합니다. 핵심 정보를 포착하기 위해 각 커뮤니티에 대한 요약을 생성합니다. 커뮤니티 할당 결과와 요약을 반환합니다.
  • extract_rels - 규칙 기반 패턴과 LLM 추출을 함께 사용해 텍스트 청크를 처리하고 엔티티 간 관계를 식별합니다. 추출된 관계의 목록을 엔티티 쌍 및 관계 유형과 함께 반환합니다.
def store_document(
    filepath: str,
    engine: Engine,
    title: Optional[str] = None,
    max_chunks: int = -1,
) -> Tuple[int, str]:
    """Store document in database and save graph."""
    session_local = sessionmaker(bind=engine)

    with session_local() as session:
        # Process document
        chunks, graph = process_document(filepath, title, max_chunks)

        # Store document
        doc = Document(content=open(filepath).read(), title=title)
        session.add(doc)
        session.flush()

        # Store chunks with embeddings
        for chunk_text, embedding in chunks:
            chunk = DocumentChunk(
                document_id=doc.id,
                content=chunk_text,
                embedding=embedding,
                chunk_index=0
            )
            session.add(chunk)

        # Build and store communities
        community_result = build_communities(graph)

        # Save graph to disk
        graph_path = f"graphs/{doc.id}_graph.pkl"
        with open(graph_path, "wb") as f:
            pickle.dump(graph, f)

        session.commit()
        return doc.id, graph_path

process_document 함수는 청크에서 지식 그래프 엔티티(노드와 관계)를 구성하는 일을 담당합니다. 이들은 networkx 무방향 Graph 자료 구조인 전역 그래프에 추가됩니다. 생성되는 각 트리플의 속성으로 원본 텍스트의 출처를 저장하며, 이는 마지막의 로컬 검색 메서드에서 사용됩니다.

def process_document(
    filepath: str,
    title: Optional[str] = None,
    max_chunks: int = -1,
    entity_types: List[str] = MIN_ENTITY_TYPES,
    relation_types: List[str] = DEFAULT_RELS_LIST,
) -> Tuple[List[Tuple[str, Any]], nx.Graph]:
    """Process a document and return chunks and graph."""
    # Read and chunk document
    page_text = open(filepath).read()
    page_chunks = chunk_document(page_text)

    # Build graph
    g = nx.Graph()

    for chunk_text, _embedding in tqdm(page_chunks[:max_chunks]):
        extraction = extract_rels(chunk_text, entity_types, relation_types)

        for ent in extraction.entities:
            g.add_node(ent[0], label=ent[1])

        for rel in extraction.relations:
            g.add_edge(rel[0], rel[2], label=rel[1], source_chunk=chunk_text)

    return page_chunks, g

청킹(chunking.py)

청킹 알고리즘은 문장 경계와 의미적 일관성을 유지하면서 입력 문서를 서로 겹치는 세그먼트로 분할합니다. 텍스트를 한 문장씩 처리하면서 지정된 크기 제한(기본값 200자)에 도달할 때까지 문장을 청크에 누적한 다음, 문장 트랜스포머 모델을 사용해 각 청크의 밀집 벡터 임베딩을 생성합니다. 청크 간 문맥의 연속성을 유지하기 위해 이전 청크 문장의 일부를 다음 청크로 넘겨주는 오버랩 메커니즘(기본값 50자)을 구현합니다. 이 오버랩은 청크를 가로지르는 관계를 포착하는 데 도움이 되며, 청크 경계에서 문맥이 손실되지 않도록 보장합니다. 이 알고리즘은 텍스트 청크와 그에 대응하는 임베딩을 함께 담은 튜플의 목록을 반환하며, 이는 이후 유사도 검색과 지식 그래프 구성에 사용됩니다.

def chunk_document(
    text: str,
    chunk_size: int = 200,
    overlap: int = 50
) -> List[Tuple[str, npt.NDArray[np.float32]]]:
    """Split document into overlapping chunks with embeddings."""
    sentences = [s.strip() for s in text.split(".") if s.strip()]
    chunks = []
    current_chunk: List[str] = []
    current_length = 0

    for sentence in sentences:
        sentence_length = len(sentence)

        if current_length + sentence_length > chunk_size and current_chunk:
            # Create chunk and get embedding
            chunk_text = ". ".join(current_chunk) + "."
            embedding = model.encode(chunk_text, convert_to_numpy=True)
            chunks.append((chunk_text, embedding))

            # Handle overlap
            overlap_chunk = []
            overlap_size = 0
            for s in reversed(current_chunk):
                if overlap_size + len(s) <= overlap:
                    overlap_chunk.insert(0, s)
                    overlap_size += len(s)

            current_chunk = overlap_chunk
            current_length = overlap_size

        current_chunk.append(sentence)
        current_length += sentence_length

    return chunks

엔티티 및 관계 추출(extract.py)

추출 파이프라인은 엔티티 및 관계 추출을 위해 GLiNER와 GLiREL 모델을 spaCy 워크플로에 통합합니다. GLiNER는 개체명 인식을 담당하고, GLiREL은 해당 엔티티 간의 관계를 추출합니다. 더 나은 성능을 위해 GPU 가속을 사용하도록 파이프라인을 구성하며, 매개변수를 통해 엔티티 유형(예: Person, Organization)과 관계 유형(예: occupation, place of birth )을 모두 사용자 정의할 수 있게 합니다. Core 추출 로직은 두 가지 핵심 함수로 구현되어 있습니다:

def nlp_model(threshold: float, entity_types: tuple[str], device: str = DEVICE):
    """Instantiate a spacy model with GLiNER and GLiREL components."""
    custom_spacy_config = {
        "gliner_model": "urchade/gliner_mediumv2.1",
        "chunk_size": 250,
        "labels": entity_types,
        "style": "ent",
        "threshold": threshold,
        "map_location": device,
    }
    spacy.require_gpu()  # type: ignore

    nlp = spacy.blank("en")
    nlp.add_pipe("gliner_spacy", config=custom_spacy_config)
    nlp.add_pipe("glirel", after="gliner_spacy")
    return nlp

그리고 extract_rels 메서드는 텍스트 입력과 함께 엔티티 및 관계 유형 설정을 받아 NLP 파이프라인으로 처리하고, 식별된 엔티티와 그 관계를 모두 담은 구조화된 추출 결과를 반환합니다. 이 메서드는 신뢰도 임계값을 적용해 품질이 낮은 관계 매칭을 걸러 내므로, 신뢰도가 높은 관계만 최종 출력에 포함됩니다.

def extract_rels(
    text: str,
    entity_types: List[str],
    relation_types: List[str],
    threshold: float = 0.75,
) -> ExtractionResult:
    """Extract entities and relations from text."""
    nlp = nlp_model(threshold, tuple(entity_types))
    docs = list(nlp.pipe([(text, {"glirel_labels": relation_types})]))
    relations = docs[0][0]._.relations

    # Extract entities with their types
    ents = [(ent.text, ent.label_) for ent in docs[0][0].ents]

    # Extract relations above threshold
    rels = [
        (item["head_text"], item["label"], item["tail_text"])
        for item in relations
        if item["score"] >= threshold
    ]

    return ExtractionResult(entities=ents, relations=rels)

GLiNER는 텍스트에서 엔티티를 식별할 수 있는 트랜스포머 기반 개체명 인식 모델입니다. 사전 정의된 엔티티 유형으로 제한되는 기존 NER 모델(spaCy의 모델 등)이나 상당한 컴퓨팅 자원을 요구하는 LLM 기반 모델과 달리, GLiNER는 높은 성능을 유지하면서도 자원 효율이 더 높은 해법을 제공합니다. 양방향 트랜스포머 인코더를 사용해 텍스트와 엔티티 유형을 병렬로 처리하며, NER을 잠재 공간에서 엔티티 유형 임베딩과 텍스트 스팬 표현 간의 매칭 문제로 다룹니다. 훨씬 작은 모델(매개변수 0.3B개)임에도 불구하고, 다양한 NER 벤치마크의 제로샷 평가에서 ChatGPT와 파인튜닝된 LLM을 모두 능가합니다.

최소한의 기본 엔티티 어노테이션 세트가 제공되지만, 선택적으로 재정의할 수 있습니다.

MIN_ENTITY_TYPES = [
    "Person",
    "Organization",
    "Location",
    "Product",
    "Concept",
]

명명된 관계 인식(rel_types.py)

GLiREL(Generalist and Lightweight model for Relation Extraction)은 특정 관계 유형에 대한 사전 학습 없이도 텍스트 내 엔티티 간의 관계를 분류할 수 있는 제로샷 관계 추출 모델입니다. spaCy 파이프라인에서 GLiNER와 함께 작동하여 탐지된 엔티티 간의 의미적 관계를 식별하며, 임계값 기반 필터링을 가능하게 하는 신뢰도 점수를 제공합니다. 이 모델은 경량 아키텍처를 유지하면서도 새로운 관계 유형을 즉석에서 처리할 수 있어 특히 효율적입니다.

관계 인식 시스템은 제약이 없는 자유 형식의 관계를 사용해 엔티티 간의 의미적 관계를 정의합니다. 다음과 같습니다.

DEFAULT_RELS = [ "created", "author", "location", "place of birth", "occupation" ]

또는 NER 레이블을 사용해 생성될 수 있는 관계의 유형을 제한하는 유형 제약을 지정할 수도 있습니다.

DEFAULT_RELS = {
    "created": {"allowed_head": ["Person"], "allowed_tail": ["Organization"]},
    "author": {"allowed_head": ["Person"], "allowed_tail": ["Document"]},
    "location": {"allowed_head": ["Location"], "allowed_tail": ["Location"]},
    "place of birth": {"allowed_head": ["Person"], "allowed_tail": ["Location"]},
    "occupation": {"allowed_head": ["Person"], "allowed_tail": ["Concept"]},
    ...
}

커뮤니티 분할(communities.py)

Leiden 알고리즘은 대규모 네트워크에서 커뮤니티를 찾는 그래프 커뮤니티 탐지 방법으로, 이전의 Louvain 알고리즘을 개선한 것입니다. 이 알고리즘은 특히 잘 연결된 커뮤니티를 보장할 수 있다는 점에서 주목할 만하며, 이 덕분에 문서 분석을 위한 GraphRAG 접근 방식에 이상적입니다. 이 알고리즘은 그래프를 모듈식 커뮤니티로 분할하는 방식으로 동작하며, 각 커뮤니티 내의 노드들은 다른 커뮤니티의 노드들보다 서로 더 강하게 연결되어 있습니다.

우리 시스템에서는 문서의 엔티티와 관계로 구성된 지식 그래프 내에서 커뮤니티를 탐지하기 위해 Leiden 알고리즘을 사용합니다. 구현에서는 그래프 연산을 위해 networkx와 cdlib 라이브러리를 사용하며, 커뮤니티 구조를 그래프의 트리플을 생성한 원본 텍스트 청크와 함께 저장합니다.

def build_communities(g: nx.Graph) -> CommunityResult:
    """Build communities from a graph."""
    communities = []
    node_community_map = {}

    # Convert to integers for algorithm
    gi = nx.convert_node_labels_to_integers(g, label_attribute="original_label")

    # Create reverse mapping
    reverse_mapping = {
        node: data["original_label"]
        for node, data in gi.nodes(data=True)
    }

    for component in nx.connected_components(gi):
        subgraph = gi.subgraph(component)
        if len(subgraph) > 1:
            # Detect communities using Leiden
            coms = algorithms.leiden(subgraph)

            for com_id, com in enumerate(coms.communities):
                # Extract community relationships
                community_subgraph = coms.graph.subgraph(com)
                community_triples = []

                for s, t, data in community_subgraph.edges(data=True):
                    triple = (
                        reverse_mapping[s],
                        data.get("label"),
                        reverse_mapping[t],
                        data.get("source_chunk")
                    )
                    community_triples.append(triple)

                communities.append(community_triples)

                # Map nodes to communities
                for node in com:
                    node_community_map[reverse_mapping[node]] = com_id

    return CommunityResult(
        communities=communities,
        node_community_map=node_community_map
    )

지식 그래프에서 커뮤니티를 추출하고 나면, 각 커뮤니티의 트리플에 담긴 핵심 정보와 관계를 포착하는 자연어 요약을 생성해야 합니다. 이를 위해 트리플을 언어 모델에 전달하면, 언어 모델은 이를 종합하여 그래프의 해당 부분에 존재하는 주요 개념과 연결을 설명하는 일관된 텍스트를 만들어 냅니다. 이 요약 텍스트는 나중에 검색에 활용할 수 있는 커뮤니티의 의미적 표현을 제공합니다.

커뮤니티에는 많은 트리플이 포함될 수 있고 언어 모델의 컨텍스트 윈도우는 제한되어 있으므로, 큰 커뮤니티는 신중하게 처리해야 합니다. 컨텍스트 윈도우에 담을 수 있는 것보다 많은 트리플(기본값 30개)을 가진 커뮤니티의 경우, 먼저 이를 더 작은 청크로 나누고 중간 요약을 생성합니다. 그런 다음 이 청크 요약들을 이어 붙여 모델에 다시 통과시켜, 모든 청크에 걸친 핵심 정보를 포착하는 최종 통합 요약을 생성합니다. 이러한 2단계 접근 방식을 통해 모델의 제약 안에서 작업하면서도 어떤 크기의 커뮤니티든 요약할 수 있습니다.

def generate_community_summary(
    llm: Llama,
    community: List[Tuple[str, str, str, str]],
    max_triples: int = 30,
    temperature: float = 0.2,
) -> str:
    # Chunk the community into smaller pieces if too large
    chunks = [
        community[i : i + max_triples] for i in range(0, len(community), max_triples)
    ]

    summaries: List[str] = []
    for chunk in chunks:
        response = llm.create_chat_completion(
            messages=[
                {
                    "role": "user",
                    "content": COMMUNITY_SUMMARY.format(community=chunk),
                }
            ],
            temperature=temperature,
        )
        summaries.append(response["choices"][0]["message"]["content"])

    # If we had multiple chunks, combine them
    if len(summaries) > 1:
        combined = " ".join(summaries)
        # Generate a final summary of the combined text
        response = llm.create_chat_completion(
            messages=[
                {
                    "role": "user",
                    "content": COMMUNITY_COMBINE.format(combined=combined),
                }
            ],
            temperature=temperature,
        )
        return response["choices"][0]["message"]["content"]

    return summaries[0]

이를 Obama 데이터셋의 일부에 실행하면 다섯 개의 주요 커뮤니티를 가진 다음과 같은 그래프를 얻게 됩니다. 대법관 지명자, 이라크 전쟁, 민주당과 같은 주제와 관련된 커뮤니티로, 이는 그래프 구조로부터 자동으로 발견됩니다.

Graph showing communities detected in Obama dataset

이제 파이프라인의 구축 단계를 마쳤습니다. 이제 검색 방법으로 넘어갑시다.

쿼리 (query.py)

QueryEngine 클래스는 다양한 유형의 검색을 처리하는 Core 구성 요소입니다. 초기화 과정에서 응답 생성을 위한 Llama 언어 모델을 설정하고, SQLAlchemy를 사용하여 데이터베이스 세션 팩토리를 생성합니다. engine 매개변수는 벡터 검색을 위해 pgvector가 설치된 PostgreSQL 데이터베이스에 연결합니다.

class QueryEngine:
    def __init__(self, engine: Engine) -> None:
        self.llm = Llama.from_pretrained(
            repo_id=MODEL_REPO,
            filename=MODEL_ID,
            local_dir=".",
            verbose=False,
            n_ctx=DEFAULT_CTX_LENGTH,
        )
        self.SessionLocal = sessionmaker(bind=engine)
        self.engine = engine

쿼리 엔진은 응답을 생성하기 위해 llama-cpp-python을 사용하여 Meta의 Llama 3.2-3B 모델과 연동합니다. 모델은 Hugging Face에서 내려받을 수 있는 로컬 GGUF 파일에서 로드됩니다. 초기화 과정에서 모델은 특정 컨텍스트 길이와 설정으로 로드됩니다. Core 상호작용은 채팅 완성 요청을 위한 로컬 인터페이스를 제공하는 _generate_llm_response 메서드를 통해 이루어집니다. 이 메서드는 시스템 프롬프트, 사용자 콘텐츠, temperature 매개변수를 받아 생성된 응답을 문자열로 반환합니다.

def _generate_llm_response(
    self,
    system_prompt: str,
    user_content: str,
    temp: float = DEFAULT_TEMPERATURE
) -> str:
    """Common method for generating LLM responses."""
    response = self.llm.create_chat_completion(
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_content},
        ],
        temperature=temp,
    )

    if not response or "choices" not in response:
        raise RuntimeError("Failed to get valid response from LLM")

    return str(response["choices"][0]["message"]["content"])

로컬 검색

로컬 검색 메서드는 지식 그래프를 사용하여 엔티티 중심 검색을 구현합니다. 이 메서드는 먼저 직렬화된 그래프를 로드하고, 쿼리에서 엔티티를 추출하고, 그래프에서 관련 데이터(엔티티, 관계, 텍스트 청크)를 수집하고, 컨텍스트 문자열을 구성한 뒤, LLM을 사용하여 응답을 생성합니다. 이 접근 방식은 특정 엔티티와 그 관계에 대한 쿼리에 특히 효과적입니다.

def local_search(self, query: str, graph_path: str) -> str:
    g = self.load_graph(graph_path)
    query_entities = self._extract_query_entities(query)
    relevant_data = self._gather_relevant_data(g, query_entities)
    context = self._build_context(relevant_data)
    return self._generate_response(query, context)

이 헬퍼 메서드는 로컬 검색을 위해 지식 그래프에서 관련 정보를 수집합니다. 쿼리 엔티티와 그래프를 입력으로 받은 다음, 일치하는 노드와 그 관계, 그리고 연관된 텍스트 청크를 수집합니다. 저희 구현에서는 관련 엔티티를 찾기 위해 대소문자를 구분하지 않는 단순한 부분 문자열 매칭을 사용하지만, 프로덕션 시스템에서는 정확도와 커버리지를 높이기 위해 더 정교한 엔티티 매칭 기법을 사용할 가능성이 높습니다. 이 메서드는 일치하는 엔티티를 바탕으로 쿼리에 대한 포괄적인 컨텍스트를 구성합니다.

def _gather_relevant_data(
    self, graph: nx.Graph, query_entities: List[str]
) -> RelevantData:
    relevant_data = RelevantData()

    for query_entity in query_entities:
        for node in graph.nodes():
            if query_entity.lower() in str(node).lower():
                self._process_matching_node(node, graph, relevant_data)

    return relevant_data

로컬 검색 컨텍스트는 지식 그래프에서 추출한 관련 정보를 LLM이 포괄적으로 파악할 수 있도록 구조화되어 있습니다. 쿼리에서 일치한 엔티티, 그래프에서 찾은 그 관계, 그리고 근거가 되는 텍스트 청크를 받아 명확한 템플릿으로 정리합니다. 엔티티 섹션에는 그래프에서 쿼리와 관련하여 일치한 모든 노드가 나열됩니다. 관계 섹션에는 이러한 엔티티들이 그래프 엣지를 통해 서로 어떻게 연결되어 있는지가 자세히 기술됩니다. 마지막으로 근거 텍스트 섹션에는 이러한 엔티티 및 관계와 연관된 실제 문서 텍스트 청크가 포함됩니다. 이렇게 구조화된 컨텍스트는 LLM이 엔티티 간의 의미적 관계와 텍스트 설명을 모두 이해하도록 도와, 더 정확하고 문맥에 적합한 응답을 생성할 수 있게 합니다.

LOCAL_SEARCH_CONTEXT = """
Relevant Entities: {entities}

Relationships:
{relationships}

Supporting Text:
{text_chunks}
"""

글로벌 검색

글로벌 검색은 맵-리듀스 방식을 사용하여 문서 커뮤니티를 분석합니다. 먼저 문서에 대한 모든 커뮤니티 요약을 가져온 다음, 맵 단계에서 각 커뮤니티에 대한 중간 답변을 생성합니다. 리듀스 단계에서는 이러한 답변들을 결합하여 일관된 응답을 만듭니다.

def global_search(self, query: str, doc_id: int, limit: int = 5) -> str:
    session = self.SessionLocal()
    try:
        communities = (
            session.query(Community)
            .filter(Community.document_id == doc_id)
            .all()
        )

        intermediate_answers = []
        print("Performing map phase over communities.")

        for community in tqdm(communities):
            answer = self._generate_llm_response(
                GLOBAL_SEARCH_COMMUNITY,
                f"Community Summary:\n{community.content}\n\nQuery: {query}",
                temp=0.7,
            )
            if answer and "No relevant information found" not in answer:
                intermediate_answers.append(answer)

        print("Performing reduce phase over community answers.")
        return self._generate_llm_response(
            GLOBAL_SEARCH_COMBINE,
            f"Query: {query}\n\nAnswers to combine:\n{' '.join(intermediate_answers)}",
            temp=0.7,
        )
    finally:
        session.close()

나이브 RAG

나이브 검색 메서드는 하이브리드 검색(벡터 유사도와 키워드 매칭의 결합)을 사용하는 전통적인 RAG 방식을 구현합니다. 하이브리드 검색 함수를 사용해 관련 텍스트 청크를 가져와 하나의 컨텍스트 문자열로 결합한 뒤, LLM을 사용해 응답을 생성합니다. 이 방법은 그래프 기반 방식보다 단순하지만, 간단한 쿼리에는 효과적일 수 있습니다.

def naive_search(self, query: str, limit: int = 5) -> str:
    from tiny_graphrag.search import hybrid_search
    search_results = hybrid_search(query, limit=limit, engine=self.engine)
    context = "\n\n".join([result.content for result in search_results])
    return self._generate_llm_response(
        NAIVE_SEARCH_RESPONSE,
        f"Context:\n{context}\n\nQuery: {query}"
    )

하이브리드 검색 구현은 벡터 임베딩을 사용하는 시맨틱 검색과 전통적인 키워드 기반 검색이라는 두 가지 서로 다른 검색 전략을 결합합니다. 이는 벡터 유사도를 위한 PostgreSQL의 pgvector 확장과 전문 검색(full-text search) 기능을 활용하는 단일 SQL 쿼리로 구현됩니다. search.py 파일의 Core 쿼리를 살펴봅시다:

WITH semantic_search AS (
    SELECT id, content, document_id,
           RANK () OVER (ORDER BY embedding <=> (:embedding)::vector) AS rank
    FROM document_chunks
    ORDER BY embedding <=> (:embedding)::vector
    LIMIT 20
),
keyword_search AS (
    SELECT id, content, document_id,
           RANK () OVER (ORDER BY ts_rank_cd(to_tsvector('english', content), query) DESC)
    FROM document_chunks, plainto_tsquery('english', :query) query
    WHERE to_tsvector('english', content) @@ query
    ORDER BY ts_rank_cd(to_tsvector('english', content), query) DESC
    LIMIT 20
)
SELECT
    COALESCE(semantic_search.document_id, keyword_search.document_id) AS document_id,
    COALESCE(semantic_search.content, keyword_search.content) AS content,
    COALESCE(1.0 / (:k + semantic_search.rank), 0.0) +
    COALESCE(1.0 / (:k + keyword_search.rank), 0.0) AS score
FROM semantic_search
FULL OUTER JOIN keyword_search ON semantic_search.id = keyword_search.id
ORDER BY score DESC
LIMIT :limit

이 쿼리는 공통 테이블 표현식(CTE)을 사용하여 두 개의 병렬 검색을 수행합니다. 첫 번째 CTE인 semantic_search는 pgvector의 코사인 유사도 연산자 <=>를 사용하여 임베딩이 쿼리 임베딩과 가장 가까운 문서를 찾습니다. 벡터 유사도를 기준으로 결과의 순위를 매기고 상위 20개의 일치 항목을 선택합니다. 두 번째 CTE인 keyword_search는 ts_rank_cd와 함께 PostgreSQL의 전문 검색 기능을 사용하여 키워드 일치를 기준으로 문서를 찾고 순위를 매기며, 마찬가지로 상위 20개의 결과를 선택합니다.

그런 다음 메인 SELECT 문은 FULL OUTER JOIN을 사용하여 이 결과들을 결합하는데, 이를 통해 어떤 문서가 둘 중 한쪽에만 나타나더라도 두 검색 방법의 일치 항목을 모두 확보할 수 있습니다. 바로 여기서 상호 순위 융합(Reciprocal Rank Fusion)이 작동합니다. 이 공식은 다음 점수 계산식으로 구현됩니다:

COALESCE(1.0 / (:k + semantic_search.rank), 0.0) +
COALESCE(1.0 / (:k + keyword_search.rank), 0.0) AS score

RRF 알고리즘은 각 목록에서의 위치에 따라 각 항목에 점수를 부여하는 방식으로 여러 순위 목록을 결합합니다. 상수 k(기본값 60)는 개별 결과 목록에서 높은 순위가 미치는 영향을 완화하는 평활화 계수 역할을 합니다. 각 문서의 최종 점수는 각 출처에서의 (k + 순위)의 역수를 합한 값입니다. COALESCE 함수는 문서가 결과 집합 중 하나에만 나타나는 경우를 처리하며, 누락된 순위에 대해서는 기본값 0을 사용합니다.

이 융합 방식은 두 검색 방법의 장단점을 서로 보완하므로 개선된 방식입니다. 시맨틱 검색은 서로 다른 단어를 사용하더라도 의미가 비슷한 문서를 찾아낼 수 있으며, 키워드 검색은 정확히 일치하는 항목을 놓치지 않도록 보장합니다. RRF 알고리즘은 이러한 신호를 원칙에 따라 결합하는 방법을 제공하며, 두 검색 모두에서 순위가 높은 문서에 더 높은 가중치를 부여하면서도 한쪽 검색에서만 강하게 일치한 결과도 유지합니다. 최종적으로 결과는 결합된 RRF 점수의 내림차순으로 정렬되고 요청된 결과 개수만큼으로 제한됩니다. 이 하이브리드 방식은 시맨틱 검색이나 키워드 검색 중 하나만 사용할 때보다 더 견고한 검색 결과를 제공하며, 특히 관련 정보가 문서 전반에 걸쳐 다양한 방식으로 표현될 수 있는 쿼리에서 그렇습니다.

모두 합치기

이제 모든 것을 구현했습니다. 참조 구현에는 터미널에서 파이프라인을 실행할 수 있는 기본 CLI 인터페이스가 포함되어 있습니다. 또한 임시 Docker 컨테이너에서 pgvector 데이터베이스를 실행하기 위한 Docker Compose 설정도 제공합니다.

데이터베이스를 설정하고 텍스트 문서를 수집하려면 다음을 실행하십시오.

graphrag init
graphrag build data/Barack_Obama.txt

이렇게 하면 Postgres와 ./graphs 폴더의 로컬 그래프 파일로 데이터가 수집됩니다. 검색하려면 query 명령을 사용하십시오.

graphrag query local --graph graphs/1_graph.pkl "What did Barack Obama study at Columbia University?"
graphrag query global --graph graphs/1_graph.pkl --doc-id 1 "What are the main themes of this document?"
graphrag query naive "What efforts did Barack Obama due to combat climate change?"

내부적으로는 앞에서 만든 최상위 Python API를 사용합니다. Python에서 호출하려면 tiny_graphgrag 네임스페이스를 임포트하고 최상위 함수를 호출하십시오.

from tiny_graphrag import QueryEngine, store_document, init_db

# Initialize the database
engine = init_db()

# Process and store a document
doc_id, graph_path = store_document(
    filepath="data/Barack_Obama.txt",
    title="Barack Obama Wikipedia",
    engine=engine
)

# Create query engine with the database connection
query_engine = QueryEngine(engine)

result = query_engine.local_search(
    query="What did Barack Obama study at Columbia University?",
    graph_path=graph_path
)
print("Local Search Result:", result)

result = query_engine.global_search(
    query="What are the main themes of this document?",
    doc_id=doc_id
)
print("Global Search Result:", result)

result = query_engine.naive_search(
    query="What efforts did Barack Obama due to combat climate change?"
)
print("Naive Search Result:", result)

사용자 정의 엔티티 유형

더 특화된 사용 사례를 위해 엔티티 유형과 관계 유형을 재정의할 수 있습니다.

from tiny_graphrag import QueryEngine, store_document, init_db

engine = init_db("postgresql://admin:admin@localhost:5432/tiny-graphrag")

# Define custom geographic entity types
geography_entity_types = [
    "City",
    "Country",
    "Landmark",
    "River",
    "Mountain",
    "Ocean"
]

# Define custom relation types with type constraints
geography_relation_types = {
    "capital_of": {
        "allowed_head": ["City"],
        "allowed_tail": ["Country"]
    },
    "flows_through": {
        "allowed_head": ["River"],
        "allowed_tail": ["City", "Country"]
    },
    "located_in": {
        "allowed_head": ["City", "Landmark"],
        "allowed_tail": ["Country"]
    },
    "borders": {
        "allowed_head": ["Country"],
        "allowed_tail": ["Country", "Ocean"]
    }
}

# Process document with custom types
doc_id, graph_path = store_document(
    filepath="data/geography.txt",
    title="World Geography",
    engine=engine,
    entity_types=geography_entity_types,
    relation_types=geography_relation_types
)

query_engine = QueryEngine(engine)

result = query_engine.local_search(
    query="What rivers flow through Paris?",
    graph_path=graph_path
)
print("Geographic Local Search:", result)

result = query_engine.global_search(
    query="What are the major geographical features of Western Europe?",
    doc_id=doc_id
)
print("Geographic Global Search:", result)

2부

2부에서는 최소한의 1000줄짜리 장난감 구현을 넘어, 프로덕션 환경에 더 적합하도록 장난감 구현에 몇 가지 중요한 개선 사항을 추가합니다. 첫 번째 주요 변경 사항은 직렬화된 networkx 그래프를 제대로 된 그래프 데이터베이스(Memgraph)로 교체하는 것입니다. 그래프 데이터베이스는 관계 기반 쿼리에 특화되어 최적화되어 있으며, 현재의 pickle 파일 기반 방식보다 대규모 그래프를 훨씬 더 효율적으로 처리할 수 있습니다. 또한 그래프 임베딩(node2vec)을 기본적으로 지원하므로, 쿼리에서 구조적 유사도와 의미적 유사도를 결합할 수 있습니다. 이는 직접 연결되어 있지는 않지만 비슷한 관계 패턴을 공유하는 관련 엔티티를 찾을 때 특히 유용합니다.

두 번째로 추가할 사항은 현재 전역 검색 구현의 계산 비용 문제를 해결하는 것입니다. n+1번의 LLM 호출이 필요한 맵리듀스 방식 대신, 커뮤니티 요약에 top-k 클러스터링을 사용합니다. 이를 통해 벡터 임베딩을 기반으로 비슷한 커뮤니티를 함께 묶습니다. 또한 (원래 GraphRAG 참조 구현처럼) 다단계 요약 구조를 만드는 계층적 클러스터링 방식을 구현하여, 상위 수준의 요약에서 시작해 필요할 때만 세부 내용으로 파고들 수 있도록 합니다.

그다음에는 엔티티 해석과 중복 제거를 개선하는 데 집중합니다. 현재 구현은 엔티티를 정확한 문자열 일치로 취급하므로, 같은 엔티티를 가리키는 서로 다른 표현 간의 연결을 놓칠 수 있습니다(예: “Barack Obama”, “President Obama”, “Obama”). 엔티티 매칭에 벡터 유사도 비교를 추가하여, 엔티티 언급의 임베딩과 그 주변 문맥을 함께 사용해 서로 다른 문자열이 같은 엔티티를 가리키는 경우를 식별할 수 있습니다.

성능은 어떨까

GraphRAG는 이론적으로 특정 시나리오에서 나이브 RAG보다 뛰어난 성능을 낼 수 있지만, 두 접근 방식 모두 근본적인 한계를 안고 있어 실제로 사용하기에는 상당히 취약합니다. 나이브 RAG의 효과는 청킹 전략과 임베딩 매개변수를 얼마나 세심하게 조정하느냐에 크게 좌우됩니다. 이러한 최적화는 대개 경험적으로 찾아내야 하며, 서로 다른 도메인이나 문서 유형에 잘 일반화되는 경우가 드뭅니다. GraphRAG는 구조화된 지식 표현을 도입하여 이러한 한계를 해결하려 하지만, 본질적으로는 한 묶음의 어려운 문제를 다른 묶음의 어려운 문제로 맞바꾸는 것에 불과합니다. GraphRAG는 나이브 RAG가 놓칠 수 있는 관계를 포착할 수 있지만, 정확한 엔티티 및 관계 추출에 결정적으로 의존하며, 이 추출 자체가 아직 해결되지 않은 문제입니다. 최종 결과의 품질은 이러한 추출 단계의 정확도에 매우 민감하며, 오류는 파이프라인 전반에 걸쳐 누적되는 경향이 있습니다.

두 접근 방식 모두 정확하고 맥락에 맞는 답변을 내놓으려면 일련의 통계적 “기적“이 완벽하게 맞아떨어져야 합니다. 청킹이 딱 알맞아야 하고, 임베딩이 관련된 의미적 관계를 포착해야 하며, LLM이 맥락을 올바르게 해석해야 하고, GraphRAG의 경우에는 엔티티 및 관계 추출까지 정확해야 합니다. 어느 하나의 구성 요소만 실패해도 관련 없는 응답이나 오해를 불러일으키는 응답이 나올 수 있습니다. GraphRAG가 모든 면에서 “더 나은” 것은 아니며, 임베딩 유사도 문제를 엔티티/관계 추출 문제로 맞바꾸는 것일 뿐입니다. 다만 일부 도메인에서는 후자가 더 풀기 쉬울 수 있습니다. 또한 GraphRAG의 전역 검색은 계산 비용이 훨씬 더 크며, 맵리듀스 연산을 수행하기 위해 LLM을 n+1번(여기서 n은 그래프의 커뮤니티 수) 호출해야 하므로 큰 문서에서는 그 비용이 상당할 수 있습니다.

두 접근 방식 중 하나를 구현하려는 분이라면, 이 시스템의 최종 사용자가 이러한 블랙박스 모델의 연쇄에 의존할 때 상당한 수준의 잠재적 부정확성과 맥락에서 벗어난 답변을 감수해야 한다는 점을 반드시 이해해야 합니다. 그래프 기반이든 나이브 방식이든, 현재 RAG의 수준은 정확성과 일관성이 중요한 프로덕션 시스템에 쓰기에는 충분히 신뢰할 만하지 않습니다. 이러한 접근 방식은 실험이나 연구 목적으로는 흥미로울 수 있지만, 프로덕션 정보 검색 시스템에서 믿고 쓸 수 있는 솔루션이 되기에는 아직 성숙하지 않았습니다.

GraphRAG는 흔히 홍보되는 것처럼 만병통치약 같은 알고리즘이 아닙니다. GraphRAG는 “환각을 해결“하지도 않으며, 언어 모델을 최종 합성기로 사용하는 모든 RAG 접근 방식이 겪는 “사전 학습 오버플로 문제“를 고쳐 주지도 않습니다. 그러나 위험이 낮은 일부 특정 도메인에서는 더 유용할 수 있으며, 나이브 RAG 구현보다 나은 결과를 보일 수도 있습니다. 결론적으로, GPU 크레딧을 태워 가며 멀쩡한 문서를 신뢰할 수 없는 답변으로 바꾸는 방법을 찾고 계신다면, 나이브 RAG와 GraphRAG 모두 훌륭한 선택입니다.


원문 © Stephen Diehl, stephendiehl.com · 글 CC BY-SA 4.0, 코드 MIT · Stephen Diehl은 런던의 소프트웨어 엔지니어이자 작가입니다(저자가 정한 소개 문구). 이 글은 저자가 개인 자격으로 쓴 것이며 어떤 조직과의 연관도 밝히거나 암시해서는 안 됩니다 (라이선스와 고지).
이 페이지는 원문을 한국어로 옮기며 변경한 비공식 번역이며 같은 CC BY-SA 4.0으로 제공합니다 · yeokja 한국어 번역 · Anthropic 사의 claude-opus-5-5 모델을 활용하여 번역되었으며 학습을 모두 비허용한 상태로 작업하였습니다.