AI/AI 서비스 개발

[AI 서비스 개발] RAG을 위한 PDF load, 텍스트 분할과 Embedding

brave_sol 2024. 12. 11. 23:32

1. 진행 순서

- PDF를 참고해서 답변하는 chatbot을 만들고 싶은데, 답별할 때 해당 정보가 있는 page 번호를 알려줬으면 했다.

- 원래 페이지 번호를 정상적으로 가져오는 경우도 있지만, 나의 경우에는 에러가 나서 따로 페이지번호를 추가해줬다.

PDF 파일 로드 > 페이지 번호 추가(메타데이터) > 텍스트 분할 (문장) > 임베딩 > 벡터 데이터베이스 저장/로드 > 검색 쿼리 및 결과 출력 > 테스트

 

2. pdf 파일 로드와 페이지 번호 추가

# 1. PDF 로드
loader = PyPDFLoader("2021_Guidelines_Beer.pdf")
documents = loader.load()

# 2. 각 페이지에 페이지 번호 추가
for i, doc in enumerate(documents):
    doc.metadata["page_number"] = i + 1  # 페이지 번호는 1부터 시작

 

3. 텍스트 분할 : 얼마나 세세하게 쪼갤 것인가?

- 내가 불러오려는 pd 파일은 대부분의 페이지가 규칙적으로 반 page씩 있어서 해당 문단을 복사해 openapi에서 한 문단의 토큰을 계산하였다.

- chunk_size: 한 단락(11A)은 600토큰 정도였는데, 소개 단락(11.)이 있는 경우도 있어서 넉넉하게 800토큰으로 잡았다.

- chunk_overlab : chunk들이 겹치는 길이 인데, 보통 chunk_size에 10~20% 정도라고 해서 100으로 잡았다.

- openai tokenizer : https://platform.openai.com/tokenizer

 

# 3. 텍스트 분할 및 메타데이터 유지
text_splitter = RecursiveCharacterTextSplitter(
    separators=["\n\n", "\n", " "],  # 큰 단위 → 작은 단위로 분할
    chunk_size=800,                  # 최대 토큰 크기
    chunk_overlap=100                # 중첩 크기
)

# 청크 분할 및 페이지 번호 포함
chunked_documents = []
for doc in documents:
    chunks = text_splitter.split_text(doc.page_content)  # 페이지 내용을 청크로 나눔
    for chunk in chunks:
        chunked_documents.append(
            Document(page_content=chunk, metadata={"page_number": doc.metadata.get("page_number", None)})
        )

 

https://www.bjcp.org/bjcp-style-guidelines/

 

4. 임베딩 생성 및 벡터 데이터베이스에 저장

- chatbot을 실행시킬때마다 임베딩을 새로 하면 비용이 계속 발생하기 때문에 로컬에 저장하고 로드하였다.

- streamlit과 사용하니 피클 로드시 에러가 나서 에러 허용하는 코드를 넣어주었다.

# 4. 임베딩 생성 및 벡터 데이터베이스 저장
embedding_model = OpenAIEmbeddings()  # OpenAI 임베딩 모델 초기화
vector_db = FAISS.from_documents(chunked_documents, embedding_model)  # FAISS 데이터베이스 생성
vector_db.save_local("beer_guidelines_vectorstore")  # 벡터 DB 로컬 저장

# 5. 벡터 데이터베이스 로드
vector_db = FAISS.load_local(
    "beer_guidelines_vectorstore",
    embedding_model,
    allow_dangerous_deserialization=True  # Pickle 파일 로드 허용
)

 

5. 쿼리 테스트 해보기

# 6. 검색 쿼리 및 결과 출력
query = "What is the ABV range of Munich Dunkel?"
results = vector_db.similarity_search(query, k=1)  # 가장 유사한 1개 청크 검색

for result in results:
    page_number = result.metadata.get("page_number", "Unknown")  # 페이지 번호 확인
    print(f"Page {page_number}:\n{result.page_content}\n")

 

6. 원하는 문자 추출하기(정규식)

- 제일 시간이 오래 걸렸는데, "11A . Ordinary Bitter"와 같이 숫자+문자로 시작하는 제목과,

반대로 문자부터 시작하는 "X5. New Zealand Pilsner" 같은 단어, 그리고 번호 뒤에 이름이 공백이 한 단어인지, 여러 단어로 구성되었는지 등등 형식이 달라 제목을 추출하기가 어려웠다.

# 특정 페이지 번호 (예: 35)
target_page = 35

# 데이터베이스의 모든 문서 가져오기
documents = vector_db.similarity_search_with_score(" ", k=vector_db.index.ntotal)

# 특정 페이지 번호에 해당하는 문서 찾기
filtered_docs = [doc for doc, score in documents if doc.metadata.get("page_number") == target_page]

import re
content = filtered_docs[0].page_content
matches = re.search(r"\n+([^\n]+)\nOverall", content, re.DOTALL)
print(matches.group(1))

 

7. re.search와 group

- re.search: content 문자열에서 정규식 패턴을 검색

- re.DOTALL : 점(.)은 원래 줄바꿈 문자를 제외한 모든 문자를 매칭하지만, 해당 옵션을 주면 줄바꿈 문자를 포함한 전체 문자열을 대상으로 매칭해준다.

- group(0) : 패턴 전체와 일치한 테스트를 반환

- group(1) : 캡처 그룹에 해당하는 결과를 반환 - 정규 표현식에서 괄호( )로 묶인 부분을 캡처그룹이라고 한다.

첫번째 괄호는 group(1), 두번째 괄호는 group(2)

2) \n+ : 하나 이상의 줄바꿈 문자

3) ([^\n]+): 캡처그룹, \n(줄바꿈)이 아닌(^) 문자, + : 한번 이상 반복

4) \nOverall : 줄바꿈 문자 뒤에 "Overall"이라는 단어

반응형