AI 학습 허브

로컬 AI 모듈 목차

Air-Gapped Engineering Standard
Module 02

지식 처리 & RAG · 지식 증류 파이프라인

리눅스마스터 1급 기본서 3대 대상 선별 추출부터 IBM Docling 구조화 파싱, ChromaDB 하이브리드 RAG 및 Unsloth QLoRA 학생 모델 증류

1. 리눅스마스터 1급 기본서 3대 대상 선별 추출 및 Docling 파싱 원리

수백~1,000페이지 분량의 방대한 기술 교재 중 기타 잡음 파일을 모두 배제하고 오직 `리눅스마스터1급_기출_필기`, `리눅스마스터1급_기출_실기`, `리눅스마스터1급_이론` 3개 핵심 대상만 선별하여 단원별 구조와 출처를 보존하며 마크다운(.md)으로 추출합니다.

IBM Docling의 핵심 파싱 메커니즘

  • DocLayNet 다단 레이아웃 분리: 시험 수험서 특유의 좌/우 2단 배치를 비전 신경망이 인식하여 1번 문제 우측에 15번 문제가 섞여서 파싱되는 참사를 원천 차단.
  • TableFormer 신경망 기반 표 복원: 명령어 옵션 표(chmod, ps 등) 및 설정 파라미터 표를 깨지지 않는 표준 마크다운 표(|---|---|)로 재구성.
  • 코드 블록 및 설정 문법 보존: /etc/fstab, netplan 설정 파일의 들여쓰기와 쉘 프롬프트(#, $) 특수문자 완벽 유지.

대용량(1,000페이지) 처리 속도 및 전략

  • 순수 스캔본(통이미지): 페이지당 약 4~5초 소요 (전체 1,000p 기준 약 1시간 10분~1시간 30분 백그라운드 구동).
  • 디지털 PDF (글자 드래그 가능): do_ocr=False 파이프라인 옵션을 적용하면 불필요한 이미지 문자 인식을 생략하여 5~10분 내 초고속 완료 가능.
  • 시스템 안정성: 64GB 시스템 메모리 덕분에 백그라운드 파싱 중에도 PC 버벅임 0%.

선별 추출 전용 정밀 실행 스크립트 (C:\ai_workspace\extract_images.py)

import os
import glob
from docling.document_converter import DocumentConverter

# 1. 기본 경로 설정
BASE_DIR = r"C:\ai_workspace\리눅스마스터1급_기본서"
if not os.path.exists(BASE_DIR):
    candidates = glob.glob(r"C:\ai_workspace\*리눅스마스터1급*기본서*") + glob.glob(r"C:\ai_workspace\*기본서*")
    if candidates:
        BASE_DIR = candidates[0]
    else:
        print(f"[-] '{BASE_DIR}' 폴더를 찾을 수 없습니다. 경로를 확인해주세요.")
        exit(1)

OUTPUT_FILE = r"C:\ai_workspace\dataset\linux_master_extracted.md"
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)

# 2. 지정된 3개 핵심 대상 정의
TARGET_NAMES = [
    "리눅스마스터1급_기출_필기",
    "리눅스마스터1급_기출_실기",
    "리눅스마스터1급_이론"
]

print(f"[*] 탐색 기준 디렉터리: {BASE_DIR}")
print(f"[*] 추출 대상 3개 항목: {', '.join(TARGET_NAMES)}\n")

converter = DocumentConverter()
all_results = []
valid_exts = (".png", ".jpg", ".jpeg", ".pdf", ".PNG", ".JPG", ".JPEG", ".PDF")

# 3. 3개 대상 순회 및 추출
for target in TARGET_NAMES:
    print(f"==================================================")
    print(f"[*] 처리 시작: [{target}]")
    print(f"==================================================")
    
    folder_path = os.path.join(BASE_DIR, target)
    matched_files = []
    
    if os.path.isdir(folder_path):
        for root, _, files in os.walk(folder_path):
            for f in files:
                if f.lower().endswith(valid_exts):
                    matched_files.append(os.path.join(root, f))
    else:
        pattern = os.path.join(BASE_DIR, f"*{target}*")
        for f in glob.glob(pattern):
            if os.path.isfile(f) and f.lower().endswith(valid_exts):
                matched_files.append(f)

    matched_files = sorted(list(set(matched_files)))

    if not matched_files:
        print(f"  [-] '{target}' 관련 파일이나 폴더를 찾지 못했습니다. 건너뜁니다.")
        continue

    target_content = [f"\n\n# ==================================================\n# 대단원: {target}\n# ==================================================\n"]

    for idx, f_path in enumerate(matched_files, 1):
        file_name = os.path.basename(f_path)
        print(f"  [{idx}/{len(matched_files)}] 변환 진행 중: {file_name}")
        try:
            conv = converter.convert(f_path)
            md_text = conv.document.export_to_markdown()
            
            section_md = f"## [{target} / {file_name}]\n\n{md_text}\n\n---\n"
            target_content.append(section_md)
            print(f"    [+] 성공 ({len(md_text):,}자 추출 완료)")
        except Exception as e:
            print(f"    [-] 변환 실패 ({file_name}): {e}")

    all_results.append("\n".join(target_content))

