소소한 컴퓨터 이야기

Exploring RAG for Tables (이론)

by Cori

해당 포스트는 Medium 'Florian June'이 작성한 Advanced RAG 포스트 시리즈 그 일곱번째 내용을 정리하며, RAG에서 테이블을 관리하기 위한 핵심 기술에 대해 다루고 있다.


RAG(복합 정답 생성, Retrieval-Augmented Generation)을 구현하는 것은 특히 비구조화된 문서에서 테이블을 효과적으로 분석하고 이해하는 데 있어 어려움을 동반한다. 특히 스캔된 문서나 이미지 형식의 문서에서 어려우며, 크게 3가지 도전과제가 있다.

 

Task 1. 문서를 스캔하거나 이미지 문서를 처리하는 복잡성으로 인해 다양한 구조, 비문자 요소의 포함, 손글씨와 인쇄된 내용의 조합 등이 자동으로 테이블 정보를 정확하게 추출하는 데 어려움을 준다. 부정확한 분석은 테이블 구조를 손상시킬 수 있으며, 불완전한 테이블을 임베딩에 사용하면 테이블의 의미 정보를 캡처하지 못할 뿐 아니라 RAG 결과를 쉽게 손상시킬 수 있다.

Task 2. 테이블 캡션을 추출하고 이를 해당 테이블에 효과적으로 연결하는 방법

Task 3. 테이블의 의미 정보를 효과적으로 저장할 수 있는 인덱스 구조를 설계하는 방법

 

Table Parsing

해당 모듈의 주요 기능은 비구조화된 문서나 이미지에서 테이블 구조를 정확하게 추출하는 것이다. 해당 테이블 캡션을 추출하고, 개발자가 테이블 캡션을 테이블과 연관시키기 편리하도록 하는 것이 좋다.

Table Parser, https://miro.medium.com/v2/resize:fit:720/format:webp/1*EbBEUEZk6YN4aeST0gJu3Q.png

method #01. GPT-4V와 같은 다중 모달 LLM을 활용하여 각 PDF 페이지에서 표를 식별하고 정보를 추출한다.

method #02. Table Transformer와 같은 전문 표 감지 모델을 활용하여 표 구조를 구분한다. 

method #03. unstructured와 같은 오픈 소스 프레임워크를 활용하여 객체 감지 모델을 사용한다. 이러한 프레임워크는 전체 문서를 포괄적으로 파싱하고, 파싱 결과에서 표 관련 콘텐츠를 추출할 수 있다.

method #04. Nougat, Donut 등과 같은 엔드 투 엔드 모델을 사용하여 전체 문서를 파싱하고 표 관련 콘텐츠를 추출한다. 이 접근 방식은 OCR 모델이 필요하지 않다.

 

표 정보를 추출하는 데 사용되는 방법과 관계없이 표 캡션을 포함해야 한다는 점은 주목할하다. (대부분의 표 캡션은 표 전체에 대한 요약 내용이라 볼 수 있다 !)

 

Index Structure

이미지 구조에 따라 다르게 인덱스를 설계할 수 있다.

method #01. 이미지 형식의 표만 인덱싱

method #02. 일반 텍스트 또는 JSON 형식의 표만 인덱싱

method #03. LaTeX 형식의 표만 인덱싱

method #04. 표의 요약만 인덱싱

method #05. 소규모 -> 대규모 or 문서 요약 인덱싱

Structure of the small-to-big index (upper) and the document summary index, https://miro.medium.com/v2/resize:fit:828/format:webp/1*olPIDUHXulhN2KXdrnRzug.png

테이블 요약의 경우, 보통 LLM을 사용하여 생성된다.

 

Algorithms that Don’t Require Table Parsing, Indexing, or RAG

일부 알고리즘은 표 파싱이 필요하지 않다.

Method #01. 관련 이미지(PDF 페이지)와 사용자의 쿼리를 VQA 모델(DAN 등) 또는 다중 모달 LLM에 보내고, 답변을 반환한다.

Method #02. 관련 텍스트 형식의 PDF 페이지와 사용자의 쿼리를 LLM에 보내고, 답변을 반환한다.

Method #03. 관련 이미지(PDF 페이지), 텍스트 청크 및 사용자의 쿼리를 다중 모달 LLM(GPT-4V 등)에 보내고, 직접 답변을 반환한다.

 

