728x90
반응형

GPT-4를 도메인 전문가처럼 활용해서 비디오 대본에서 지식을 추출해 볼까요?

며칠 전에 GPT-4를 사용할 수 있게 되었어요. 가장 먼저 해보고 싶었던 건 정보 추출 모델로서 얼마나 잘 작동하는지 테스트하는 거였죠. 여기서 작업은 주어진 텍스트에서 관련 엔터티와 관계를 추출하는 건데요. GPT-3.5도 이미 좀 사용해 봤는데, 제가 알아차린 가장 중요한 점은 GPT 엔드포인트를 엔터티 연결 솔루션으로 사용하거나 인용과 같은 외부 참조를 제시하는 걸 원하지 않는다는 거였어요. 왜냐하면 그런 유형의 정보를 막 환각하거든요.

하지만 GPT-3나 GPT-4의 좋은 점은 다양한 도메인에서 잘 작동한다는 점이에요. 예를 들어 텍스트에서 사람, 조직 또는 위치를 추출하는 데 사용할 수 있죠. 하지만 전용 NLP 모델과의 경쟁에서는 GPT 모델이 두각을 나타내는 부분은 아니라고 생각해요(성능은 괜찮지만요). 대신 GPT 모델의 강점은 제한된 학습 데이터 때문에 다른 오픈 소스 모델이 실패하는 도메인에서 일반화하고 사용할 수 있다는 점이죠.

제 친구 마이클 헝거가 자연 다큐멘터리에서 정보를 추출하는 데 GPT-4를 테스트하는 좋은 아이디어를 줬어요. 저는 생태계와 동물이 육지와 너무 달라서 심해 다큐멘터리를 항상 좋아했거든요. 그래서 수중 다큐멘터리에서 GPT-4 정보 추출 기능을 테스트해 보기로 했어요. 게다가 바다 식물과 생물 사이의 관계를 감지하도록 훈련된 오픈 소스 NLP 모델은 없는 걸로 알아요. 따라서 심해 다큐멘터리는 GPT-4를 사용해서 Knowledge Graph를 구성하는 훌륭한 예시가 될 수 있어요.

모든 코드는 Jupyter 노트북 형태의 GitHub에서 확인할 수 있어요.

데이터세트

다큐멘터리를 가장 쉽게 접할 수 있는 곳은 유튜브죠. GPT-4는 다중 모드(비디오, 오디오 및 텍스트 지원)이지만 현재 버전의 엔드포인트는 텍스트 입력만 지원해요. 따라서 동영상 자체가 아닌 동영상의 오디오 스크립트를 분석할 거예요.

다음 다큐멘터리의 내용을 분석해 볼 건데요.

우선 다큐멘터리의 주제가 마음에 들어요. 둘째, audio2text 모델을 전혀 사용할 필요가 없기 때문에 YouTube 비디오에서 캡션을 추출하는 게 쉽죠. 물론 HuggingFace나 OpenAI의 Whisper에서 사용 가능한 모든 모델을 사용해서 오디오를 텍스트로 변환해도 괜찮아요. 셋째, 이 동영상에는 자동 생성되지 않은 캡션이 있어요. 처음에는 YouTube에서 자동 생성된 캡션에서 정보를 추출하려고 했는데, 최적의 입력이 아닐 수도 있다는 걸 알게 됐어요. 따라서 가능하다면 자동 생성된 YouTube 캡션은 사용하지 않는 게 좋아요.

캡션은 YouTube 대본/자막 라이브러리를 사용해서 직접 가져올 수 있어요. 우리가 할 일은 동영상 ID를 제공하는 것뿐이죠.

from youtube_transcript_api import YouTubeTranscriptApi

video_id = "nrI483C5Tro"
transcript = YouTubeTranscriptApi.get_transcript(video_id)
print(transcript[:5])

대본은 다음과 같은 구조를 가지고 있어요.

