[AI 서비스 개발] RAG을 위한 PDF load, 텍스트 분할과 Embedding
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)})
)
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"이라는 단어