인덱싱이 필요 없는 몇 가지 방법은 다음 그림에 나와 있다.

method #04. 추출한 이미지 형식의 표를 사용한 후, OCR 모델을 사용하여 표의 모든 텍스트를 인식한다. 그런 다음 모든 텍스트와 사용자의 질문을 LLM에 직접 보내고 답변을 반환한다.

 

RAG 프로세스에 의존하지 않는 몇가지 방법도 있다.

Method #01. LLM을 사용하지 않고 특정 데이터셋을 기반으로 훈련하여 BERT 유사 트랜스포머와 같은 모델이 TAPAS와 같은 표 이해 작업을 더 잘 지원할 수 있도록 한다.

Method #02. LLM을 사용하여 사전 훈련, 미세 조정 방법 또는 프롬프트를 통해 GPT4Table과 같은 표 이해 작업을 수행할 수 있도록 한다.

 

Existing Open Source Solutions

LlamaIndex는 테이블에 대한 네 가지 접근 방식을 제안한다.

Method #01.련 이미지를(PDF 페이지) 검색하고 이를 GPT-4V에 보내서 질문에 답변하게 합니다. 해당 방식은 테이블 파싱이 필요하지 않으며, 결과는 이미지에 답이 있어도 정확한 답변을 생성할 수 없다.

Method #02. 모든 PDF 페이지를 이미지로 간주하고, 각 페이지에 대해 GPT-4V가 이미지 추론을 수행한다. 이미지 추론에 대해 텍스트 벡터 저장소 인덱스를 구축하고, 이미지 추론 벡터 저장소를 조회하여 답변을 찾는다. GPT-4V가 테이블을 식별하고 이미지에서 그 내용을 추출하는 능력이 불안정한데, 특히 이미지에 테이블, 텍스트 및 기타 이미지가 혼합되어 있는 경우(PDF 형식에서 흔히 발생) 더욱 그렇다.

Method #03. Table Transformer를 사용하여 검색된 이미지에서 테이블 정보를 잘라내고, 잘라낸 이미지를 GPT-4V에 보내어 질문에 대한 답변을 받는다. 해당 방식은 인덱싱이 필요하지 않다.

Method #04.라낸 테이블 이미지에 OCR을 적용하고 데이터를 GPT-4 또는 GPT-3.5에 보내서 질문에 답변하게 한다. 해당 방식도 마찬가지로 인덱싱이 필요하지 않으며, 이미지에서 테이블 정보를 추출할 수 없어 잘못된 답변이 생성된다.

 

이 중 세번째 방법이 상대적으로 좋은 효과를 내는 것으로 나타났지만, 사용해보면 성능이 그다지 좋지 않다고 한다. 

Langchain도 몇 가지 솔루션을 제안했으며, 반구조화된 RAG의 주요 기술은 다음과 같다:

  • 테이블 파싱은 unstructured 라이브러리를 사용한다.

Langchain’s semi-structured RAG, https://ai.plainenglish.io/advanced-rag-07-exploring-rag-for-tables-5c3fc0de7af6

반구조화 및 멀티모달 RAG는 세 가지 솔루션을 제안한다.

Option 1. 멀티모달 임베딩(CLIP 등)을 사용하여 이미지와 텍스트를 임베딩하고, 유사성 검색을 통해 둘 다 검색 후 원본 이미지와 청크를 멀티모달 LLM에 전달하여 답변을 합성한다.

Option 2. GPT-4V, LLaVA 또는 FUYU-8b와 같은 멀티모달 LLM을 사용하여 이미지로부터 텍스트 요약을 생성한다. 그런 다음 텍스트를 임베딩하고 검색하여 텍스트 청크를 LLM에 전달하여 답변을 합성한다.

Option 3. GPT-4V, LLaVA 또는 FUYU-8b와 같은 멀티모달 LLM을 사용하여 이미지로부터 텍스트 요약을 생성한 다음, 원본 이미지에 대한 참조와 함께 이미지 요약을 임베딩하고 검색한다. 그런 다음 원본 이미지와 텍스트 청크를 멀티모달 LLM에 전달하여 답변을 합성한다.

 

Proposed Solution

Proposed Solution, https://miro.medium.com/v2/resize:fit:828/format:webp/1*7T-ph4ETQ7j6UsPS19BCNA.png

