GraphRAG

상업 계약을 위한 에이전트 기반 GraphRAG

hsystems 2026. 4. 22. 09:01
728x90
반응형

LangGraph 에이전트를 사용해서 법률 정보를 Knowledge Graph로 구성하고 답변 정확도를 높여봐요.

모든 비즈니스에서 법적 계약은 당사자 간의 관계, 의무, 책임을 정의하는 기본 문서죠. 파트너십 계약, NDA, 공급업체 계약 등 이러한 문서에는 의사 결정, 위험 관리, 규정 준수를 추진하는 중요한 정보가 담겨 있을 때가 많아요. 하지만 이런 계약에서 통찰력을 탐색하고 추출하는 건 복잡하고 시간이 꽤 걸릴 수 있어요.

이번 포스팅에서는 에이전트 GraphRAG를 사용해서 엔드투엔드 솔루션을 구현하고, 법적 계약을 이해하고 작업하는 프로세스를 간소화하는 방법을 알아볼 거예요. 저는 GraphRAG를 저장소에 저장된 정보를 검색하거나 추론하는 모든 방법을 가리키는 포괄적인 용어라고 생각하는데요. 특히 Knowledge Graph는 더욱 체계적이고 상황에 맞는 응답을 가능하게 해주죠.

Neo4j의 Knowledge Graph로 법적 계약을 구조화하면 쿼리하고 분석하기 쉬운 강력한 정보 저장소를 만들 수 있어요. 여기서 사용자가 계약에 대해 구체적인 질문을 할 수 있는 LangGraph 에이전트를 구축해서 새로운 통찰력을 빠르게 발견할 수 있도록 하는 거죠.

애플리케이션 응답 예시

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

데이터 구조화가 중요한 이유

일부 도메인은 순진한 RAG와 잘 작동하지만, 법적 계약에는 고유한 어려움이 있어요.

순진한 벡터 RAG를 사용해서 관련 없는 계약에서 정보를 가져오는 모습

위에 보이는 것처럼 관련 청크를 검색하기 위해 벡터 인덱스에만 의존하면 관련 없는 계약에서 정보를 가져오는 등의 문제가 생길 수 있어요. 법적 언어는 고도로 구조화되어 있고, 서로 다른 계약에서 비슷한 표현을 사용하면 부정확하거나 오해의 소지가 있는 검색 결과가 나올 수 있기 때문이죠. 이러한 한계 때문에 GraphRAG처럼 더 구조화된 접근 방식이 필요해요. 정확하고 상황에 맞는 검색을 보장해야 하니까요.

GraphRAG를 구현하려면 먼저 Knowledge Graph를 구성해야 해요.

정형 정보와 비정형 정보를 담은 법률 Knowledge Graph

법적 계약에 대한 Knowledge Graph를 구축하려면 문서에서 구조화된 정보를 추출하고, 이걸 원시 텍스트와 함께 저장하는 방법이 필요해요. Large Language Model(LLM)은 계약서를 읽고 당사자, 날짜, 계약 유형, 중요한 조항 같은 주요 세부 사항을 식별해서 도움을 줄 수 있죠. 계약을 단순한 텍스트 덩어리로 취급하는 대신, 기본적인 법적 의미를 반영하는 구조화된 구성 요소로 분해하는 거예요. 예를 들어 LLM은 "ACME Inc.가 2024년 1월 1일부터 월 $10,000를 지불하기로 동의합니다"라는 내용에 지불 의무와 시작 날짜가 모두 포함되어 있다는 걸 인식하고, 이걸 구조화된 형식으로 저장할 수 있어요.

이렇게 구조화된 데이터가 있으면 Knowledge Graph에 저장하는 거예요. 여기서 회사, 계약, 조항 같은 entities는 해당 relationships와 함께 표현되죠. 구조화되지 않은 텍스트도 계속 사용할 수 있지만, 이제 구조화된 레이어를 사용해서 검색을 세분화하고 훨씬 더 정확하게 검색할 수 있어요. 가장 관련성이 높은 텍스트 덩어리를 가져오는 대신, 해당 attributes를 기준으로 계약을 필터링할 수 있는 거죠. 이건 지난달에 체결된 계약 수, 특정 회사와 활성 계약이 있는지 여부 등 순진한 RAG로는 해결하기 어려운 질문에 답할 수 있다는 걸 의미해요. 이런 질문에는 집계 및 필터링이 필요한데, 표준 벡터 기반 검색만으로는 불가능하거든요.

구조화된 데이터와 구조화되지 않은 데이터를 결합하면 검색 시 상황에 맞는 정보를 더 많이 얻을 수 있어요. 사용자가 계약의 지불 조건에 대해 물어볼 때, 관련 없는 계약의 조건을 끌어낼 수 있는 텍스트 유사성에 의존하기보다는 검색이 올바른 계약으로 제한되도록 하는 거죠. 이 하이브리드 접근 방식은 단순한 RAG의 한계를 극복하고, 법률 문서에 대한 훨씬 더 심층적이고 안정적인 분석을 가능하게 해줘요.

그래프 구성

저희는 Large Language Model(LLM)을 활용해서 법률 문서에서 구조화된 정보를 추출하는데요. 이때 계약 이해 Atticus 데이터세트(CUAD)를 사용해요. CUAD는 CC BY 4.0에 따라 라이센스가 부여된 계약 분석을 위해 널리 사용되는 벤치마크 데이터 세트예요. CUAD 데이터세트에는 500개가 넘는 계약이 포함되어 있어서 구조화된 추출 파이프라인을 평가하는 데 아주 이상적이죠.