[
   {
      "text":"water the liquid that oceans are made of",
      "start":5.46,
      "duration":4.38
   },
   {
      "text":"and it fills endless depths only few will venturexa0xa0",
      "start":12.24,
      "duration":4.92
   },
   {
      "text":"out into the endless open oceanxa0nof this vast underwater world",
      "start":17.16,
      "duration":4.68
   }
]

캡션은 덩어리로 분할되어 비디오 자막으로 사용할 수 있어요. 따라서 시작 시간(start)과 지속 시간(duration) 정보가 텍스트와 함께 제공되죠. `xa0` 및 `n`과 같은 몇 가지 특수 문자도 볼 수 있네요.

GPT-4 endpoint는 요청당 최대 8,000개의 토큰을 지원하지만, 단일 요청에서 전체 기록을 처리하려면 더 많은 토큰이 필요해요. 그래서 transcript를 여러 부분으로 나누어야 하죠. 녹취록을 여러 부분으로 나누어 자막이 없는 시간이 5초 이상일 때 부분의 끝을 결정하고 내레이션을 잠시 중단하기로 결정했어요. 이 접근 방식을 사용해서 모든 연결 텍스트를 함께 유지하고 관련 정보를 단일 섹션에 유지하는 것을 목표로 했답니다.

다음 코드를 사용해서 transcript를 여러 섹션으로 그룹화했어요.

# Split into sections and include start and end timestamps
sections = []
current_section = ""
start_time = None
previous_end = 0
pause_threshold = 5

for line in transcript:
    if current_section and (line["start"] - previous_end > pause_threshold):
        # If there is a pause greater than 5s, we deem the end of section
        end_time = line["start"]
        sections.append(
            {
                "text": current_section.strip(),
                "start_time": start_time,
                "end_time": end_time,
            }
        )
        current_section = ""
        start_time = None
    else:
        # If this is the start of a new section, record the start time
        if not start_time:
            start_time = line["start"]

        # Add the line to the current paragraph
        clean_text = line["text"].replace("n", " ").replace("xa0", " ")
        current_section += " ".join(clean_text.split()) + " "
        # Tag the end of the dialogue
        previous_end = line["start"] + line["duration"]

# If there's a paragraph left at the end, add it to the list of paragraphs
if current_section:
    end_time = transcript[-1]["start"] + transcript[-1]["duration"]
    sections.append(
        {
            "text": current_section.strip().replace("n", " ").replace("xa0", " "),
            "start_time": start_time,
            "end_time": end_time,
        }
    )
# Remove empty paragraphs
sections = [p for p in sections if p["text"]]

섹션 그룹화 결과를 평가하기 위해서 다음 정보를 출력했어요.

# Number of paragraphs
print(f"Number of paragraphs: {len(sections)}")
print(f"Max characters per paragraph: {max([len(el['text']) for el in sections])}")

77개 섹션이 있고 가장 긴 섹션은 1267자네요. GPT-4 토큰 한도 근처에는 전혀 도달하지 않았고, 적어도 이 예에서는 위의 접근 방식이 훌륭한 텍스트 세분성을 제공한다고 생각해요.

GPT-4를 이용한 정보 추출

GPT-4 endpoint는 채팅에 최적화되어 있지만 기존 완료 작업에도 적합해요. 모델이 대화에 최적화되어 있으므로 다음을 제공할 수 있어요. system 메시지는 대화의 맥락을 유지하는 데 도움이 될 수 있는 이전 메시지와 함께 어시스턴트의 동작을 설정하는 데 도움이 되죠. 하지만 텍스트 완성 작업을 위해 GPT-4 endpoint를 사용하고 있으므로 이전 메시지는 제공하지 않아요.

저는 다음 프롬프트를 system 메시지로 사용했어요.

system = "You are an archeology and biology expert helping us extract relevant information."