Nougat은 테이블 캡션을 잘 추출할 수 있어 테이블과 관련짓는것이 매우 편리하다. 이에 테이블 파싱 단계에서는 Nougat을 사용한다. 문서 요약 인덱스 구조는 작은 청크의 내용에는 테이블 요약이 포함되고, 큰 청크의 내용에는 LaTeX 형식의 해당 테이블과 텍스트 형식의 테이블 캡션이 포함된다. 여기서는 이를 multi-vector retriever를 이용하여 구현한다. 테이블 요약의 경우 테이블과 테이블 캡션을 LLM에 보내어 요약을 받는데, 이를 통해 테이블을 효율적으로 파싱하면서 테이블 요약과 테이블 간의 관계를 종합적으로 고려할 수 있다.

 

The principle of Nougat

Nougat은 Donut 아키텍처를 기반으로 개발되었으며, OCR 관련 입력이나 모듈이 필요 없이, 네트워크를 통해 암묵적으로 텍스트를 인식한다. Nougat의 수식 파싱 능력은 매우 뛰어나며, 또한 테이블 파싱에서도 우수한 성능을 발휘한다.

parsing result of nougat, https://miro.medium.com/v2/resize:fit:828/format:webp/1*GCZ6Waj9zB9AR3tEKMinKQ.png

학습데이터를 형식화하는 코드는 다음과 같고, 테이블의 경우 \end{table} 바로 다음이 caption_parts인 것이 일반적이다.

def format_element(
    element: Element, keep_refs: bool = False, latex_env: bool = False
) -> List[str]:
    """
    Formats a given Element into a list of formatted strings.
    Args:
        element (Element): The element to be formatted.
        keep_refs (bool, optional): Whether to keep references in the formatting. Default is False.
        latex_env (bool, optional): Whether to use LaTeX environment formatting. Default is False.
    Returns:
        List[str]: A list of formatted strings representing the formatted element.
    """
    if isinstance(element, Table):
        parts = [
            "[TABLE%s]\n\\begin{table}\n"
            % (str(uuid4())[:5] if element.id is None else ":" + str(element.id))
        ]
        parts.extend(format_children(element, keep_refs, latex_env))
        caption_parts = format_element(element.caption, keep_refs, latex_env)
        remove_trailing_whitespace(caption_parts)
        parts.append("\\end{table}\n")
        if len(caption_parts) > 0:
            parts.extend(caption_parts + ["\n"])
        parts.append("[ENDTABLE]\n\n")
        return parts

Nougat의 장, 단점

장점

1. Nougat은 수식과 테이블과 같이 이전 파싱 도구에서 어려웠던 부분을 LaTeX 소스 코드로 정확하게 파싱 가능하다.

2. Nougat의 파싱 결과는 마크다운과 유사한 Unstructured 문서이다.

3. 테이블 캡션을 쉽게 얻고 이를 테이블과 편리하게 연관시킬 수 있다.

 

단점

1. Nougat의 파싱 속도가 느려 대규모 배포에 어려움을 겪을 수 있다.

2. Nougat은 과학 논문을 기반으로 학습되었기 때문에 유사한 구조의 문서에서 뛰어난 성능을 발휘한다. 그러나 라틴 이외 언어로 작성된 문서에서는 성능이 떨어진다.

3. Nougat 모델은 한 번에 과학 논문의 한 페이지만 학습하므로 다른 페이지에 대한 지식이 부족하다. 이로 인해 파싱된 내용에 일관성이 없을 수 있으며, 인식 효과가 좋지 않을 수 있다.

4. 개의 열로 된 논문에서 테이블을 파싱하는 것은 단일 열 논문에서만큼 효과적이지 않다.

 

Nougat을 이용한 테이블 파싱

Env Setting

pip install langchain
pip install chromadb
pip install nougat-ocr
import os
os.environ["OPENAI_API_KEY"] = "YOUR_OPEN_AI_KEY"

import subprocess
import uuid
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain.retrievers.multi_vector import MultiVectorRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import Chroma
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from langchain_core.runnables import RunnablePassthrough

테이블 캡션이 반드시 테이블 아래에 위치해야 한다거나 테이블이 \begin{table}로 시작하여 \end{table}로 끝나야 한다는 내용을 명시한 공식 문서는 발견되지 않았기에, june_get_tables_from_mmd는 사용자 정의형이다.