계약의 토큰 수 분포는 아래 그림에서 확인할 수 있어요.

CUAD 계약의 토큰 수 분포

이 데이터 세트에 있는 대부분의 계약은 토큰 수가 10,000개 미만으로 비교적 짧은 편이에요. 하지만 훨씬 긴 계약도 있어서, 80,000개 토큰에 달하는 경우도 있죠. 이런 긴 계약은 흔하진 않지만, 짧은 계약이 대부분을 차지하고 있어요. 분포를 보면 가파르게 감소하는 걸 알 수 있는데, 이는 장기 계약이 예외라는 걸 의미하죠.

저희는 추출을 위해 Gemini 2.0 Flash를 사용하고 있고, 토큰 입력 제한이 100만 개이기 때문에 이런 계약을 처리하는 건 문제가 되지 않아요. 저희 데이터 세트에서 가장 긴 계약(약 80,000개 토큰)도 모델 용량 안에 충분히 들어오거든요. 대부분의 계약이 훨씬 짧기 때문에, 처리를 위해 문서가 잘리거나 작은 덩어리로 나뉘는 것에 대해 걱정할 필요도 없답니다.

구조화된 데이터 추출

대부분의 상용 LLM에는 Pydantic 객체를 사용하여 출력 스키마를 정의하는 옵션이 있어요. Location 예시를 한번 살펴볼까요?

class Location(BaseModel):
    """
    Represents a physical location including address, city, state, and country.
    """

    address: Optional[str] = Field(
        ..., description="The street address of the location.Use None if not provided"
    )
    city: Optional[str] = Field(
        ..., description="The city of the location.Use None if not provided"
    )
    state: Optional[str] = Field(
        ..., description="The state or region of the location.Use None if not provided"
    )
    country: str = Field(
        ...,
        description="The country of the location. Use the two-letter ISO standard.",
    )

구조화된 출력을 위해 LLM을 사용할 때, Pydantic은 속성 유형을 지정하고 모델의 응답을 안내하는 설명을 제공해서 명확한 스키마를 정의하는 데 도움을 줘요. 각 field에는 str 또는 Optional[str]과 같은 유형과, LLM에 출력 형식 지정 방법을 정확하게 알려주는 설명이 있죠.

예를 들어 Location 모델에서는 다음과 같은 주요 속성을 정의해요. address, city, state, 그리고 country! 어떤 데이터가 예상되고 어떻게 구성되어야 하는지 지정하는 거죠. 예를 들어 country field는 "US", "FR", or "JP"와 같은 두 글자 국가 코드 표준을 따르도록 되어있어요. 'United States' 또는 'USA'와 같이 일관되지 않은 변형 대신에요. 이 원칙은 다른 구조화된 데이터에도 적용되는데, ISO 8601은 날짜를 표준 형식(YYYY-MM-DD)으로 유지하는 것과 같아요.

Pydantic으로 구조화된 출력을 정의함으로써 LLM 응답을 더욱 안정적이고 기계 판독 가능하게 만들 수 있고, 데이터베이스나 API에 쉽게 통합할 수도 있어요. 명확한 field 설명은 모델이 올바른 형식의 데이터를 생성하는 데 도움이 되기 때문에, 사후 처리의 필요성이 줄어든답니다.

Pydantic 스키마 모델은 더 정교해질 수도 있어요. 모델을 예로 들어볼게요. 법적 계약의 주요 세부 사항을 포착해서 추출된 데이터가 표준화된 구조를 따르도록 보장하는 거죠.

class Contract(BaseModel):
    """
    Represents the key details of the contract.
    """

    summary: str = Field(
        ...,
        description=("High level summary of the contract with relevant facts and details. Include all relevant information to provide full picture."
        "Do no use any pronouns"),
    )
    contract_type: str = Field(
        ...,
        description="The type of contract being entered into.",
        enum=CONTRACT_TYPES,
    )
    parties: List[Organization] = Field(
        ...,
        description="List of parties involved in the contract, with details of each party's role.",
    )
    effective_date: str = Field(
        ...,
        description=(
            "Enter the date when the contract becomes effective in yyyy-MM-dd format."
            "If only the year (e.g., 2015) is known, use 2015-01-01 as the default date."
            "Always fill in full date"
        ),
    )
    contract_scope: str = Field(
        ...,
        description="Description of the scope of the contract, including rights, duties, and any limitations.",
    )
    duration: Optional[str] = Field(
        None,
        description=(
            "The duration of the agreement, including provisions for renewal or termination."
            "Use ISO 8601 durations standard"
        ),
    )

    end_date: Optional[str] = Field(
        None,
        description=(
            "The date when the contract expires. Use yyyy-MM-dd format."
            "If only the year (e.g., 2015) is known, use 2015-01-01 as the default date."
            "Always fill in full date"
        ),
    )
    total_amount: Optional[float] = Field(
        None, description="Total value of the contract."
    )
    governing_law: Optional[Location] = Field(
        None, description="The jurisdiction's laws governing the contract."
    )
    clauses: Optional[List[Clause]] = Field(
        None, description=f"""Relevant summaries of clause types. Allowed clause types are {CLAUSE_TYPES}"""
    )

이 계약 스키마는 법적 계약의 주요 세부 사항을 구조화된 방식으로 구성해서, LLM을 통해 더 쉽게 분석할 수 있게 해줘요. 여기에는 기밀 유지 또는 해고와 같은 다양한 유형의 clause가 포함되고, 각 clause에는 간략한 summary가 들어가죠. 관련 parties는 이름, 위치, role과 함께 나열되고, 계약 세부 정보에는 시작일과 종료일, 총 가치, 준거법 등이 포함돼요. 준거법과 같은 일부 attributes는 중첩 model을 사용해서 정의할 수 있어서, 더 자세하고 복잡한 output이 가능하답니다.