하지만 `system` 메시지를 제공할 때와 제공하지 않을 때 모델이 거의 동일하게 작동한다는 것을 알았어요. 다음으로, 주어진 텍스트에서 관련 엔터티와 관계를 추출하는 몇 가지 반복을 통해 다음 프롬프트를 개발했답니다.

# Set up the prompt for GPT-3 to complete ``` prompt = """#This a transcript from a sea documentary. #The task is to extract as many relevant entities to biology, chemistry, or archeology. #The entities should include all animals, biological entities, locations. #However, the entities should not include distances or time durations. #Also, return the type of an entity using the Wikipedia class system and the sentiment of the mentioned entity, #where the sentiment value ranges from -1 to 1, and -1 being very negative, 1 being very positive #Additionally, extract all relevant relationships between identified entities. #The relationships should follow the Wikipedia schema type. #The output of a relationship should be in a form of a triple Head, Relationship, Tail, for example #Peter, WORKS_AT, Hospital/n # An example "St. Peter is located in Paris" should have an output with the following format entity St. Peter, person, 0.0 Paris, location, 0.0 relationships St.Peter, LOCATED_IN, Parisn""" ```

GPT-4는 주어진 텍스트에서 관련 엔터티를 추출하라는 메시지를 표시해요. 또한 거리와 지속 시간을 엔터티로 처리해서는 안 된다는 몇 가지 제약 조건을 추가했죠. 추출된 엔터티에는 이름, 유형 및 감정이 포함되어야 해요. 관계는 트리플 형태로 제공되어야 하고요. 모델이 Wikipedia 스키마 유형을 따라야 한다는 힌트도 추가했는데, 이렇게 하면 추출된 관계 유형이 좀 더 표준화될 거예요. 출력의 예를 제공하는 것이 항상 좋다는 것을 배웠는데, 그렇지 않으면 모델이 마음대로 다른 출력 형식을 사용할 수 있거든요.

한 가지 주목할 점은 추출된 엔터티와 관계에 대한 멋진 JSON 표현을 제공하도록 모델에 지시했을 수도 있다는 거예요. 잘 구조화된 데이터는 확실히 플러스가 될 수 있지만, API 비용은 입력 및 출력 토큰 수별로 계산되므로 잘 구성된 JSON 객체에 대한 비용을 지불하게 되는 셈이죠. 따라서 JSON 상용구에는 가격이 함께 제공된다는 점, 기억해 주세요.

다음으로 GPT-4 엔드포인트를 호출하고 응답을 처리하는 함수를 정의해야 해요.

@retry(tries=3, delay=5)
def process_gpt4(text):
    paragraph = text

    completion = openai.ChatCompletion.create(
        model="gpt-4",
        # Try to be as deterministic as possible
        temperature=0,
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": prompt + paragraph},
        ],
    )

    nlp_results = completion.choices[0].message.content
    
    if not "relationships" in nlp_results:
        raise Exception(
            "GPT-4 is not being nice and isn't returning results in correct format"
        )
    
    return parse_entities_and_relationships(nlp_results)

프롬프트에서 출력 형식을 명시적으로 정의했지만, GPT-4 모델은 때때로 자체 작업을 수행하고 규칙을 따르지 않아요. 수백 건의 요청 중 딱 두 번만 그런 일이 일어났지만, 그런 일이 발생하면 짜증나고 모든 다운스트림 데이터 흐름이 의도한 대로 작동하지 않게 되죠. 따라서 응답에 대한 간단한 확인을 추가하고 이러한 경우를 대비해 재시도 데코레이터를 추가했어요.

게다가 저는 단지 temperature 모델이 가능한 한 결정적으로 동작하도록 만드는 매개변수인데요. 하지만 녹취록을 몇 번 다시 실행해 보니 약간 다른 결과가 나왔어요. GPT-4를 사용하여 선택한 동영상의 스크립트를 처리하는 데 약 1.6달러가 소요되네요.

Graph 모델 및 가져오기