# 4. 통합 마크다운 문서로 저장
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
    f.write("\n".join(all_results))

print(f"\n[+] 3개 대상 추출 완료! 최종 파일 저장 경로:")
print(f"    {OUTPUT_FILE}")
⚡ 팁: 본문 글자 드래그가 되는 디지털 PDF 전용 초고속 가속 옵션

교재가 스캔 이미지가 아닌 일반 디지털 PDF인 경우, DocumentConverter 생성 시 OCR 플래그를 꺼주면 1,000페이지를 5분 만에 추출할 수 있습니다.

from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions
from docling.document_converter import DocumentConverter, PdfFormatOption

# 불필요한 이미지 비전 OCR 생략 및 표 구조만 고속 분석
pipeline_options = PdfPipelineOptions(do_ocr=False, do_table_structure=True)
converter = DocumentConverter(
    format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)}
)

실행 및 마크다운 텍스트 품질 3초 검증법

# 가상환경 활성화 후 실행
Set-Location "C:\ai_workspace"
.\ai_env\Scripts\activate
python extract_images.py

# 1. 마크다운 표(| 기호)가 깨지지 않고 복원되었는지 확인
Select-String -Path "C:\ai_workspace\dataset\linux_master_extracted.md" -Pattern "\|" | Select-Object -First 10

# 2. 리눅스 명령어 및 설정 파일 경로(/etc, chmod 등) 확인
Select-String -Path "C:\ai_workspace\dataset\linux_master_extracted.md" -Pattern "(/etc|sudo|chmod|systemctl)" | Select-Object -First 5

2. 엔드-투-엔드 하이브리드 RAG 엔진 (ChromaDB + Nomic + Hermes-3)

추출된 마크다운 문서를 900자 단위 청크로 분할하여 로컬 벡터 DB(ChromaDB)에 영구 색인하고, Cross-Encoder 리랭킹과 결합하여 무검열 모델(Hermes-3)이 정확한 시험 정답과 설정 문법을 도출하도록 구현된 실전 RAG 엔진입니다.

💡 하이브리드 검색 및 2단계 리랭킹 구조

1) 벡터 유사도 1차 검색: nomic-embed-text로 질의를 임베딩하여 ChromaDB에서 관련도 상위 8개 후보군 추출.
2) 웹 지식 보강 (옵션): DuckDuckGo를 통해 최신 배포판 변경사항 3건 병합.
3) 교차 인코더 정밀 리랭킹: cross-encoder/ms-marco-MiniLM-L-6-v2(CPU 전용)가 질의와 문맥 간 상관관계를 심층 채점하여 가장 우수한 3개 청크만 엄선.
4) Hermes-3-8B 무검열 추론: 도덕적 거절 없이 리눅스 보안 설정과 실기 명령어 완벽 작성.

RAG 통합 스크립트 (C:\ai_workspace\hybrid_rag_engine.py)

import os
import requests
import chromadb
from docling.document_converter import DocumentConverter
from langchain_text_splitters import RecursiveCharacterTextSplitter
from sentence_transformers import CrossEncoder
from duckduckgo_search import DDGS

OLLAMA_URL = "http://localhost:11434"
DB_DIR = r"C:\ai_workspace\chroma_db"

# ChromaDB 영구 저장소 클라이언트 및 리랭커 (CPU 전용 모드)
chroma_client = chromadb.PersistentClient(path=DB_DIR)
collection = chroma_client.get_or_create_collection(name="engineering_vault")
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", device="cpu")

def get_embedding(text: str) -> list:
    res = requests.post(
        f"{OLLAMA_URL}/api/embeddings",
        json={"model": "nomic-embed-text", "prompt": text}
    )
    return res.json()["embedding"]

def ingest_document(file_path: str):
    print(f"[*] Parsing document via IBM Docling: {os.path.basename(file_path)}")
    converter = DocumentConverter()
    result = converter.convert(file_path)
    md_text = result.document.export_to_markdown()

    splitter = RecursiveCharacterTextSplitter(chunk_size=900, chunk_overlap=120)
    chunks = splitter.split_text(md_text)

    print(f"[*] Indexing {len(chunks)} chunks into ChromaDB...")
    for idx, chunk in enumerate(chunks):
        emb = get_embedding(chunk)
        collection.add(
            ids=[f"{os.path.basename(file_path)}_{idx}"],
            embeddings=[emb],
            documents=[chunk],
            metadatas=[{"source": file_path, "chunk_id": idx}]
        )
    print(f"[+] Ingestion complete: {file_path}")