중첩된 object 접근 방식은 복잡한 data relationships을 처리하는 일부 AI model에서 잘 작동하는 반면, 다른 AI model에서는 깊이 중첩된 세부 정보로 인해 어려움을 겪을 수도 있어요.

다음 예제를 사용해서 접근 방식을 테스트해 볼 수 있어요. 우리는 LLM을 Fine-tuning하기 위해 LangChain framework를 사용하고 있어요.

llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash")
llm.with_structured_output(Contract).invoke(
    "Tomaz works with Neo4j since 2017 and will make a billion dollar until 2030."
    "The contract was signed in Las Vegas"
)

이는 다음을 output해요:

Contract(
    summary="Tomaz works with Neo4j since 2017 and will make a billion dollar until 2030.",
    contract_type="Service",
    parties=[
        Organization(
            name="Tomaz",
            location=Location(
                address=None,
                city="Las Vegas",
                state=None,
                country="US"
            ),
            role="employee"
        ),
        Organization(
            name="Neo4j",
            location=Location(
                address=None,
                city=None,
                state=None,
                country="US"
            ),
            role="employer"
        )
    ],
    effective_date="2017-01-01",
    contract_scope="Tomaz will work with Neo4j",
    duration=None,
    end_date="2030-01-01",
    total_amount=1_000_000_000.0,
    governing_law=None,
    clauses=None
)

자, 이제 계약 데이터가 구조화된 형식이 되었으니, 이걸 Neo4j로 가져오기 위한 Cypher 쿼리를 정의하고 엔터티, 관계, 그리고 주요 Clause들을 그래프 구조로 매핑해볼까요? 이 단계를 통해 추출된 원시 데이터는 쿼리 가능한 Knowledge Graph로 변환되어 계약 관련 인사이트를 효율적으로 탐색하고 검색할 수 있게 돼요.

UNWIND $data AS row
MERGE (c:Contract {file_id: row.file_id})
SET c.summary = row.summary,
    c.contract_type = row.contract_type,
    c.effective_date = date(row.effective_date),
    c.contract_scope = row.contract_scope,
    c.duration = row.duration,
    c.end_date = CASE WHEN row.end_date IS NOT NULL THEN date(row.end_date) ELSE NULL END,
    c.total_amount = row.total_amount
WITH c, row
CALL (c, row) {
    WITH c, row
    WHERE row.governing_law IS NOT NULL
    MERGE (c)-[:HAS_GOVERNING_LAW]->(l:Location)
    SET l += row.governing_law
}
FOREACH (party IN row.parties |
    MERGE (p:Party {name: party.name})
    MERGE (p)-[:HAS_LOCATION]->(pl:Location)
    SET pl += party.location
    MERGE (p)-[pr:PARTY_TO]->(c)
    SET pr.role = party.role
)
FOREACH (clause IN row.clauses |
    MERGE (c)-[:HAS_CLAUSE]->(cl:Clause {type: clause.clause_type})
    SET cl.summary = clause.summary
)

이 Cypher 쿼리는 다음과 같은 속성을 가진 계약 Node를 생성해서 구조화된 계약 데이터를 Neo4j로 가져오죠. summary, contract_type, effective_date, duration, 그리고 total_amount. 준거법이 지정되면 계약은 Location Node에 연결돼요. 계약에 관련된 당사자는 Party Node로 저장되고, 각 당사자는 Location에 연결되며 계약과 관련된 역할이 할당되죠. 또한, 쿼리는 Clause를 처리해서 Clause Node를 생성하고 해당 유형과 요약을 저장하는 동시에 이를 계약에 연결합니다.

계약을 처리하고 가져온 후, 결과 그래프는 다음 그래프 스키마를 따르게 됩니다.

가져온 법적 그래프 스키마

단일 계약에 대해서도 한번 살펴볼까요?

단일 계약 그래프

이 그래프는 계약(주황색 Node)이 다양한 Clause(빨간색 Node), Party(파란색 Node), Location(보라색 Node)으로 연결되는 계약 구조를 나타내요. 계약에는 갱신 및 종료, 책임 및 면책, 기밀 유지 및 비공개라는 세 가지 Clause가 있네요. Modus Media International과 Dragon Systems, Inc.라는 두 Party가 관련되어 있으며, 각각 해당 Location인 네덜란드(NL)와 미국(US)에 연결되어 있어요. 본 계약은 미국법의 적용을 받습니다. 계약 Node에는 날짜 및 기타 관련 세부 정보가 포함된 추가 메타데이터도 포함되어 있답니다.

CUAD 법적 계약이 포함된 공개 읽기 전용 인스턴스는 다음 자격 증명으로 사용할 수 있어요.

URI: neo4j+s://demo.neo4jlabs.com
username: legalcontracts
password: legalcontracts
database: legalcontracts

Entity Resolution

회사, 개인 및 Location이 참조되는 방식이 다양하기 때문에 법적 계약의 Entity Resolution은 꽤 까다로운 작업이에요. 예를 들어, 회사가 "Acme Inc."로 표시될 수 있는데, 어떤 계약에서는 'Acme Corporation'을, 또 다른 계약에서는 'Acme Corporation'을 사용할 수도 있거든요. 따라서 동일한 Entity를 지칭하는지 확인하는 프로세스가 필요하죠.

