본문 바로가기
스터디/RAG

[framework] 랭체인은 쉽게되는데 왜 우리는 안됐을까?

by gnosis90 2026. 5. 3.

새가 예쁜 것 같다.

 

 첫번째였나 두번째였나 RAG를 기초부터 다시 다지기로 한 뒤 가졌던 스터디 발표 시간.

나연이가 발표하는 날이었는데 꽤 많은 장수의 pdf 파일의 텍스트가 잘추출되는 것을 보여주었는데

이 덕분에 우리 스터디의 고민이었던 대용량 PDF 처리의 실마리를 찾아볼 수 있는 계기가 되었다.

 

그렇다면 날것, 파이썬 라이브러리만으로 처리했을 때는 랭체인과 어떤 차이가 있었길래 추출이 잘 안되었던걸까?


 

클로드 말에 따르면 랭체인이 잘 되는 이유는 내부적으로 레이아웃 분석을 해주기 때문이다.

직접 구현할 때는 pdfplumber나 fitz 중 어떤 걸 쓰느냐 + 어떻게 정렬하느냐가 핵심이다.

이 PDF처럼 다단 레이아웃이나 표가 섞인 경우 pdfplumber가 좌표 기반으로 더 잘 처리한다.

 

이 부분은 그날도 그 이전에도 계속 나왔던 얘기었으나 나는 잘 이해하지 못했다.

pdf가 좌표가 있다고.. 머리가 아프군 이정도.

 

https://wikidocs.net/288614 

 

22-05. 랭체인의 PDF를 읽는 다양한 방법

랭체인에서는 PDF 파일을 읽고 처리할 수 있는 다양한 PDF Loader를 제공합니다. 다음 주소에서 랭체인에서 제공하는 모든 PDF Loader들을 확인할 수 있습니다. 이번…

wikidocs.net


 아래에 보게 될 코드는 PDF 문서에서 텍스트를 추출하여 RAG(검색 증강 생성) 시스템이 읽기 좋은 형태로 가공하는

데이터 로더(Loader)클래스이다.

 

단순히 글자를 긁어오는 게 아니라, 머리말/꼬리말을 잘라내고, 글자 위치를 계산해 줄을 맞추는 정교한 작업을 수행한다.


1. 초기 설정 : __init__

def __init__(self, header_margin=0.08, footer_margin=0.08, x_tolerance=5, y_tolerance=5):

 

이 부분은 로더가 작동할 때의 '기준'을 정한다.

 

- header_margin, footer_margin(0.08) : 페이지 상단과 하단의 8% 영역을 버린다.

보통 이 영역에는 페이지 번호나 문서 제목 같은 '잡음'이 많기 때문이다.

- x_tolerance, y_tolerance(5) : 글자가 약간 어긋나 있어도 같은 단어나 같은 줄로 인식할지 결정하는 '너그러움?'의 정도이다.


2. 메인 실행 흐름 : load

 

 PDF 파일을 열어서 전체 페이지를 하나씩 돌며 텍스트를 뽑아낸다.

결과는 ['1페이지 내용', '2페이지 내용', ...] 식의 리스트 형태로 반환된다.

import pdfplumber
import re
from service.rag.ingestion.loader.base_loader import BaseLoader