저희는 정보 추출 파이프라인의 결과를 저장하기 위해 Neo4j를 사용할 거예요. 무료 Neo4j Sandbox 인스턴스를 사용하거나, 무료 AuraDB, 또는 로컬 데스크탑 환경을 사용할 수도 있어요.

한 가지 확실한 건, 완벽한 NLP 모델은 없다는 거죠. 따라서 우리는 추출된 모든 엔터티와 관계가 추출된 텍스트를 가리키도록 하여 필요한 경우 정보의 유효성을 확인할 수 있기를 원해요.

추출된 엔터티와 관계를 관련 텍스트로 가리키고 싶으니까, 그래프에 비디오와 함께 섹션을 포함해야 해요. 섹션 `Node`에는 텍스트, 시작 및 종료 시간이 포함될 거예요. 그러면 엔터티와 관계가 섹션 `Node`에 연결되겠죠. 직관에 어긋날 수 있는 점은 추출된 관계를 그래프의 `Node`로 표현한다는 건데요. 그 이유는 Neo4j가 다른 관계를 가리키는 관계를 허용하지 않기 때문이에요. 하지만 우리는 추출된 관계와 원본 텍스트 사이의 연결을 원하잖아요? 따라서 추출된 관계를 별도의 `Node`로 모델링해야 하는 거죠.

그래프 가져오기를 위한 Cypher 쿼리는 다음과 같아요.

import_query = """
MERGE (v:Video {id:$videoId})
CREATE (v)-[:HAS_SECTION]->(p:Section)
SET p.startTime = toFloat($start),
    p.endTime = toFloat($end),
    p.text = $text
FOREACH (e in $entities |
  MERGE (entity:Entity {name: e[0]})
  ON CREATE SET entity.type = e[1] 
  MERGE (p)-[:MENTIONS{sentiment:toFloat(e[2])}]->(entity))
WITH p
UNWIND $relationships AS relation
MERGE (source:Entity {name: relation[0]})
MERGE (target:Entity {name: relation[2]})
MERGE (source)-[:RELATIONSHIP]->(r:Relationship {type: relation[1]})-[:RELATIONSHIP]->(target)
MERGE (p)-[mr:MENTIONS_RELATIONSHIP]->(r)
"""

마지막으로 다음 코드를 사용해서 전체 기록을 처리하고 추출된 정보를 Neo4j로 가져올 수 있어요.

with driver.session() as session:
    for i, section in enumerate(sections):
        print(f"Processing {i} paragraph")
        text = section["text"]
        start = section["start_time"]
        end = section["end_time"]
        entities, relationships = process_gpt4(text)
        params = {
            "videoId": video_id,
            "start": start,
            "end": end,
            "text": text,
            "entities": entities,
            "relationships": relationships,
        }
        session.run(import_query, params)

Neo4j Browser를 열고 다음 Cypher 쿼리를 실행해서 가져오기가 잘 되었는지 확인할 수 있어요.

CALL apoc.meta.graph()

메타 그래프 프로시저는 다음 그래프 시각화를 반환해야 하죠.

GPT-4를 통한 Entity Disambiguation

GPT-4 결과를 살펴본 후 간단한 Entity Disambiguation을 수행하는 게 좋겠다고 판단했어요. 예를 들어 현재 Moray Eel에는 5개의 Node가 있거든요.

모든 Entity를 소문자로 바꾸고 다양한 Natural Language Processing 기술을 사용해서 어떤 Node가 동일한 Entity를 참조하는지 식별할 수 있어요. 하지만 GPT-4 엔드포인트를 사용해서 Entity Disambiguation을 수행할 수도 있죠. Entity Disambiguation을 수행하기 위해 다음과 같은 Prompt를 작성했어요.

disambiguation_prompt = """
#Act as a entity disambiugation tool and tell me which values reference the same entity. 
#For example if I give you
#
#Birds
#Bird
#Ant
#
#You return to me
#
#Birds, 1
#Bird, 1
#Ant, 2
#
#As the Bird and Birds values have the same integer assigned to them, it means that they reference the same entity.
#Now process the following valuesn
"""