한 가지 방법은 텍스트 임베딩이나 Levenshtein 거리 같은 문자열 거리 측정법을 써서 후보 일치 항목을 만들어보는 거예요. 임베딩은 의미론적 유사성을 잡아내고, 문자열 거리는 문자 수준의 차이를 측정하죠. 후보가 식별되면 주소나 세금 ID 같은 메타데이터를 비교하고, 그래프에서 공유 관계를 분석하거나, 중요한 경우 사람이 직접 검토하는 등의 추가 평가가 필요할 수 있어요.

대규모 엔터티를 해결하려면 다음과 같은 오픈 소스 솔루션이 필요해요. Dedupe 같은 솔루션이나, Senzing 같은 상용 도구가 자동화된 방법을 제공하죠. 어떤 접근 방식을 선택할지는 데이터 품질, 정확성 요구 사항, 그리고 수동으로 감독할 수 있는지에 따라 달라져요.

일단 합법적인 그래프가 만들어지면, Agentic GraphRAG 구현으로 넘어갈 수 있어요.

Agentic GraphRAG

에이전트 아키텍처는 복잡성, 모듈성, 추론 기능이 정말 다양해요. 이런 아키텍처의 핵심에는 도구, 메모리, 오케스트레이션 메커니즘으로 보완되는 중앙 추론 엔진 역할을 하는 LLM이 있죠. 가장 큰 차이점은 LLM이 의사 결정을 내릴 때 얼마나 많은 자율성을 가지는지, 그리고 외부 시스템과의 상호 작용이 어떻게 구성되어 있는지예요.

특히 챗봇 같은 구현을 위한 가장 간단하고 효과적인 설계 중 하나는 도구를 사용한 직접적인 LLM 접근 방식이에요. 이 설정에서 LLM은 의사 결정자 역할을 하면서 호출할 도구(있다면)를 동적으로 선택하고, 필요할 때 작업을 다시 시도하고, 복잡한 요청을 처리하기 위해 여러 도구를 순차적으로 실행하죠.

LangGraph 에이전트 아키텍처

이 다이어그램은 간단한 LangGraph 에이전트 워크플로우를 보여줘요. 시작 지점은 __start__이고, LLM이 사용자 입력을 처리하는 보조 노드로 이동하죠. 여기서 어시스턴트는 도구를 호출해서 관련 정보를 가져오거나, 바로 __end__로 넘어가서 상호작용을 끝낼 수 있어요. 도구를 사용하는 경우, 보조자는 다른 도구를 호출할지 아니면 세션을 종료할지 결정하기 전에 응답을 처리해요. 이런 구조 덕분에 에이전트는 응답하기 전에 외부 정보가 필요한 시점을 자율적으로 판단할 수 있죠.

이 접근 방식은 특히 추론 및 자기 수정 기능이 뛰어난 Gemini나 GPT-4o 같은 강력한 상용 모델에 잘 맞아요.

도구

LLM은 강력한 추론 엔진이지만, 그 효과는 외부 도구를 얼마나 잘 활용하느냐에 달려 있는 경우가 많아요. 데이터베이스 쿼리, API, 검색 기능 등 이런 도구들은 사실 검색, 계산 수행, 구조화된 데이터와 상호 작용하는 LLM의 기능을 확장해 주죠.

LLM 도구

다양한 쿼리를 처리할 수 있을 만큼 일반적이면서도 의미 있는 결과를 반환할 수 있을 만큼 정밀한 도구를 설계하는 건 과학이라기보다는 예술에 가까워요. 우리가 실제로 구축하고 있는 건 LLM과 기본 데이터 사이의 의미 계층인 셈이죠. LLM이 Neo4j Knowledge Graph나 데이터베이스 스키마의 정확한 구조를 이해하도록 요구하는 대신, 이런 복잡성을 추상화하는 도구를 정의하는 거예요.

이런 접근 방식을 쓰면 LLM은 계약 정보가 그래프의 Node와 Relationship으로 저장되어 있는지, 아니면 문서 저장소의 원시 텍스트로 저장되어 있는지 알 필요가 없어요. 사용자의 질문에 따라 관련 데이터를 가져오기 위해 올바른 도구를 호출하기만 하면 되죠.

우리의 경우, 계약 검색 도구가 이런 의미 인터페이스 역할을 해요. 사용자가 계약 조건, 의무, 당사자에 대해 질문하면 LLM은 요청을 데이터베이스 쿼리로 변환하고, 관련 정보를 검색해서 LLM이 해석하고 요약할 수 있는 형식으로 표시하는 구조화된 쿼리 도구를 호출하죠. 이렇게 하면 다양한 LLM이 저장 구조에 대한 직접적인 지식이 없어도 계약 데이터와 상호 작용할 수 있는 유연하고 모델에 구애받지 않는 시스템이 만들어져요.

최적의 도구 세트를 설계하기 위한 만능 표준은 없어요. 어떤 모델에서는 잘 작동하는 게 다른 모델에서는 실패할 수도 있죠. 어떤 모델은 모호한 도구 지침을 적절하게 처리하는 반면, 다른 모델은 복잡한 매개변수 때문에 어려움을 겪거나 명시적인 프롬프트가 필요하기도 해요. 일반성과 작업별 효율성 사이의 절충안은 도구 설계에 사용 중인 LLM에 대한 반복, 테스트, Fine-tuning이 필요하다는 걸 의미해요.