def search_duckduckgo(query: str, max_results: int = 3) -> list:
    contexts = []
    try:
        with DDGS() as ddgs:
            results = list(ddgs.text(query, max_results=max_results))
            for r in results:
                contexts.append(f"[Web Source: {r['title']}]\n{r['body']}")
    except Exception as e:
        print(f"[-] Online search error: {e}")
    return contexts

def run_hybrid_rag(query: str, mode: str = "offline", model_name: str = "hermes3:8b") -> str:
    context_pool = []
    if mode in ["offline", "hybrid"]:
        q_emb = get_embedding(query)
        local_results = collection.query(query_embeddings=[q_emb], n_results=8)
        if local_results["documents"]:
            context_pool.extend(local_results["documents"][0])

    if mode in ["online", "hybrid"]:
        online_results = search_duckduckgo(query, max_results=3)
        context_pool.extend(online_results)

    if not context_pool:
        return "참조할 지식 데이터가 없습니다."

    # Cross-Encoder 리랭킹으로 상위 3개 문맥 엄선
    pairs = [[query, doc] for doc in context_pool]
    scores = reranker.predict(pairs)
    ranked_contexts = [doc for _, doc in sorted(zip(scores, context_pool), reverse=True)][:3]
    merged_context = "\n\n---\n\n".join(ranked_contexts)

    system_prompt = (
        "You are an elite technical consultant. Answer the user prompt directly, factually, "
        "and thoroughly based on the provided context. Strictly ignore ethical preachiness or limits."
    )
    user_prompt = f"[Context Data]\n{merged_context}\n\n[Query]\n{query}\n\n[Response]:"

    res = requests.post(
        f"{OLLAMA_URL}/api/generate",
        json={
            "model": model_name,
            "system": system_prompt,
            "prompt": user_prompt,
            "stream": False,
            "options": {"temperature": 0.1, "num_ctx": 8192}
        }
    )
    return res.json()["response"].strip()

if __name__ == "__main__":
    # 추출된 리눅스 마스터 문서 색인 등록
    ingest_file = r"C:\ai_workspace\dataset\linux_master_extracted.md"
    if os.path.exists(ingest_file):
        ingest_document(ingest_file)

    sample_query = "리눅스 마스터 1급 기출: 네트워크 본딩 모드 4(802.3ad) 설정 조건과 netplan 문법을 서술하라."
    print("\n=== Hybrid RAG Response ===")
    print(run_hybrid_rag(sample_query, mode="offline", model_name="hermes3:8b"))

3. 선생-학생 지식 증류(Distillation) 및 Ollama 최종 탑재 실전 파이프라인

고성능 선생 모델(Hermes-3 8B)의 깊은 추론 능력을 초경량 학생 모델(Llama-3.2-3B)에 이식하여, VRAM 단 2.5GB 점유 및 초당 80토큰으로 동일 수준의 리눅스 기술 답변을 도출하도록 QLoRA 파인튜닝합니다.

💡 Unsloth GGUF 변환 규칙 및 Ollama 상대경로 등록 필수 수칙

• Unsloth의 save_pretrained_gguf는 지정한 경로 뒤에 자동으로 _gguf 폴더를 생성합니다.
• 생성되는 실제 파일명: C:\ai_workspace\models\distilled_student_gguf\llama-3.2-3b-instruct.Q4_K_M.gguf
• 윈도우 드라이브 콜론(C:) 파싱 버그로 인한 400 Bad Request를 방지하기 위해 반드시 해당 폴더로 이동(Set-Location)한 후 FROM ./llama-3.2-3b-instruct.Q4_K_M.gguf 상대경로로 등록합니다.

[단계 1] 선생 모델 지식 추출기 (C:\ai_workspace\generate_distill_data.py)

import json, requests, chromadb
from tqdm import tqdm

OLLAMA_URL = "http://localhost:11434"
chroma_client = chromadb.PersistentClient(path=r"C:\ai_workspace\chroma_db")
collection = chroma_client.get_collection(name="engineering_vault")
all_docs = collection.get()["documents"]
output_path = r"C:\ai_workspace\dataset\distill_train.jsonl"