def june_run_nougat(file_path, output_dir):
    # Run Nougat and store results as Mathpix Markdown
    cmd = ["nougat", file_path, "-o", output_dir, "-m", "0.1.0-base", "--no-skipping"]
    res = subprocess.run(cmd) 
    if res.returncode != 0:
        print("Error when running nougat.")
        return res.returncode
    else:
        print("Operation Completed!")
        return 0

def june_get_tables_from_mmd(mmd_path):
    f = open(mmd_path)
    lines = f.readlines()
    res = []
    tmp = []
    flag = ""
    for line in lines:
        if line == "\\begin{table}\n":
            flag = "BEGINTABLE"
        elif line == "\\end{table}\n":
            flag = "ENDTABLE"
        
        if flag == "BEGINTABLE":
            tmp.append(line)
        elif flag == "ENDTABLE":
            tmp.append(line)
            flag = "CAPTION"
        elif flag == "CAPTION":
            tmp.append(line)
            flag = "MARKDOWN"
            print('-' * 100)
            print(''.join(tmp))
            res.append(''.join(tmp))
            tmp = []
    return res
    
file_path = "YOUR_PDF_PATH"
output_dir = "YOUR_OUTPUT_DIR_PATH"

if june_run_nougat(file_path, output_dir) == 1:
    import sys
    sys.exit(1)

mmd_path = output_dir + '/' + os.path.splitext(file_path)[0].split('/')[-1] + ".mmd" 
tables = june_get_tables_from_mmd(mmd_path)

획득한 파싱 결과는 LLM을 사용해서 요약한다. 

prompt_text = """You are an assistant tasked with summarizing tables and text. \ 
Give a concise summary of the table or text. The table is formatted in LaTeX, and its caption is in plain text format: {element}  """
prompt = ChatPromptTemplate.from_template(prompt_text)

# Summary chain
model = ChatOpenAI(temperature = 0, model = "gpt-3.5-turbo")
summarize_chain = {"element": lambda x: x} | prompt | model | StrOutputParser()
# Get table summaries
table_summaries = summarize_chain.batch(tables, {"max_concurrency": 5})

Multi-Vector Retriever를 사용하여 Document Summary Index Structure를 구축하자

# The vectorstore to use to index the child chunks
vectorstore = Chroma(collection_name = "summaries", embedding_function = OpenAIEmbeddings())
store = InMemoryStore()   # The storage layer for the parent documents
id_key = "doc_id"

retriever = MultiVectorRetriever(   # The retriever (empty to start)
    vectorstore = vectorstore,
    docstore = store,
    id_key = id_key,
    search_kwargs={"k": 1} # Solving Number of requested results 4 is greater than number of elements in index..., updating n_results = 1
)

# Add tables
table_ids = [str(uuid.uuid4()) for _ in tables]
summary_tables = [
    Document(page_content = s, metadata = {id_key: table_ids[i]})
    for i, s in enumerate(table_summaries)
]
retriever.vectorstore.add_documents(summary_tables)
retriever.docstore.mset(list(zip(table_ids, tables)))

RAG Pipeline을 구축하고, 쿼리를 수행해보자 

# Prompt template
template = """Answer the question based only on the following context, which can include text and tables, there is a table in LaTeX format and a table caption in plain text format:
{context}
Question: {question}
"""
prompt = ChatPromptTemplate.from_template(template)
model = ChatOpenAI(temperature = 0, model = "gpt-3.5-turbo")   # LLM
# Simple RAG pipeline
chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | prompt
    | model
    | StrOutputParser()
)

print(chain.invoke("when layer type is Self-Attention, what is the Complexity per Layer?"))  # Query about table 1
print(chain.invoke("Which parser performs worst for BLEU EN-DE"))  # Query about table 2
print(chain.invoke("Which parser performs best for WSJ 23 F1"))  # Query about table 4

Ref.

https://medium.com/ai-in-plain-english/advanced-rag-07-exploring-rag-for-tables-5c3fc0de7af6

'AI > Natural Language Processing' 카테고리의 다른 글

Prompt Compression (이론)  (0) 2024.06.18
Self-RAG (이론)  (0) 2024.06.17
Exploring Query Rewriting (이론)  (1) 2024.06.12
Exploring Semantic Chunking (이론)  (0) 2024.06.12
Re-ranking (이론)  (0) 2024.06.11

블로그의 정보

코딩하는 오리

Cori

활동하기