계약 분석의 경우, 효과적인 도구는 사용자가 쿼리를 엄격하게 표현하지 않아도 계약을 검색하고 주요 용어를 요약해야 해요. 이런 유연성을 얻으려면 신중한 Prompt Engineering, 강력한 스키마 설계, 그리고 다양한 LLM 기능에 대한 적응이 필요하죠. 모델이 발전함에 따라 도구를 더욱 직관적이고 효과적으로 만들기 위한 전략도 발전할 거예요.

이 섹션에서는 도구 구현에 대한 다양한 접근 방식을 살펴보고, 유연성, 효율성, 다양한 LLM과의 호환성을 비교해 볼 거예요.

제가 선호하는 접근 방식은 동적이고 결정론적으로 Cypher 쿼리를 구성해서 데이터베이스에 대해 실행하는 거예요. 이 방법은 구현 유연성을 유지하면서 일관되고 예측 가능한 쿼리 생성을 보장하죠. 이런 방식으로 쿼리를 구성함으로써 의미 계층을 강화해서 사용자 입력이 데이터베이스 검색으로 원활하게 변환될 수 있도록 하는 거예요. 이렇게 하면 LLM은 기본 데이터 모델을 이해하기보다는 관련 정보를 검색하는 데 집중할 수 있어요.

우리 도구는 관련 계약을 식별하기 위한 것이기 때문에, 다양한 속성을 기반으로 계약을 검색할 수 있는 옵션을 LLM에 제공해야 해요. 입력 설명은 다시 Pydantic 객체로 제공되죠.

class ContractInput(BaseModel):
    min_effective_date: Optional[str] = Field(
        None, description="Earliest contract effective date (YYYY-MM-DD)"
    )
    max_effective_date: Optional[str] = Field(
        None, description="Latest contract effective date (YYYY-MM-DD)"
    )
    min_end_date: Optional[str] = Field(
        None, description="Earliest contract end date (YYYY-MM-DD)"
    )
    max_end_date: Optional[str] = Field(
        None, description="Latest contract end date (YYYY-MM-DD)"
    )
    contract_type: Optional[str] = Field(
        None, description=f"Contract type; valid types: {CONTRACT_TYPES}"
    )
    parties: Optional[List[str]] = Field(
        None, description="List of parties involved in the contract"
    )
    summary_search: Optional[str] = Field(
        None, description="Inspect summary of the contract"
    )
    country: Optional[str] = Field(
        None, description="Country where the contract applies. Use the two-letter ISO standard."
    )
    active: Optional[bool] = Field(None, description="Whether the contract is active")
    monetary_value: Optional[MonetaryValue] = Field(
        None, description="The total amount or value of a contract"
    )

LLM 도구를 사용하면 속성은 목적에 따라 다양한 형태를 취할 수 있어요. 일부 필드는 contract_type, country처럼 간단한 문자열로, 단일 값을 저장하죠. 다른 필드인 parties는 계약에 관련된 여러 엔터티처럼 여러 항목을 허용하는 문자열 목록이에요.

기본 데이터 유형 외에도 속성은 복잡한 객체를 나타낼 수도 있어요. 예를 들어 monetary_value는 통화 유형, 연산자 등 구조화된 데이터가 포함된 MonetaryValue 객체를 사용하죠. 중첩된 객체가 있는 속성은 데이터를 명확하고 구조적으로 표현하지만, 모델은 이를 효과적으로 처리하는 데 어려움을 겪는 경향이 있어서 단순하게 유지해야 해요.

이 프로젝트의 일환으로 우리는 추가 기능인 cypher_aggregation 속성을 실험하고 있어요. 특정 필터링이나 집계가 필요한 시나리오에 대해 LLM에 더 큰 유연성을 제공하죠.