아이디어는 동일한 Entity를 참조하는 Node에 동일한 정수를 할당하는 거예요. 이 Prompt를 사용하면 모든 Node에 추가 태그를 지정할 수 있어요. disambiguation Property 말이죠.

def disambiguate(entities):
    completion = openai.ChatCompletion.create(
        model="gpt-4",
        # Try to be as deterministic as possible
        temperature=0,
        messages=[
            {"role": "user", "content": disambiguation_prompt + "n".join(all_animals)},
        ],
    )

    disambiguation_results = completion.choices[0].message.content
    return [row.split(", ") for row in disambiguation_results.split("n")]

all_animals = run_query("""
MATCH (e:Entity {type: 'animal'})
RETURN e.name AS animal
""")['animal'].to_list()


disambiguation_params = disambiguate(all_animals)
run_query(
    """
UNWIND $data AS row
MATCH (e:Entity {name:row[0]})
SET e.disambiguation = row[1]
""",
    {"data": disambiguation_params},
)

이제 disambiguation 정보가 데이터베이스에 있으니, 이걸 사용해서 결과를 평가할 수 있어요.

MATCH (e:Entity {type:"animal"})
RETURN e.disambiguation AS i, collect(e.name) AS entities
ORDER BY size(entities) DESC
LIMIT 5

이 disambiguation이 엄청 복잡한 건 아니지만, Natural Language Processing 지식이나 직접 만든 규칙을 개발하지 않고도 이걸 해낼 수 있다는 점은 주목할 만하죠?

분석

이 블로그 게시물의 마지막 단계에서는 GPT-4 모델을 사용해서 정보 추출 파이프라인의 결과를 평가해볼게요.

먼저 추출된 Entity의 종류와 개수를 살펴볼까요?

MATCH (e:Entity)
RETURN e.type AS type, count(*) AS count
ORDER BY count DESC
LIMIT 5

대부분의 개체는 동물, 위치, 생물학적 개체 등이에요. 하지만 모델이 공백을 사용하거나, 생물학적 개체에 밑줄을 사용하는 경우도 있다는 걸 알 수 있죠.

GPT 엔드포인트를 실험하면서 가장 좋은 접근 방식은 어떤 정보를 어떻게 분류할지 최대한 구체적으로 지정하는 것임을 깨달았어요. 그래서 GPT-4를 사용해서 추출하려는 엔터티 유형을 정의하는 게 좋다고 생각해요. 결과 유형이 더 일관성이 있거든요.

또, 모델이 33가지 항목 유형을 분류하지 못하는 경우도 있었어요. 문제는 요청 시 GPT-4가 이러한 엔터티에 대한 일부 유형을 제시할 수 있다는 점이에요. 하지만 엔터티 유형이 요청되지 않는 결과의 관계 추출 부분에만 나타나는 거죠. 한 가지 해결 방법은 관계 추출 부분에서도 항목 유형을 요청하는 거예요.

다음으로 영상에서 어떤 동물이 가장 많이 언급되는지 한번 살펴볼까요?

MATCH (e:Entity {type:"animal"})
RETURN e.name AS entity, e.type AS type,
       count{(e)<-[:MENTIONS]-()} AS mentions
ORDER BY mentions DESC
LIMIT 5

가장 많이 언급되는 동물은 곰치, 라이온 피시, 부서지기 쉬운 별이네요. 저는 장어만 잘 알고 있어서 다른 물고기에 대해서는 다큐멘터리를 보시는 것도 좋을 것 같아요.

곰치와 관련해서 어떤 관계나 사실이 추출되었는지 평가할 수도 있어요.

MATCH (e:Entity {name:"morays"})-[:RELATIONSHIP]->(r)-[:RELATIONSHIP]->(target)
RETURN e.name AS source, r.type AS relationship, target.name AS target,
       count{(r)<-[:MENTIONS_RELATIONSHIP]-()} AS mentions