class PDFLoader(BaseLoader):

    # 이 부분은 로더가 작동할 때의 '기준'을 정한다.
    def __init__(self, header_margin=0.08, footer_margin=0.08, x_tolerance=5, y_tolerance=5):
        
        self.header_margin = header_margin
        self.footer_margin = footer_margin
        self.x_tolerance   = x_tolerance
        self.y_tolerance   = y_tolerance

    def load(self, pdf_path: str) -> list[str]:
        pages = []
        with pdfplumber.open(pdf_path) as pdf:
            for page in pdf.pages:
                text = self._extract_page(page)
                if text.strip():
                    pages.append(text)
        return pages

    def _extract_page(self, page) -> str:
        height  = page.height
        top     = height * self.header_margin
        bottom  = height * (1 - self.footer_margin)
        cropped = page.crop((0, top, page.width, bottom))
        words   = cropped.extract_words(x_tolerance=self.x_tolerance, y_tolerance=self.y_tolerance)
        if not words:
            return ""
        words_sorted = sorted(words, key=lambda w: (round(w["top"] / 5) * 5, w["x0"]))
        lines = self._group_into_lines(words_sorted)
        return self._clean_text("\n".join(lines))

    def _group_into_lines(self, words, line_gap=6):
        if not words:
            return []
        lines, cur_line, cur_top = [], [words[0]], words[0]["top"]
        for w in words[1:]:
            if abs(w["top"] - cur_top) <= line_gap:
                cur_line.append(w)
            else:
                lines.append(" ".join(wd["text"] for wd in cur_line))
                cur_line, cur_top = [w], w["top"]
        lines.append(" ".join(wd["text"] for wd in cur_line))
        return lines

    def _clean_text(self, text):
        text = text.replace("\u00a0", " ").replace("\r", "\n")
        text = re.sub(r" {3,}", "  ", text)
        text = re.sub(r"\n{3,}", "\n\n", text)
        return text.strip()

 

3. 핵심 기술1 : 영역 자르기와 단어 추출(_extract_page)

 

제미나이 말로는 이 코드가 똑똑한 이유가

PDF를 단순 텍스트가 아니라 좌표값으로 보기 때문이라고 한다.

 

1. page.crop : 위에서 설정한 마진을 이용해 페이지의 위아래를 잘라낸다(중요한 본문만 남김).

(이건 전에도 성희가 말했지만 장단점이 있다.)

 

2. extract_words : 페이지 내의 모든 단어를 좌표(x, y 위치) 정보와 함께 추출한다.

 

3. words_stored : 추출된 단어들을 위에서 아래로, 왼쪽에서 오른쪽으로 정렬한다.

 

 - 여기서 round(w["top"] / 5) * 5 기술이 쓰이는데, 이는 미세하게 높낮이가 다른 글자들을 같은 줄(Y좌표)로 묶기 위한 보정 작업이다.


4. 핵심기술2 : 줄바꿈 재구성(_group_into_lines)

 

 PDF 내부 데이터는 사람 눈에는 줄이 맞아 보여도, 실제로는 순서가 뒤죽박죽인 경우가 많다.

 

- 이 함수는 단어들의 Y좌표(top)를 비교한다.

- 현재 줄의 글자들과 다음 글자의 높이 차이가 line_gab(6) 이하라면 "아, 이건 같은 줄이구나" 하고 옆에 붙인다.

- 차이가 크면 "여기서부터는 다음 줄이네"하고 줄바꿈을 수행한다.


5. 마무리 청소 : _clean_text

 

 추출된 텍스트에 포함된 불필요한 공백이나 특수 문자를 정리한다.

- \u00a0 : 웹에서 흔히 딸려나오는 공백 문자를 일반 스페이스로 바꾼다.

- re.sub(r" {3, }", "    ", text) : 공백이 너무 길면(3칸 이상) 2칸으로 줄인다.

- re.sub(r"\n {3, }", "\n\n", text) : 엔터가 너무 많이 쳐져 있으면 최대 2번(한 줄 띄움)으로 제한한다.


왜 이렇게 하는 걸까?

 

 RAG 시스템(AI가 문서를 읽고 답변하는 시스템)에서는 텍스트가 깨끗하고 줄바꿈이 정확해야 AI가 문맥을 오해하지 않고 정확하게 답변할 수 있기 때문이다.


요약하자면 위 코드들은 

 

1. "위아래 8%는 필요 없으니까 잘라내라"(헤더/푸터 제거)

2. "글자들을 좌표 기준으로 다 가져와서 줄별로 세워라"(정밀한 텍스트 재구성)

3. "공백이나 엔터 너무 많은 건 보기 흉하니 정리해라"(텍스트 클리닝)

 

그러하다...