cypher_aggregation: Optional[str] = Field(
    None,
    description="""Custom Cypher statement for advanced aggregations and analytics.

    This will be appended to the base query:
    ```
    MATCH (c:Contract)
    <filtering based on other parameters>
    WITH c, summary, contract_type, contract_scope, effective_date, end_date, parties, active, monetary_value, contract_id, countries
    <your cypher goes here>
    ```
    
    Examples:
    
    1. Count contracts by type:
    ```
    RETURN contract_type, count(*) AS count ORDER BY count DESC
    ```
    
    2. Calculate average contract duration by type:
    ```
    WITH contract_type, effective_date, end_date
    WHERE effective_date IS NOT NULL AND end_date IS NOT NULL
    WITH contract_type, duration.between(effective_date, end_date).days AS duration
    RETURN contract_type, avg(duration) AS avg_duration ORDER BY avg_duration DESC
    ```
    
    3. Calculate contracts per effective date year:
    ```
    RETURN effective_date.year AS year, count(*) AS count ORDER BY year
    ```
    
    4. Counts the party with the highest number of active contracts:
    ```
    UNWIND parties AS party
    WITH party.name AS party_name, active, count(*) AS contract_count
    WHERE active = true
    RETURN party_name, contract_count
    ORDER BY contract_count DESC
    LIMIT 1
    ```
    """

cypher_aggregation 속성을 사용하면 LLM이 고급 집계 및 분석을 위한 사용자 정의 Cypher 문을 정의할 수 있어요. 질문별 집계 논리를 추가하여 기본 쿼리를 확장해서 유연한 필터링 및 계산이 가능하게 하는 거죠.

이 기능은 유형별 계약 계산, 평균 계약 기간 계산, 시간 경과에 따른 계약 분포 분석, 계약 활동을 기반으로 주요 당사자 식별과 같은 사용 사례를 지원해요. LLM은 이 속성을 활용해서 사전 정의된 쿼리 구조 없이도 특정 분석 요구 사항에 맞는 통찰력을 동적으로 생성할 수 있죠.

이러한 유연성은 가치가 있지만, 적응성이 향상되면 작업의 복잡성이 추가되어 일관성과 견고성이 감소하므로 신중하게 평가해야 해요.

LLM에 함수를 제시할 때 함수의 이름과 설명을 명확하게 정의해야 해요. 잘 구조화된 설명은 모델이 함수를 올바르게 사용하도록 안내해서, 모델이 해당 목적, 예상 입력 및 출력을 이해하도록 돕죠. 이렇게 하면 모호함이 줄어들고 의미 있고 신뢰할 수 있는 쿼리를 생성하는 LLM의 기능이 향상돼요.

class ContractSearchTool(BaseTool):
    name: str = "ContractSearch"
    description: str = (
        "useful for when you need to answer questions related to any contracts"
    )
    args_schema: Type[BaseModel] = ContractInput

마지막으로, 주어진 입력을 처리하고 해당 Cypher 문을 구성하고 효율적으로 실행하는 함수를 구현해야 해요.

함수의 핵심 논리는 Cypher 문을 구성하는 데 초점을 맞춰요. 쿼리의 기초로 계약을 일치시키는 것부터 시작하죠.

cypher_statement = "MATCH (c:Contract) "

다음으로 입력 매개변수를 처리하는 함수를 구현해야 해요. 이 예에서는 주로 속성을 사용하여 지정된 기준에 따라 계약을 필터링하죠.

예를 들어, contract_type 속성은 간단한 node 속성 필터링을 수행하는 데 사용돼요.

if contract_type:
    filters.append("c.contract_type = $contract_type")
    params["contract_type"] = contract_type

이 코드는 다음에 대한 Cypher 필터를 추가합니다. contract_type 값에 쿼리 매개변수를 사용하는 동안 쿼리 삽입 보안 문제를 방지하기 위해서죠.

가능한 계약 유형 값이 속성 설명에 표시되므로:

contract_type: Optional[str] = Field(
    None, description=f"Contract type; valid types: {CONTRACT_TYPES}"
)

LLM이 이를 처리하므로 입력 값을 유효한 계약 유형으로 매핑하는 것에 대해 걱정할 필요가 없어요.

우리는 LLM이 Knowledge Graph와 상호 작용할 수 있는 도구를 구축하고 있어요. 여기서 도구는 구조화된 쿼리에 대한 추상화 계층 역할을 하죠. 핵심 기능은 온톨로지와 유사하지만 동적으로 계산되는 추론된 속성을 런타임에 사용하는 기능이에요.

if active is not None:
    operator = ">=" if active else "<"
    filters.append(f"c.end_date {operator} date()")

여기서, active 계약이 진행 중인지 여부를 결정하는 런타임 분류 역할을 해요 (>= date()) 또는 만료됨 (< date()). 이 논리는 필요한 경우에만 속성을 계산하여 구조화된 쿼리를 확장하므로 보다 유연한 LLM 추론이 가능하죠. 도구 내에서 이와 같은 논리를 처리함으로써 LLM이 단순화되고 직관적인 작업과 상호 작용하여 쿼리 공식화보다는 추론에 집중할 수 있도록 해요.

필터링은 특정 당사자가 관련된 계약으로 결과를 제한하는 등 인접 node에 따라 달라지는 경우도 있어요. 그만큼 parties 속성은 선택적 목록이며 제공되면 해당 엔터티에 연결된 계약만 고려되도록 하죠.

if parties:
    parties_filter = []
    for i, party in enumerate(parties):
        party_param_name = f"party_{i}"
        parties_filter.append(
            f"""EXISTS {{
            MATCH (c)<-[:PARTY_TO]-(party)
            WHERE toLower(party.name) CONTAINS ${party_param_name}
        }}"""
        )
        params[party_param_name] = party.lower()

이 코드는 관련 당사자를 기준으로 계약을 필터링하여 논리를 다음과 같이 처리합니다. AND, 즉 계약이 포함되려면 지정된 모든 조건이 충족되어야 함을 의미하죠. 제공된 당사자 목록을 반복하고 각 당사자 조건이 유지되어야 하는 쿼리를 구성해요.

충돌을 방지하기 위해 각 당사자에 대해 고유한 매개변수 이름이 생성돼요. 그만큼 EXISTS 조항은 계약이 PARTY_TO 이름에 지정된 값이 포함된 당사자와의 관계. 대소문자를 구분하지 않는 일치를 허용하기 위해 이름은 소문자로 변환돼요. 각 당사자 조건은 별도로 추가되어 암시적 조건을 적용합니다. AND 그들 사이에.

지원과 같이 더 복잡한 로직이 필요한 경우 OR 조건이 있거나 다른 일치 기준을 허용하는 경우 입력을 변경해야 해요. 단순한 당사자 이름 목록 대신 연산자를 지정하는 구조화된 입력 형식이 필요하죠.

또한 사소한 오타를 허용하는 파티 매칭 방법을 구현하여 철자와 형식의 변형을 처리함으로써 사용자 경험을 향상시킬 수 있어요.

더 많은 유연성을 추가하기 위해 연산자 개체를 중첩된 속성으로 도입하여 필터링 논리를 더 많이 제어할 수 있어요. 하드코딩 비교 대신 연산자에 대한 열거형을 정의하고 이를 동적으로 사용하죠.

예를 들어, 금전적 가치가 있는 경우 총 금액이 지정된 값보다 큰지, 작은지 또는 정확히 같은지를 기준으로 계약을 필터링해야 할 수 있어요. 고정된 비교 논리를 가정하는 대신 가능한 연산자를 나타내는 열거형을 정의합니다.

class NumberOperator(str, Enum): EQUALS = "=" GREATER_THAN = ">" LESS_THAN = "<" class MonetaryValue(BaseModel): """The total amount or value of a contract""" value: float operator: NumberOperator if monetary_value: filters.append(f"c.total_amount {monetary_value.operator.value} $total_value") params["total_value"] = monetary_value.value

이 접근 방식은 시스템의 표현력을 훨씬 높여줘요. 빡빡한 필터링 규칙 대신 도구 인터페이스를 사용하면 LLM이 값뿐만 아니라 비교 방법까지 지정할 수 있거든요. 이렇게 하면 LLM의 상호 작용을 간단하고 선언적으로 유지하면서도 더 넓은 범위의 쿼리를 쉽게 처리할 수 있죠.

일부 LLM은 중첩된 객체를 입력으로 사용하는 데 어려움을 겪기 때문에 구조화된 연산자 기반 필터링을 처리하기가 쉽지 않아요. 추가로 between 연산자는 두 개의 별도 값이 필요하므로 구문 분석 및 입력 유효성 검사가 더 복잡해질 수 있죠.

최소 및 최대 속성

저는 보통 간단하게 하려고 minmax 날짜 속성을 사용하곤 해요. 이렇게 하면 자연스럽게 범위 필터링을 지원하고 between 로직도 간단해지거든요.

if min_effective_date:
    filters.append("c.effective_date >= date($min_effective_date)")
    params["min_effective_date"] = min_effective_date
if max_effective_date:
    filters.append("c.effective_date <= date($max_effective_date)")
    params["max_effective_date"] = max_effective_date

이 기능은 선택적인 하한 및 상한 조건을 추가해서 유효 날짜 범위를 기준으로 계약을 필터링해줘요. min_effective_datemax_effective_date가 제공되면 지정된 날짜 범위 내의 계약만 포함되는 거죠.

속성은 Semantic Search에도 사용할 수 있어요. 여기서는 Vector Embedding을 미리 사용하는 대신 메타데이터 필터링에 사후 필터링 방식을 사용하는 거죠. 먼저 날짜 범위, 금전적 가치, 정당과 같은 구조화된 필터를 적용해서 후보 집합의 범위를 좁혀요. 그런 다음 필터링된 하위 집합에 대해 벡터 검색을 수행해서 의미적 유사성을 기준으로 결과의 순위를 매기는 거예요.

if summary_search:
    cypher_statement += (
        "WITH c, vector.similarity.cosine(c.embedding, $embedding) "
        "AS score ORDER BY score DESC WITH c, score WHERE score > 0.9 "
    )  # Define a threshold limit
    params["embedding"] = embeddings.embed_query(summary_search)
else:  # Else we sort by latest
    cypher_statement += "WITH c ORDER BY c.effective_date DESC "

이 코드는 summary_search가 제공되는 경우 Semantic Search를 적용해요. 계약 임베딩과 쿼리 임베딩 사이의 코사인 유사성을 계산하고, 관련성에 따라 결과를 정렬하고, 임계값 0.9로 점수가 낮은 일치 항목을 필터링하는 거죠. 그렇지 않으면 기본적으로 가장 최근의 계약을 기준으로 정렬돼요 (effective_date 기준).

Cypher 집계 속성은 LLM에 어느 정도 부분적인 Text2Cypher 기능을 제공해서 초기 구조화된 필터링 후 집계를 동적으로 생성할 수 있도록 테스트하고 싶었던 실험이었어요. 가능한 모든 집계를 미리 정의하는 대신 이 접근 방식을 사용하면 LLM이 필요에 따라 개수, 평균 또는 그룹화된 요약과 같은 계산을 지정해서 쿼리를 더욱 유연하고 표현력 있게 만들 수 있죠. 하지만 이렇게 하면 더 많은 쿼리 로직이 LLM으로 이동하므로 생성된 모든 쿼리가 올바르게 작동하는지 확인하는 것이 중요해요. 형식이 잘못되었거나 호환되지 않는 Cypher 문으로 인해 실행이 중단될 수 있기 때문이죠. 유연성과 안정성 사이의 이러한 절충은 시스템 설계 시 주요 고려 사항이에요.

if cypher_aggregation:
    cypher_statement += """WITH c, c.summary AS summary, c.contract_type AS contract_type, 
      c.contract_scope AS contract_scope, c.effective_date AS effective_date, c.end_date AS end_date,
      [(c)<-[r:PARTY_TO]-(party) | {party: party.name, role: r.role}] AS parties, c.end_date >= date() AS active, c.total_amount as monetary_value, c.file_id AS contract_id,
      apoc.coll.toSet([(c)<-[:PARTY_TO]-(party)-[:LOCATED_IN]->(country) | country.name]) AS countries """
    cypher_statement += cypher_aggregation

암호 집계가 제공되지 않는 경우 프롬프트가 너무 복잡해지지 않도록 5개의 예시 계약과 함께 식별된 계약의 총 개수를 반환해요. 대규모 결과 세트로 어려움을 겪는 LLM은 별로 쓸모가 없기 때문에 과도한 행을 처리하는 것이 중요하죠. 또한 LLM이 100개의 계약 제목으로 답변을 생성하는 것도 좋은 사용자 경험은 아니에요.

cypher_statement += """WITH collect(c) AS nodes
RETURN {
    total_count_of_contracts: size(nodes),
    example_values: [
      el in nodes[..5] |
      {summary:el.summary, contract_type:el.contract_type, 
       contract_scope: el.contract_scope, file_id: el.file_id, 
        effective_date: el.effective_date, end_date: el.end_date,
        monetary_value: el.total_amount, contract_id: el.file_id, 
        parties: [(el)<-[r:PARTY_TO]-(party) | {name: party.name, role: r.role}], 
        countries: apoc.coll.toSet([(el)<-[:PARTY_TO]-()-[:LOCATED_IN]->(country) | country.name])}
    ]
} AS output"""

이 Cypher statement는 일치하는 모든 계약을 목록으로 수집해서, 총 개수와 요약, 유형, 범위, 날짜, 금전적 가치, 역할이 있는 관련 당사자, 고유한 국가 위치 등 주요 속성이 포함된 최대 5개의 예제 계약을 반환해요.

이제 계약 검색 도구가 구축되었으니, 이걸 LLM에 전달해서 Agent GraphRAG를 구현해볼게요.

에이전트 벤치마크

Agent GraphRAG 구현을 진지하게 고려하고 있다면, 벤치마크뿐만 아니라 전체 프로젝트의 기초로서 평가 데이터 세트가 필요해요. 잘 구성된 데이터 세트는 시스템이 처리해야 하는 범위를 정의하는 데 도움이 되고, 초기 개발이 실제 사용 사례에 부합하도록 보장하죠. 게다가 성능 평가를 위한 유용한 도구가 되어서, LLM이 그래프와 얼마나 잘 상호 작용하고, 정보를 검색하고, 추론을 적용하는지 측정할 수 있어요. 또한 추측이 아닌 명확한 피드백을 통해 쿼리, 도구 사용 및 응답 형식을 반복적으로 구체화할 수 있으므로, 신속한 엔지니어링 최적화에도 필수적이에요. 구조화된 데이터 세트가 없으면 맹목적으로 개선 사항을 정량화하기 어렵고 불일치를 포착하기가 더 어려워져요.

벤치마크 코드는 에서 확인할 수 있어요.

저는 시스템을 평가하는 데 사용할 22개의 질문 목록을 작성했어요. 또한 다음과 같은 새로운 측정항목을 도입할 예정이에요. answer_satisfaction 맞춤 프롬프트가 제공되죠.

answer_satisfaction = AspectCritic(
    name="answer_satisfaction",
    definition="""You will evaluate an ANSWER to a legal QUESTION based on a provided SOLUTION.

Rate the answer on a scale from 0 to 1, where:
- 0 = incorrect, substantially incomplete, or misleading
- 1 = correct and sufficiently complete

Consider these evaluation criteria:
1. Factual correctness is paramount - the answer must not contradict the solution
2. The answer must address the core elements of the solution
3. Additional relevant information beyond the solution is acceptable and may enhance the answer
4. Technical legal terminology should be used appropriately if present in the solution
5. For quantitative legal analyses, accurate figures must be provided

+ fewshots
"""

많은 질문은 많은 양의 정보를 반환할 수 있어요. 예를 들어, 2020년 이전에 서명된 계약을 요청하면 수백 개의 결과가 나올 수 있죠. LLM은 총 개수와 몇 가지 예시 항목을 모두 수신하므로, LLM이 표시하기로 선택한 특정 예시보다는 총 개수에 중점을 두고 평가해야 해요.

벤치마크 결과

제공된 결과는 평가된 모든 모델(Gemini 1.5 Pro, Gemini 2.0 Flash 및 GPT-4o)이 대부분의 도구 호출에서 유사하게 성능이 뛰어나며, GPT-4o가 Gemini 모델(0.82 대 0.77)보다 약간 더 뛰어난 성능을 나타낸다는 것을 보여줘요. 눈에 띄는 차이점은 주로 부분적인 Text2Cypher가 사용될 때, 특히 다양한 집계 작업에서 나타나요.

이는 매우 간단한 22개의 질문에 불과하므로, LLM의 추론 기능을 실제로 탐색하지 못했어요.

또한 LLM은 일반적으로 복잡한 Cypher 쿼리를 직접 생성하는 것보다 Python 코드 생성 및 실행을 더 잘 처리하므로, 집계에 Python을 활용하여 정확성을 크게 향상시킬 수 있는 프로젝트를 봤어요.

웹 애플리케이션

저는 응답을 프런트 엔드로 직접 스트리밍하는 FastAPI에서 호스팅되는 LangGraph를 기반으로 하는 간단한 React 웹 애플리케이션도 구축했어요. 웹 앱을 만드는 데 도움을 준 Anei Gorcic에게 특별한 감사를 드립니다.

다음 명령을 사용하여 전체 스택을 시작할 수 있어요.

docker compose up

그리고 localhost:5173으로 이동하면 돼요.

웹 애플리케이션

요약

LLM은 점점 더 강력한 추론 능력을 갖추게 되면서, 적절한 도구와 함께 사용하면 법적 계약처럼 복잡한 영역을 탐색하는 데 아주 유용한 도우미가 될 수 있어요. 여기서는 실제 계약에서 흔히 볼 수 있는 다양한 조항을 깊이 다루지는 않고, 핵심적인 계약 속성에만 집중해서 겉핥기 식으로 살펴봤어요. 앞으로 조항 적용 범위를 넓히거나 도구 설계 및 상호 작용 전략을 개선하는 등 발전할 여지가 정말 많답니다.

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

이미지

이 게시글에 사용된 모든 이미지는 작성자가 직접 만들었어요.


  • LangChain

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

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

728x90
반응형