UNION ALL
MATCH (e:Entity {name:"morays"})<-[:RELATIONSHIP]->(r)<-[:RELATIONSHIP]-(source)
RETURN source.name AS source, r.type AS relationship, e.name AS target,
       count{(r)<-[:MENTIONS_RELATIONSHIP]-()} AS mentions

곰치에 관해 우리가 배울 수 있는 건 꽤 많네요. 그루퍼와 협력하고 Triggerfishes와 공존하며 청소 새우에 의해 청소되고 있어요. 또한 암컷 곰치를 찾는 곰치가 관련성이 있을 수도 있대요.

예를 들어, 곰치가 lionfish와 상호 작용하는 관계가 정확한지 확인하고 싶다고 가정해 볼게요. 원본 텍스트를 검색하고 주장을 수동으로 확인할 수 있죠.

MATCH (e:Entity)-[:RELATIONSHIP]->(r)-[:RELATIONSHIP]->(t:Entity)
WHERE e.name = "morays" AND r.type = "INTERACTS_WITH" AND t.name = "Lionfish"
MATCH (r)<-[:MENTIONS_RELATIONSHIP]-(s:Section)
RETURN s.text AS text
ly tough are its cousins the scorpion fishes they 
lie there as if dead especially when others around
them freak out and even when moray eels fight with lionfishes for food

본문에는 장어가 먹이를 두고 라이온피시와 싸운다고 언급되어 있어요. 우리는 또한 그 내용이 심지어 사람에게도 읽고 이해하기 어렵다는 것을 알 수 있죠. 따라서 우리는 인간조차도 어려움을 겪을 수 있는 성적표를 훌륭하게 처리한 GPT-4를 칭찬할 수 있어요.

마지막으로, 우리가 보고 싶은 관련 엔터티가 있는 섹션의 타임스탬프를 반환하는 검색 엔진으로 Knowledge Graph를 사용할 수 있어요. 예를 들어, lionfish가 언급된 섹션의 모든 타임스탬프를 반환하도록 데이터베이스에 요청할 수 있죠.

MATCH (e:Entity {name:"Lionfish"})<-[:MENTIONS]-(s:Section)<-[:HAS_SECTION]-(v:Video)
RETURN s.startTime AS timestamp, s.endTime AS endTime,
       "https://youtube.com/watch?v=" + v.id + "&t=" + toString(toInteger(s.startTime)) AS URL
ORDER BY timestamp

요약

다양한 도메인에 걸쳐 일반화하는 GPT-3.5 및 GPT-4 모델의 놀라운 능력은 다양한 데이터 세트를 탐색하고 분석하여 관련 정보를 추출하는 강력한 도구에요. 솔직히 말해서 GPT-4 없이 이 블로그 게시물을 다시 작성하는 데 어떤 엔드포인트를 사용할지 잘 모르겠어요. 제가 아는 한, 바다 생물에 대한 오픈 소스 관계 추출 모델이나 데이터 세트는 없거든요. 따라서 데이터세트에 라벨을 지정하고 사용자 정의 모델을 교육하는 번거로움을 피하기 위해 간단히 GPT 엔드포인트를 활용하면 되는 거죠. 또한 오디오 또는 텍스트 입력을 기반으로 하는 다중 모드 분석에 대한 약속된 기능을 검토할 수 있는 기회를 간절히 기대하고 있어요.

언제나 그렇듯이 코드는 다음에서 사용할 수 있어요: .

Knowledge Graph 구축을 고려 중이신가요?
다운로드개발자 가이드: Knowledge Graph 구축 방법자신있게 구축을 시작하기 위해 알아야 할 모든 것을 단계별로 안내합니다.

  • ChatGPT
  • GPT-4
  • NLP

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

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

728x90
반응형

+ Recent posts