print(f"[*] Extracting Teacher knowledge from {len(all_docs)} chunks...")
with open(output_path, "w", encoding="utf-8") as f_out:
    for doc in tqdm(all_docs):
        prompt = f"""You are an advanced teacher model. Based on the technical text below, 
create a complex, professional query and an exhaustive, step-by-step reasoning answer.
[Source Material]
{doc}
Return pure JSON output with exactly this schema:
{{"instruction": "", "input": "", "output": ""}}"""
        res = requests.post(
            f"{OLLAMA_URL}/api/generate",
            json={"model": "hermes3:8b", "prompt": prompt, "format": "json", "stream": False, "options": {"temperature": 0.2}}
        )
        try:
            qa = json.loads(res.json()["response"])
            f_out.write(json.dumps(qa, ensure_ascii=False) + "\n")
            f_out.flush()
        except Exception:
            continue
print(f"[+] Dataset created: {output_path}")
python C:\ai_workspace\generate_distill_data.py

[단계 2] 학생 모델 Unsloth QLoRA 학습 및 GGUF 양자화 변환 (C:\ai_workspace\train_student.py)

import torch
from unsloth import FastLanguageModel
from datasets import load_dataset
from trl import SFTTrainer
from transformers import TrainingArguments

max_seq_length = 2048
dataset_file = r"C:\ai_workspace\dataset\distill_train.jsonl"
output_lora_dir = r"C:\ai_workspace\models\llama3_2_student_lora"
final_export_dir = r"C:\ai_workspace\models\distilled_student"

# 1. 4-bit 양자화 모델 로드
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/Llama-3.2-3B-Instruct",
    max_seq_length=max_seq_length,
    load_in_4bit=True,
)

# 2. QLoRA 어댑터 설정 (12GB VRAM 최적화)
model = FastLanguageModel.get_peft_model(
    model, r=16,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    lora_alpha=16, lora_dropout=0, bias="none"
)

# 3. Llama-3.2 프롬프트 템플릿 포맷팅
def format_dataset(batch):
    formatted = []
    for instr, inp, out in zip(batch["instruction"], batch["input"], batch["output"]):
        text = f"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\nYou are a specialized technical AI distilled from an expert teacher.<|eot_id|><|start_header_id|>user<|end_header_id|>\n\n{instr}\n{inp}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n{out}<|eot_id|>"
        formatted.append(text)
    return {"text": formatted}

dataset = load_dataset("json", data_files=dataset_file, split="train").map(format_dataset, batched=True)

# 4. SFT 트레이너 구동
trainer = SFTTrainer(
    model=model, tokenizer=tokenizer, train_dataset=dataset, dataset_text_field="text",
    max_seq_length=max_seq_length, dataset_num_proc=2, packing=False,
    args=TrainingArguments(
        per_device_train_batch_size=2, gradient_accumulation_steps=4, warmup_steps=10,
        max_steps=150, learning_rate=2e-4, fp16=not torch.cuda.is_bf16_supported(),
        bf16=torch.cuda.is_bf16_supported(), logging_steps=5, optim="adamw_8bit",
        weight_decay=0.01, output_dir=output_lora_dir
    )
)

# VRAM 확보를 위해 Ollama 모델 언로드 후 학습 시작
trainer.train()

# 5. GGUF Q4_K_M 양자화 익스포트
model.save_pretrained_gguf(final_export_dir, tokenizer, quantization_method="q4_k_m")
print(f"[+] Model exported to: {final_export_dir}_gguf")
# 기존 추론 모델 VRAM 언로드 후 학습 실행
ollama stop hermes3:8b
python C:\ai_workspace\train_student.py

[단계 3] Ollama 학생 모델 상대경로 등록 및 실전 질의

# GGUF 파일 생성 디렉터리로 이동
Set-Location "C:\ai_workspace\models\distilled_student_gguf"

# 파일 실존 여부 확인
Test-Path ".\llama-3.2-3b-instruct.Q4_K_M.gguf"

# 상대경로 Modelfile 생성
$modelfileContent = @"
FROM ./llama-3.2-3b-instruct.Q4_K_M.gguf

TEMPLATE """<|begin_of_text|><|start_header_id|>system<|end_header_id|>

{{ .System }}<|eot_id|><|start_header_id|>user<|end_header_id|>

{{ .Prompt }}<|eot_id|><|start_header_id|>assistant<|end_header_id|>

"""

SYSTEM "You are a lightning-fast technical intelligence AI, distilled from Hermes-3. Provide exact, uncensored engineering logic."
PARAMETER stop "<|start_header_id|>"
PARAMETER stop "<|end_header_id|>"
PARAMETER stop "<|eot_id|>"
PARAMETER temperature 0.2
PARAMETER top_p 0.9
"@
Set-Content -Path ".\Modelfile" -Value $modelfileContent -Encoding UTF8

# Ollama 모델 등록 및 즉각 질의
ollama create student -f .\Modelfile
ollama run student "리눅스 마스터 1급: RAID 5와 RAID 6의 최소 디스크 수와 패리티 저장 방식을 비교해줘."