SAP-ERP-AI-Agent / src /rag /ingest.py
daisysooyeon's picture
deploy: SAP ERP AI Agent (HF Spaces docker)
50efdc6
Raw
History Blame Contribute Delete
20.5 kB
"""
src/rag/ingest.py
SAP Learning Hub PDF λ¬Έμ„œ νŒŒμ‹± β†’ μ²­ν‚Ή β†’ 메타데이터 λΆ€μ°© β†’ ChromaDB 인제슀트
핡심 섀계:
1) μ²­ν¬λ§ˆλ‹€ μ»¬λ ‰μ…˜ μ „μ—­μ—μ„œ μœ μΌν•˜κ³  μž¬μΈμ œμŠ€νŠΈν•΄λ„ μ•ˆμ •μ μΈ chunk_id λΆ€μ—¬
(ν˜•μ‹: "<source_stem>::NNNN"). 이 값을 ChromaDB document id λ‘œλ„ λ™μΌν•˜κ²Œ μ‚¬μš©
β†’ retrieved 청크의 metadata["chunk_id"] 만으둜 μ •λ‹΅ μ—¬λΆ€λ₯Ό μ •ν™• λ§€μΉ­ (id κΈ°μ€€ RAG 평가)
2) λ¬Έμ„œ λ‹¨μœ„ 메타(source, doc_title, total_pages, total_chunks) +
청크 λ‹¨μœ„ 메타(chunk_id, chunk_index, page, char_count, unit, lesson, section) λΆ€μ°©
β†’ μΈμš©Β·ν•„ν„°λ§Β·ν‰κ°€μ— μ‚¬μš©. PDF λ‘œλ”κ°€ μ±„μš°λŠ” μž‘λ‹€ν•œ ν•„λ“œλŠ” μ œκ±°ν•˜κ³  μœ μš©ν•œ κ²ƒλ§Œ μœ μ§€
3) SAP Learning Hub PDFλŠ” νŽ˜μ΄μ§€ 푸터에 'Unit N: 제λͺ©'(짝수 λ©΄) / 'Lesson: 제λͺ©'(ν™€μˆ˜ λ©΄)이
μΌκ΄€λ˜κ²Œ λ°•ν˜€ 있음. 이λ₯Ό νŽ˜μ΄μ§€ λ‹¨μœ„λ‘œ μΆ”μΆœ β†’ forward-fill ν•΄μ„œ 각 청크에 주제(unit/lesson)λ₯Ό
λ©”νƒ€λ‘œ λΆ€μ°©ν•˜κ³ , 반볡 ν‘Έν„°(μ €μž‘κΆŒ/Unit/Lesson 쀄)λŠ” λ³Έλ¬Έμ—μ„œ μ œκ±°ν•΄ μž„λ² λ”© λ…Έμ΄μ¦ˆλ₯Ό 쀄인닀.
4) (μ˜΅μ…˜) 각 청크 λ³Έλ¬Έ μ•žμ— μ»¨ν…μŠ€νŠΈ 헀더λ₯Ό prepend ν•΄μ„œ 메타 정보λ₯Ό μž„λ² λ”©μ— 반영
configs.yaml: rag.contextual_header
5) (μ˜΅μ…˜) 각 PDF νŽ˜μ΄μ§€μ˜ λ‚΄μž₯ 이미지λ₯Ό Surya OCR둜 인식 β†’ 별도 OCR 청크둜 ChromaDB에 μΆ”κ°€
chunk_id ν˜•μ‹: "<source_stem>_ocr::NNNN" / metadata.source_type = "ocr"
configs.yaml: rag.ocr_enabled
chunk_size / chunk_overlap / collection_name / contextual_header / ocr_enabled 등은 configs.yamlμ—μ„œ μ½μŠ΅λ‹ˆλ‹€.
"""
import io
import re
from pathlib import Path
import fitz # PyMuPDF β€” PDF 뢁마크(outline) 직접 쑰회용
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_core.documents import Document
from src.config import get_config
DOCS_DIR = "data/docs"
# SAP Learning Hub PDF ν‘Έν„° νŒ¨ν„΄.
# 짝수 λ©΄ ν‘Έν„°: "Unit 4: Master Data"
# ν™€μˆ˜ λ©΄ ν‘Έν„°: "Lesson: Maintaining Business Partner Master Data"
# 곡톡 ν‘Έν„° : "Β© Copyright. All rights reserved." + νŽ˜μ΄μ§€ 번호
# μ €μž‘κΆŒ 쀄을 μ•΅μ»€λ‘œ 작고 κ·Έ μœ„μͺ½μ—μ„œ Unit/Lesson 쀄을 찾으면 λ³Έλ¬Έ λ‚΄ 동일 문ꡬ와 ν˜Όλ™ 없이
# 'λŸ¬λ‹ ν‘Έν„°'만 μ •ν™•νžˆ μ§‘λŠ”λ‹€.
_UNIT_FOOTER = re.compile(r"^\s*Unit\s+(\d+):\s+(.+?)\s*$")
_LESSON_FOOTER = re.compile(r"^\s*Lesson:\s+(.+?)\s*$")
_COPYRIGHT = re.compile(r"Copyright", re.I)
def _build_chunk_id(source_stem: str, index: int) -> str:
"""μ»¬λ ‰μ…˜ μ „μ—­μ—μ„œ μœ μΌν•˜κ³  μž¬μΈμ œμŠ€νŠΈν•΄λ„ μ•ˆμ •μ μΈ 청크 id."""
return f"{source_stem}::{index:04d}"
def _extract_footers(page_text: str) -> tuple[str | None, str | None]:
"""ν•œ νŽ˜μ΄μ§€μ˜ ν‘Έν„°μ—μ„œ (unit, lesson) μΆ”μΆœ. μ—†μœΌλ©΄ (None, None)."""
lines = [l.strip() for l in page_text.splitlines() if l.strip()]
anchor = next((i for i, l in enumerate(lines) if _COPYRIGHT.search(l)), len(lines))
unit = lesson = None
for l in reversed(lines[:anchor]): # μ €μž‘κΆŒ 쀄 λ°”λ‘œ μœ„ β†’ ν‘Έν„°
if unit is None:
m = _UNIT_FOOTER.match(l)
if m:
unit = f"Unit {m.group(1)}: {m.group(2)}"
if lesson is None:
m = _LESSON_FOOTER.match(l)
if m:
lesson = m.group(1)
if unit and lesson:
break
return unit, lesson
def _page_section_map(pages: list[Document]) -> dict[int, tuple[str | None, str | None]]:
"""
νŽ˜μ΄μ§€λ³„ (unit, lesson) 맡을 forward-fill 둜 ꡬ성.
- unit : ν•œ 번 λ“±μž₯ν•˜λ©΄ λ‹€μŒ unit이 λ‚˜μ˜¬ λ•ŒκΉŒμ§€ μœ μ§€ (짝/홀 λ©΄ λͺ¨λ‘ 채움 β†’ ~95% 컀버리지)
- lesson: unit이 λ°”λ€Œλ©΄ 리셋 (lesson이 unit 경계λ₯Ό λ„˜μ–΄ 잘λͺ» μ „νŒŒλ˜λŠ” 것 λ°©μ§€)
ν‚€λŠ” 청크가 μƒμ†ν•˜λŠ” page 메타값(0-indexed)κ³Ό λ™μΌν•˜κ²Œ λ§žμΆ˜λ‹€.
"""
section: dict[int, tuple[str | None, str | None]] = {}
cur_unit = cur_lesson = None
for i, pg in enumerate(pages):
unit, lesson = _extract_footers(pg.page_content)
if unit and unit != cur_unit:
cur_unit, cur_lesson = unit, None # μƒˆ unit μ§„μž… β†’ lesson 리셋
if lesson:
cur_lesson = lesson
raw_page = pg.metadata.get("page")
page_no = raw_page if isinstance(raw_page, int) else i
section[page_no] = (cur_unit, cur_lesson)
return section
def _toc_section_map(pdf_path: Path) -> dict[int, str]:
"""
PDF 뢁마크(outline)의 L3 ν•­λͺ©μ„ 'section'으둜 보고 νŽ˜μ΄μ§€(0-indexed)λ³„λ‘œ λ§€ν•‘.
계측: L1=Unit, L2=Lesson, L3=Section(레슨 λ‚΄ ν† ν”½ ν—€λ”©).
- 각 L3λŠ” μ‹œμž‘ νŽ˜μ΄μ§€λΆ€ν„° λ‹€μŒ toc ν•­λͺ© μ „κΉŒμ§€ 유효 (forward-fill)
- Unit/Lesson 경계(L1Β·L2)λ₯Ό λ§Œλ‚˜λ©΄ section을 None으둜 리셋 β†’ 레슨 κ°„ μ˜€μ—Ό λ°©μ§€
- 메타데이터 + μ»¨ν…μŠ€νŠΈ 헀더 μ–‘μͺ½μ— μ‹£λŠ”λ‹€. 헀딩은 μ„Ήμ…˜ 첫 μ²­ν¬μ—λ§Œ μžˆμœΌλ―€λ‘œ,
헀더에 section을 λ„£μœΌλ©΄ ν—€λ”© μ—†λŠ” μ„Ήμ…˜ 쀑간 청크에도 주제 μ‹ ν˜Έκ°€ μ‹€λ¦°λ‹€(검색 보강).
λΆλ§ˆν¬κ°€ μ—†κ±°λ‚˜ 쑰회 μ‹€νŒ¨ μ‹œ 빈 맡을 λ°˜ν™˜ν•œλ‹€(section λ©”νƒ€λŠ” κ·Έλƒ₯ μƒλž΅λ¨).
"""
try:
with fitz.open(str(pdf_path)) as doc:
toc = doc.get_toc() # [[level, title, page(1-indexed)], ...] λ¬Έμ„œ μˆœμ„œ
page_count = doc.page_count
except Exception as e:
print(f"[WARN] {pdf_path.name}: failed to read bookmarks, section metadata skipped ({e})")
return {}
# (μ‹œμž‘νŽ˜μ΄μ§€0, section) 마컀 ꡬ성: L3λŠ” μ„€μ •, L1/L2λŠ” 리셋
markers: list[tuple[int, str | None]] = []
cur: str | None = None
for level, title, page1 in toc:
if level == 3:
cur = title.strip()
elif level <= 2:
cur = None
markers.append((page1 - 1, cur))
# νŽ˜μ΄μ§€λ³„ forward-fill (같은 νŽ˜μ΄μ§€μ— λ§ˆμ»€κ°€ μ—¬λŸ¬ 개면 λ’€μͺ½μ΄ 이김)
out: dict[int, str] = {}
last: str | None = None
mi = 0
for p in range(page_count):
while mi < len(markers) and markers[mi][0] <= p:
last = markers[mi][1]
mi += 1
if last:
out[p] = last
return out
def _strip_footer_noise(page_text: str) -> str:
"""λͺ¨λ“  νŽ˜μ΄μ§€μ— λ°˜λ³΅λ˜λŠ” λŸ¬λ‹ ν‘Έν„°(μ €μž‘κΆŒ/Unit/Lesson 쀄)λ₯Ό λ³Έλ¬Έμ—μ„œ μ œκ±°ν•΄ μž„λ² λ”© λ…Έμ΄μ¦ˆλ₯Ό μ€„μž„.
제거된 주제 μ •λ³΄λŠ” 메타데이터 + μ»¨ν…μŠ€νŠΈ 헀더에 κ΅¬μ‘°ν™”λ˜μ–΄ λ‹€μ‹œ μ‹€λ¦¬λ―€λ‘œ 정보 손싀은 μ—†λ‹€."""
kept = [
line for line in page_text.splitlines()
if not (_COPYRIGHT.search(line) or _UNIT_FOOTER.match(line) or _LESSON_FOOTER.match(line))
]
return "\n".join(kept)
def _contextual_header(doc_title: str, page: int,
unit: str | None = None, lesson: str | None = None,
section: str | None = None) -> str:
"""μž„λ² λ”© ν…μŠ€νŠΈ μ•žμ— prepend ν•  μ»¨ν…μŠ€νŠΈ 헀더 (메타 정보λ₯Ό μž„λ² λ”©μ— 반영).
ν˜•μ‹: [Source: <doc> | <unit> | Lesson: <lesson> | Section: <section> | p.N]
(unit/lesson/section은 μžˆμ„ λ•Œλ§Œ 포함. section은 헀딩이 μ—†λŠ” μ„Ήμ…˜ 쀑간 청크에도
주제 μ‹ ν˜Έλ₯Ό μ‹€μ–΄ dense/BM25 맀칭을 λ³΄κ°•ν•œλ‹€.)"""
loc = f"p.{page + 1}" if page >= 0 else "p.?" # pageλŠ” 0-indexed β†’ ν‘œμ‹œλŠ” 1-indexed
parts = [f"Source: {doc_title}"]
if unit:
parts.append(unit)
if lesson:
parts.append(f"Lesson: {lesson}")
if section:
parts.append(f"Section: {section}")
parts.append(loc)
return f"[{' | '.join(parts)}]"
# ── Surya OCR (이미지 μ²­ν‚Ή) ────────────────────────────────────────────────────
_surya_cache: dict = {}
def _init_surya() -> bool:
"""Surya OCR λͺ¨λΈμ„ lazy-load. λ‘œλ“œ 성곡 μ‹œ True λ°˜ν™˜.
이 κ΅¬ν˜„μ€ 0.14+ FoundationPredictor APIλ₯Ό μ‚¬μš©ν•œλ‹€(requirements.txt: surya-ocr>=0.14.0,
μ„€μΉ˜ 확인 0.17.1). 0.17.x의 pad_token_id μ΄μŠˆκ°€ OCR ν’ˆμ§ˆμ— 영ν–₯ μ£Όλ©΄ 0.14~0.16으둜
λ‚΄λ €μ„œ ν•€ν•˜λŠ” 것을 κ³ λ €.
(μ°Έκ³  β€” κ³Όκ±° 0.6.xλŠ” surya.model.* ν•¨μˆ˜ API(run_ocr), 0.7~0.13은 Recognition/Detection
Predictor 클래슀 APIμ˜€λ‹€. _ocr_pil에 ν•΄λ‹Ή λΆ„κΈ° 흔적이 남아 μžˆμœΌλ‚˜ ν˜„μž¬ κ²½λ‘œλŠ” foundation만.)
"""
if "ready" in _surya_cache:
return _surya_cache["ready"]
errors: list[str] = []
# ── API #1: surya-ocr 0.14+ (0.17.x 확인) β€” RecognitionPredictorκ°€ FoundationPredictor ν•„μš” ──
try:
from surya.foundation import FoundationPredictor
from surya.recognition import RecognitionPredictor
from surya.detection import DetectionPredictor
foundation = FoundationPredictor()
_surya_cache["det"] = DetectionPredictor()
_surya_cache["rec"] = RecognitionPredictor(foundation)
_surya_cache["api"] = "foundation"
_surya_cache["ready"] = True
print("[OCR] Surya predictors loaded (foundation API, surya>=0.14)")
return True
except Exception as e:
errors.append(f"foundation API: {type(e).__name__}: {e}")
print(f"[WARN] surya-ocr unavailable. Image OCR will be skipped.")
print(f" Hint: pip install \"surya-ocr>=0.6.0,<0.14.0\"")
for err in errors:
print(f" {err}")
_surya_cache["ready"] = False
return False
def _ocr_pil(pil_img) -> str:
"""PIL 이미지에 Surya OCR을 μ‹€ν–‰ν•΄ μΆ”μΆœλœ ν…μŠ€νŠΈλ₯Ό λ°˜ν™˜. μ‹€νŒ¨ μ‹œ 빈 λ¬Έμžμ—΄."""
try:
api = _surya_cache.get("api")
if api == "foundation":
# 0.14+ __call__: (images, task_names=None, det_predictor=...) β€” det_predictor ν•„μˆ˜
results = _surya_cache["rec"]([pil_img], det_predictor=_surya_cache["det"])
elif api == "predictor":
# 0.13.x __call__: (images, langs, det_predictor=...) β€” det_predictor ν•„μˆ˜
results = _surya_cache["rec"]([pil_img], [["en"]], det_predictor=_surya_cache["det"])
else: # function API (surya 0.6.x)
from surya.ocr import run_ocr
results = run_ocr(
[pil_img], [["en"]],
_surya_cache["det_model"], _surya_cache["det_proc"],
_surya_cache["rec_model"], _surya_cache["rec_proc"],
)
if not results:
return ""
return "\n".join(line.text for line in results[0].text_lines if line.text.strip())
except Exception as e:
print(f"[WARN] Surya OCR inference failed: {e}")
return ""
def _build_ocr_chunk_id(source_stem: str, index: int) -> str:
"""OCR 청크 μ „μš© μ•ˆμ •μ  id β€” ν…μŠ€νŠΈ 청크 λ„€μž„μŠ€νŽ˜μ΄μŠ€(_ocr:: 접두)와 뢄리."""
return f"{source_stem}_ocr::{index:04d}"
def _ocr_contextual_header(doc_title: str, page: int,
unit: str | None = None, lesson: str | None = None,
section: str | None = None) -> str:
"""OCR 청크용 μ»¨ν…μŠ€νŠΈ 헀더 (ν…μŠ€νŠΈ 헀더와 λ™μΌν•˜λ˜ '| OCR' νƒœκ·Έ μΆ”κ°€)."""
loc = f"p.{page + 1}" if page >= 0 else "p.?"
parts = [f"Source: {doc_title}"]
if unit:
parts.append(unit)
if lesson:
parts.append(f"Lesson: {lesson}")
if section:
parts.append(f"Section: {section}")
parts.append(loc)
parts.append("OCR")
return f"[{' | '.join(parts)}]"
def ingest_documents(pdf_dir: str = DOCS_DIR, reset: bool = True) -> None:
"""
PDF 일괄 λ‘œλ“œ β†’ μ²­ν‚Ή β†’ 메타데이터/유일 id λΆ€μ°© β†’ μž„λ² λ”© β†’ ChromaDB μ €μž₯.
Args:
pdf_dir: PDF 디렉토리 (κΈ°λ³Έ data/docs)
reset: True이면 κΈ°μ‘΄ μ»¬λ ‰μ…˜μ„ μ‚­μ œ ν›„ μƒˆλ‘œ 생성 (쀑볡/ꡬ UUID id λ°©μ§€)
"""
cfg = get_config()
splitter = RecursiveCharacterTextSplitter(
chunk_size=cfg.rag.chunk_size,
chunk_overlap=cfg.rag.chunk_overlap,
separators=["\n\n", "\n", ".", " "],
)
embedding = HuggingFaceBgeEmbeddings(model_name="BAAI/bge-m3", encode_kwargs={"batch_size": 32})
all_docs = []
all_ids: list[str] = []
for pdf_path in sorted(Path(pdf_dir).glob("*.pdf")):
loader = PyMuPDFLoader(str(pdf_path), extract_images=False)
pages = loader.load()
if not pages:
print(f"[WARN] {pdf_path.name}: no pages loaded, skipping.")
continue
source = pdf_path.name
source_stem = pdf_path.stem
pdf_meta = pages[0].metadata
doc_title = (pdf_meta.get("title") or "").strip() or source_stem
total_pages = int(pdf_meta.get("total_pages") or len(pages))
# μ²­ν‚Ή '전에' νŽ˜μ΄μ§€ ν‘Έν„°μ—μ„œ unit/lesson μΆ”μΆœ β†’ νŽ˜μ΄μ§€λ³„ 주제 λ§΅ ꡬ성.
# section(L3 ν† ν”½ ν—€λ”©)은 PDF λΆλ§ˆν¬μ—μ„œ λ³„λ„λ‘œ νŽ˜μ΄μ§€λ³„ λ§€ν•‘.
# κ·Έ λ‹€μŒ 반볡 ν‘Έν„°λ₯Ό λ³Έλ¬Έμ—μ„œ μ œκ±°ν•˜κ³  λ‚˜μ„œ μ²­ν‚Ήν•œλ‹€(μž„λ² λ”© λ…Έμ΄μ¦ˆ 제거).
section_map = _page_section_map(pages)
section_toc = _toc_section_map(pdf_path)
for pg in pages:
pg.page_content = _strip_footer_noise(pg.page_content)
chunks = splitter.split_documents(pages)
total_chunks = len(chunks)
for idx, chunk in enumerate(chunks):
raw_page = chunk.metadata.get("page")
page = int(raw_page) if isinstance(raw_page, int) else -1
original = chunk.page_content
chunk_id = _build_chunk_id(source_stem, idx)
unit, lesson = section_map.get(page, (None, None))
section = section_toc.get(page)
# 메타데이터 μž¬κ΅¬μ„±: PDF λ‘œλ”κ°€ μ±„μš΄ μž‘λ‹€ν•œ ν•„λ“œ(creator/producer/…)λŠ” 버리고
# μΈμš©Β·ν•„ν„°λ§Β·ν‰κ°€μ— μœ μš©ν•œ ν•„λ“œλ§Œ 남긴닀. (ChromaλŠ” scalar λ©”νƒ€λ§Œ ν—ˆμš©)
chunk.metadata = {
# ── λ¬Έμ„œ λ‹¨μœ„(whole-document) 메타 ──
"source": source,
"doc_title": doc_title,
"total_pages": total_pages,
"total_chunks": total_chunks,
# ── 청크 λ‹¨μœ„(this-chunk) 메타 ──
"chunk_id": chunk_id, # μ»¬λ ‰μ…˜ μ „μ—­ 유일 id == Chroma document id (평가 κΈ°μ€€)
"chunk_index": idx, # λ¬Έμ„œ λ‚΄ 순번
"page": page, # 0-indexed (PyMuPDF κ΄€λ‘€)
"char_count": len(original),
}
# 주제 λ©”νƒ€λŠ” μΆ”μΆœλœ νŽ˜μ΄μ§€μ—λ§Œ λΆ€μ°© (ChromaλŠ” None 메타λ₯Ό ν—ˆμš©ν•˜μ§€ μ•ŠμœΌλ―€λ‘œ ν‚€ 자체λ₯Ό μƒλž΅)
if unit:
chunk.metadata["unit"] = unit
if lesson:
chunk.metadata["lesson"] = lesson
if section:
chunk.metadata["section"] = section # 인용/필터링 + μ•„λž˜ 헀더에 반영
# 메타 정보λ₯Ό μž„λ² λ”© ν…μŠ€νŠΈμ— 반영 (contextual chunk header)
if cfg.rag.contextual_header:
chunk.page_content = f"{_contextual_header(doc_title, page, unit, lesson, section)}\n\n{original}"
all_docs.append(chunk)
all_ids.append(chunk_id)
last_id = _build_chunk_id(source_stem, total_chunks - 1)
print(f"[OK] {source}: {total_chunks} text chunks ({source_stem}::0000 ~ {last_id})")
# ── OCR: PDF λ‚΄μž₯ 이미지 β†’ Surya OCR β†’ 별도 OCR 청크 ─────────────────
if cfg.rag.ocr_enabled and _init_surya():
from PIL import Image as PILImage
ocr_idx = 0
with fitz.open(str(pdf_path)) as fitz_doc:
for page_idx in range(len(fitz_doc)):
page_obj = fitz_doc[page_idx]
img_list = page_obj.get_images(full=True)
if not img_list:
continue
page_ocr_parts: list[str] = []
for img_info in img_list:
xref = img_info[0]
try:
pix = fitz.Pixmap(fitz_doc, xref)
# CMYK(n>4) β†’ RGB λ³€ν™˜
if pix.n > 4:
pix = fitz.Pixmap(fitz.csRGB, pix)
pil_img = PILImage.open(io.BytesIO(pix.tobytes("png")))
# 50px 미만 μ•„μ΄μ½˜/μž₯식은 μ œμ™Έ
if pil_img.width < 50 or pil_img.height < 50:
continue
text = _ocr_pil(pil_img)
if text.strip():
page_ocr_parts.append(text)
except Exception as e:
print(f"[WARN] OCR extract error (page {page_idx}, xref {xref}): {e}")
if not page_ocr_parts:
continue
combined = "\n\n".join(page_ocr_parts)
unit, lesson = section_map.get(page_idx, (None, None))
section_val = section_toc.get(page_idx)
# κΈ΄ OCR ν…μŠ€νŠΈλ„ ν…μŠ€νŠΈ 청크와 λ™μΌν•œ splitter둜 λΆ„ν• 
raw_doc = Document(page_content=combined)
ocr_splits = splitter.split_documents([raw_doc])
for ocr_chunk in ocr_splits:
ocr_text = ocr_chunk.page_content
cid = _build_ocr_chunk_id(source_stem, ocr_idx)
meta = {
"source": source,
"doc_title": doc_title,
"total_pages": total_pages,
"chunk_id": cid,
"chunk_index": ocr_idx,
"page": page_idx,
"char_count": len(ocr_text),
"source_type": "ocr",
"ocr_engine": "surya",
}
if unit:
meta["unit"] = unit
if lesson:
meta["lesson"] = lesson
if section_val:
meta["section"] = section_val
if cfg.rag.contextual_header:
ocr_text = f"{_ocr_contextual_header(doc_title, page_idx, unit, lesson, section_val)}\n\n{ocr_text}"
all_docs.append(Document(page_content=ocr_text, metadata=meta))
all_ids.append(cid)
ocr_idx += 1
if ocr_idx:
print(f"[OCR] {source}: {ocr_idx} OCR chunks added")
if not all_docs:
print("[WARN] No PDFs found. Place PDF files in data/docs/ and re-run.")
return
# κΈ°μ‘΄ μ»¬λ ‰μ…˜ μ΄ˆκΈ°ν™” (쀑볡 적재 및 ꡬ UUID id μž”μ‘΄ λ°©μ§€)
if reset:
try:
Chroma(
persist_directory=cfg.paths.chroma_db,
collection_name=cfg.rag.collection_name,
embedding_function=embedding,
).delete_collection()
print(f"[reset] Existing collection '{cfg.rag.collection_name}' deleted.")
except Exception as e:
print(f"[reset] Could not delete existing collection: {e}")
vectorstore = Chroma.from_documents(
documents=all_docs,
embedding=embedding,
ids=all_ids, # λ©”νƒ€μ˜ chunk_id 와 λ™μΌν•œ 값을 vector store id 둜 μ‚¬μš©
persist_directory=cfg.paths.chroma_db,
collection_name=cfg.rag.collection_name,
)
vectorstore.persist()
ocr_count = sum(1 for d in all_docs if d.metadata.get("source_type") == "ocr")
text_count = len(all_docs) - ocr_count
print(f"[DONE] {len(all_docs)} chunks saved to ChromaDB (text: {text_count}, ocr: {ocr_count})")
if __name__ == "__main__":
ingest_documents()