공정 엔지니어의 AI 엔지니어로의 성장 기록

비전공자의 바이브 코딩/생산공정 AI 엔지니어링

Chpater 5. 바이브 코딩으로 AI 도입하기(1)

ai-process-engineer 2026. 6. 19. 23:44

Chapter 3에서 대시보드를, Chapter 4에서 통계분석 툴을 만들었습니다. 드디어 시리즈의 마지막 챕터입니다. 이번에는 우리가 만든 도구 안에 AI를 직접 들여놓습니다. 다룰 내용이 많아 두 편으로 나눴습니다.

  • (1)편 — 오늘 크롤링으로 지식 허브(Knowledge Hub)를 만들고, RAG로 검색하며, 그 자료를 Obsidian과 연결해 위키로 만듭니다.
  • (2)편 — 다음 다시 공정으로 돌아와 ML을 적용한 SDR 분석과 AI 브리핑을 만들고, 여기에 챗봇을 붙여 (1)편에서 만든 RAG로 검색하게 합니다.

먼저 드리는 말씀

이번 글은 이 시리즈에서 개념이 가장 어려운 편입니다. 처음 보시는 분이라면 한 번에 다 이해되지 않는 게 당연하고, 그래도 전혀 문제없습니다. 저도 그랬습니다. 이런 건 머리로 먼저 이해하려 들기보다 직접 만들어보면서 손에 익히는 게 빠르고, 이해는 나중에 자연스럽게 따라옵니다. 막히는 용어가 나와도 멈추지 마시고, 큰 흐름만 놓치지 않게 끝까지 따라와 주세요.


RAG가 대체 뭘까

이번 챕터의 핵심은 RAG입니다. 솔직히 저도 이 개념을 공부하면서 꽤 헤맸습니다. 설명하는 글마다 "환각(할루시네이션)을 막으려고 신뢰할 수 있는 출처에서 검색해 보강한다"는 식의 추상적인 말만 반복하고, 정작 손에 잡히는 예시가 없었기 때문입니다. 그래서 여기서는 제가 직접 만든 허브로 검색하는 모습을 보여드리면서 설명하겠습니다.

먼저 제가 왜 이걸 만들었는지부터 솔직하게 말씀드리겠습니다. 사실 좋은 답을 얻고 싶다면 Claude API 같은 상용 LLM을 쓰면 됩니다. 그게 제일 쉽고 성능도 좋습니다. 그런데 저는 비용 없이, 회사 안에서도 외부 연결 없이 돌아가는 구조를 만들고 싶었습니다. 그래서 제 PC에서 도는 로컬 LLM(gemma3:4b)을 썼습니다. 문제는, 이런 작은 로컬 모델은 아는 것이 많지 않아서 배터리 같은 전문 분야를 물으면 그럴듯하지만 틀린 답을 내놓기 일쑤라는 점입니다.

이 약한 모델을 똑똑하게 쓰는 방법이 바로 RAG입니다. 모델이 잘 모른다면, 모델에게 무리하게 외우게 하는 대신 답이 들어 있는 자료를 먼저 찾아서 함께 건네주면 됩니다. 그래서 저는 배터리 분야에서 널리 인용되는 지식 사이트(BatteryUniversity)를 크롤링해 저만의 데이터 허브를 만들었습니다.

그다음이 중요합니다. 크롤링한 글을 그냥 쌓아두기만 하면 의미로 검색할 수 없습니다. 그래서 LangChain(RAG를 만들 때 쓰는 프레임워크 중 하나)으로 글을 적당한 크기로 잘라(청킹) 앞뒤를 조금씩 겹치게(오버랩) 만든 뒤, 각 조각을 "의미를 담은 숫자 벡터"로 바꿔(임베딩) 벡터 DB에 저장했습니다. 이렇게 하면 단어가 정확히 일치하지 않아도 뜻이 비슷한 내용을 찾아낼 수 있습니다.

마지막으로 이 벡터 DB를 로컬 LLM과 연결합니다. 제가 한국어로 질문을 던지면, LLM이 질문의 의미를 이해해 벡터 DB에서 관련된 조각들을 찾아오고, 그 조각들을 근거로 답을 만들어 줍니다. 모델이 원래 몰랐던 내용도 찾아온 자료를 보고 답하니 훨씬 정확해집니다. 바로 이게 RAG입니다.

백문이 불여일견이니, 실제로 물어본 화면을 먼저 살짝 보겠습니다. 안에서 무슨 일이 벌어지는지는 뒤에서 단계별로 풀 테니, 지금은 결과만 가볍게 봐 주세요.

"ocv 테스트 방법"이라고 한국어로 물었더니, 시스템이 이를 영어로 번역해 검색하고(원본 자료가 영어라서요), 로컬 모델이 답을 정리한 뒤, 그 답의 근거가 된 문서들을 유사도 점수와 함께 아래에 보여줍니다. 작은 모델 혼자서는 어림없는 답을, 찾아온 근거 덕분에 제법 그럴듯하게 내놓습니다. 이 화면이 어떻게 만들어지는지는 5-4에서 다시 천천히 뜯어보겠습니다.

사실 RAG는 이보다 더 넓은 개념입니다. 벡터 DB니 임베딩이니 하는 것들을 다 걷어내고 보면, "답하기 전에 믿을 만한 출처에서 관련 정보를 찾아와 함께 참고하는 행위" 그 자체가 RAG입니다. 제가 만든 것은 그 개념을 벡터 검색까지 갖춰 제대로 구현한 형태이고요. 그러니 지금 모든 용어가 이해되지 않아도, 큰 그림 — "찾아서 보여주고, 그걸 근거로 답한다" — 만 잡고 가시면 충분합니다.

참고로 로컬 LLM이라고 다 작은 건 아닙니다. PC 사양만 받쳐주면 훨씬 큰 모델(예: gemma3:27b)도 돌릴 수 있고, 이 프로젝트가 가벼운 4b를 쓴 건 순전히 집 PC 사정 때문입니다. 그리고 RAG의 진짜 장점은 여기에 있습니다. 모델이 크든 작든, 최신이고 전용인 자료를 근거로 붙여주면 답의 신뢰도가 올라간다는 것. 즉 RAG는 작은 모델을 보완해 주기도 하지만, 큰 모델에게도 "우리 회사 자료", "이 분야 전문 자료"처럼 모델이 학습하지 못한 지식을 더해 주는 장치입니다.


전체 그림 — 시스템 아키텍처

말로만 들으면 복잡하니 한 장의 그림으로 정리하겠습니다. 핵심은, 한 번 크롤링한 데이터를 두 갈래로 활용한다는 점입니다. 하나는 사람이 직접 읽는 위키(Obsidian), 다른 하나는 AI가 검색하는 벡터 DB입니다.

Battery University 신뢰할 수 있는 지식 사이트 크롤링 · httpx + BeautifulSoup PostgreSQL · 원문 저장 HTML · Markdown 보관 ① 위키 파이프라인 ② 검색 파이프라인 Markdown 파일 markdownify 변환 · 섹션별 폴더 Obsidian 위키 노트 · 그래프 뷰 딥링크로 바로 열람 벡터 DB · pgvector 청킹·임베딩 → 2,959개 청크 RAG 검색 Local LLM이 질문을 이해해 의미로 찾아 답변 생성

출발은 같습니다. BatteryUniversity를 크롤링해 PostgreSQL에 원문을 저장합니다. 여기서 ① 위키 파이프라인은 원문을 Markdown으로 바꿔 Obsidian 볼트에 넣어 열람용 위키로 만들고, ② 검색 파이프라인은 원문을 잘게 잘라 임베딩한 뒤 벡터 DB에 넣어 RAG 검색의 재료로 씁니다. 아래에서 이 네 단계를 차례로 만들어 보겠습니다.


5-1. 크롤링 — 허브의 재료 모으기

파이프라인의 출발점은 크롤링입니다. 배터리 분야에서 널리 인용되는 BatteryUniversity.com의 아티클 약 213개를 순서대로 수집해 PostgreSQL DB에 저장했습니다. 서버에 부담을 주지 않도록 0.5~1.5초 간격을 두고, 가져온 HTML은 사람이 읽기 좋은 Markdown으로 변환해 함께 보관합니다. 이미 받은 글은 건너뛰어 중복도 막습니다.

왼쪽 사이드바의 파이프라인(Crawling → Embedding → Obsidian → RAG Search)이 이 글의 전체 흐름 그대로입니다. 진행 바는 SSE로 실시간 갱신되어 지금 몇 번째 문서를 받고 있는지 보여줍니다. 여기서 가장 중요한 건 화려한 기능이 아니라 "믿을 수 있는 한 곳"에서 모았다는 사실입니다. 허브의 품질은 결국 재료의 품질에서 결정됩니다.


5-2. 임베딩 — 의미로 검색되게 만들기

모아둔 글은 그대로는 의미 기반 검색이 되지 않습니다. 이 단계에서 글을 검색 가능한 형태로 가공합니다. 각 아티클을 약 1,600자(앞뒤 200자 겹침) 크기로 잘라 모두 2,959개의 조각으로 만들고, 각 조각을 nomic-embed-text 모델로 768차원 벡터로 바꿔 pgvector에 저장했습니다.

용어가 어렵게 들리지만 뜻은 단순합니다. 청킹은 글을 적당한 크기로 자르는 것입니다. 너무 길면 핵심이 묻히고 너무 짧으면 맥락이 끊기니 알맞게 나누는 것이죠. 오버랩은 자른 경계에서 문맥이 잘리지 않도록 앞뒤를 조금 겹쳐 두는 장치입니다. 임베딩은 그 조각을 "의미를 담은 숫자"로 바꾸는 작업이라, 뜻이 비슷한 조각끼리는 숫자도 가깝게 모입니다. 이 단계가 끝나면 단어가 아니라 의미로 검색할 준비가 끝난 것입니다.

혹시 이 단락이 어려우셨다면, "글을 알맞게 잘라서 의미를 담은 숫자로 바꿔 저장해 뒀다" 정도만 기억하고 넘어가셔도 충분합니다. 나머지는 직접 돌려보면 손에 잡힙니다.


5-3. Obsidian 위키 — 사람이 읽는 또 하나의 결과물

같은 크롤링 데이터를 한 번 더 활용합니다. 이번엔 검색이 아니라 "사람이 직접 읽는 위키"로요. 크롤링한 아티클을 섹션별 폴더 구조로 정리해 Markdown 파일로 내보내면, Obsidian 볼트에서 그대로 열람할 수 있습니다.

내보낸 노트 상단에는 출처 URL과 분류 정보가 붙습니다. 앞서 본 RAG 검색 결과에서 "Open in Obsidian"을 누르면, 딥링크를 통해 해당 노트로 바로 점프합니다. AI가 찾아준 답의 출처를 클릭 한 번으로 원문에서 확인할 수 있는 것입니다.

그리고 Obsidian의 그래프 뷰로 보면, 213개의 문서가 어떻게 서로 연결되는지 한눈에 들어옵니다. 흩어진 자료가 하나의 지식 그물망으로 묶이는 순간입니다.

결국 한 번의 크롤링으로 검색용 벡터 DB와 열람용 위키, 두 가지를 동시에 얻은 셈입니다.


5-4. RAG 검색 — 자연어로 묻고 근거로 답하기

마지막은 이 모든 재료가 하나로 합쳐지는 검색입니다. 앞에서 살짝 보여드린 그 화면을 다시 꺼내, 이번엔 질문을 던졌을 때 안에서 무슨 일이 벌어지는지 한 단계씩 따라가 보겠습니다.

제가 "ocv 테스트 방법"이라고 입력한 순간부터, 화면 위에서는 이런 일이 차례로 일어납니다.

  1. 한국어 질문임을 감지하고 영어로 번역합니다(원문 자료가 영어라서요). 화면에도 "OCV test method"로 번역된 게 보입니다.
  2. 번역된 질문을 임베딩 때와 똑같은 방식으로 벡터로 바꿉니다.
  3. 벡터 DB에서 의미가 가장 가까운 조각들을 찾아옵니다. 5-2에서 잘라 넣어 둔 그 조각들입니다.
  4. 찾아온 조각들을 한데 모아 로컬 LLM에게 "이 자료를 근거로 답해 달라"고 건넵니다.
  5. 모델이 답을 정리하고, 어떤 문서에서 가져왔는지 출처·유사도 점수·Obsidian 링크까지 함께 내놓습니다.

앞의 네 단계가 여기서 전부 맞물립니다. 크롤링으로 모은 글(5-1)이, 잘게 잘려 의미로 검색되도록 임베딩되고(5-2), 위키로도 연결되어 출처를 바로 열어볼 수 있고(5-3), 그 모두가 이 한 번의 질문에서 하나로 합쳐집니다.

처음에 말씀드린 "약한 모델을 똑똑하게 쓰는 법"이 바로 이 장면입니다. 모델 자체는 작지만, 매번 믿을 수 있는 근거를 손에 쥐여 주니 전문 질문에도 제법 쓸 만한 답을 내놓습니다. 이것이 RAG의 완성된 모습입니다.


마지막으로 한 가지만 강조하고 싶습니다. 이건 장난감 예제가 아닙니다. 저는 지금 회사에서, 제가 속한 사업부의 기술문서와 보고서 같은 내부 문서를 마크다운으로 바꾸고, 사업부가 만드는 제품과 설비의 기술자료를 크롤링으로 모아 똑같은 방식의 거대한 지식 허브를 만들고 있습니다. 신입이 며칠씩 헤매며 찾던 자료를, 자연어로 한 번 물어 근거와 함께 받아 보는 구조입니다. 현업에서 실제로 쓰이는 기술이니, 지금은 어렵더라도 잘 익혀 두시면 분명 쓸 데가 있습니다.

여기까지가 (1)편입니다. 크롤링으로 허브를 짓고, 임베딩으로 검색되게 만들고, Obsidian으로 위키를 엮고, RAG로 자연어 검색까지 완성했습니다. 다음 (2)편에서는 다시 공정 현장으로 돌아갑니다. ML을 적용한 SDR 분석과 AI 브리핑을 만들고, 거기에 챗봇을 붙여 오늘 만든 이 RAG로 검색하게 할 예정입니다. 마지막 편에서 뵙겠습니다.


부록 — PRD 전문

읽기만 하실 분은 여기서 마치셔도 됩니다. 직접 만들어 보고 싶은 분을 위해 PRD 전문을 붙여 둡니다. 복사해 프로젝트 루트의 PRD.md로 저장하고 Claude Code에 건네면 같은 시스템을 재현할 수 있습니다. 단, DB 접속 정보의 계정·비밀번호는 공개용으로 가려두었으니 <YOUR_DB_USER>, <YOUR_DB_PASSWORD> 자리에 본인 값을 채워 넣으세요.

PRD 전문 펼치기 / 접기 (클릭)
# PRD: Battery Knowledge Hub — RAG 시스템

**버전:** 1.2
**날짜:** 2026-06-19
**상태:** Active — 핵심 구현 완료
**프로젝트 디렉토리:** `D:\Dev\03-3.RAG`
**향후 통합 대상:** `D:\Dev\03.portfolio` (근본 원인 분석)

---

## 1. 개요

### 1.1 목적

[BatteryUniversity.com](https://www.batteryuniversity.com/articles/)의 모든 아티클을 크롤링하여 PostgreSQL 데이터베이스에 저장하고, pgvector 기반 벡터 저장소에 임베딩한 뒤, LangChain 기반의 RAG(검색 증강 생성) 검색 인터페이스를 제공하는 Battery Knowledge Hub를 구축한다. 이 시스템은 배터리 엔지니어링 전용 지식 베이스 역할을 하며, 향후 포트폴리오 프로젝트의 근본 원인 분석 워크플로우에 통합될 예정이다.

### 1.2 목표

- BatteryUniversity 아티클 ~213개를 메타데이터 포함하여 체계적으로 크롤링 및 저장
- Ollama 네이티브 임베딩을 활용한 pgvector 기반 벡터 검색 레이어 구축
- `gemma3:4b` 기반 RAG를 통한 한국어/영어 이중 언어 자연어 검색 제공
- 크롤링 아티클을 Obsidian에서 열람 가능한 Markdown 파일로 내보내기
- 파이프라인 전체를 관리하는 개발자용 Test Console (Next.js) 제공
- 향후 `D:\Dev\03.portfolio` 통합을 위한 깔끔한 REST API (FastAPI) 제공

### 1.3 범위

| 포함 | 제외 |
|---|---|
| BatteryUniversity.com 아티클 크롤링 | 기타 외부 배터리 DB |
| pgvector 기반 시맨틱 검색 | 실시간 웹 검색 |
| Ollama gemma3:4b 생성/번역 | 클라우드 LLM API |
| Next.js Test Console | 프로덕션 사용자 UI |
| FastAPI REST 엔드포인트 | 인증/사용자 관리 |
| 한국어 ↔ 영어 쿼리 번역 | 전체 다국어 지원 |
| Obsidian Markdown 내보내기 + 딥링크 URI | Obsidian 플러그인 개발 |

### 1.4 현재 상태 (2026-06-19 기준)

| 파이프라인 단계 | 상태 | 수량 |
|---|---|---|
| 크롤링 | 완료 | 212 / 213 아티클 |
| 임베딩 | 완료 | pgvector에 2,959개 청크 |
| Obsidian 내보내기 | 운영 중 | 전체 아티클 내보내기 가능 |
| RAG 검색 | 운영 중 | 한/영 이중 언어 검색 정상 동작 |

> `information` 카테고리의 아티클 1개가 BatteryUniversity.com 서버 오류로 크롤링 실패. 나머지 모든 아티클은 크롤링 및 임베딩 완료.

---

## 2. 기술 스택

### 2.1 프론트엔드

| 구성 요소 | 기술 |
|---|---|
| 프레임워크 | Next.js 16.2.9 (App Router, Turbopack) |
| 스타일링 | Tailwind CSS v3 |
| 상태 관리 | React 내장 (useState / useRef) |
| HTTP 클라이언트 | Axios |
| 실시간 진행 상황 | Server-Sent Events (SSE) via EventSource API |

### 2.2 백엔드

| 구성 요소 | 기술 |
|---|---|
| 런타임 | Python 3.14.3 |
| API 프레임워크 | FastAPI |
| ORM | SQLAlchemy 2.x (async + asyncpg) |
| 데이터베이스 | PostgreSQL 16 (네이티브, localhost) |
| 벡터 확장 | pgvector 0.8.x |
| LLM 런타임 | Ollama (로컬) |
| LLM 모델 | `gemma3:4b` (생성, KO→EN 번역) |
| 임베딩 모델 | `nomic-embed-text` via Ollama (768차원 벡터) |
| RAG 프레임워크 | LangChain (`langchain-ollama`, `langchain-text-splitters`) |
| 웹 스크래핑 | httpx + BeautifulSoup4 |
| HTML→Markdown | markdownify |
| 태스크 큐 | FastAPI BackgroundTasks (asyncio) |

> **임베딩 모델 참고:** `gemma3:4b`는 생성형 모델로 고정 크기 임베딩 벡터를 생성할 수 없다. `nomic-embed-text`가 전용 임베딩 모델(768차원)이며, gemma3:4b는 한국어→영어 번역과 RAG 답변 생성만 담당한다.

> **Python 버전 참고:** Python 3.14.3은 `requirements.txt`에서 유연한(`>=`) 버전 지정이 필요하다. numpy, pgvector 등 일부 패키지는 3.14용 사전 빌드 바이너리가 없어 소스 컴파일이 필요하다.

### 2.3 프로젝트 구조

```
D:\Dev\03-3.RAG\
├── docs/
│   ├── PRD.md
│   ├── PRD_KOR.md
│   ├── DevPlan.md
│   ├── DevPlan_KOR.md
│   └── image/
├── src/
│   ├── frontend/
│   │   ├── app/
│   │   │   ├── layout.tsx
│   │   │   ├── page.tsx              # /crawling으로 리다이렉트
│   │   │   ├── crawling/page.tsx
│   │   │   ├── embedding/page.tsx
│   │   │   ├── obsidian/page.tsx
│   │   │   └── search/page.tsx
│   │   ├── components/
│   │   │   ├── Sidebar.tsx
│   │   │   └── ProgressBar.tsx
│   │   ├── lib/api.ts
│   │   ├── .env.local
│   │   └── package.json
│   └── backend/
│       ├── main.py
│       ├── config.py
│       ├── database.py
│       ├── models/article.py
│       ├── api/
│       │   ├── crawling.py
│       │   ├── embedding.py
│       │   ├── obsidian.py
│       │   └── search.py
│       ├── services/
│       │   ├── crawler.py
│       │   ├── embedder.py
│       │   ├── obsidian_exporter.py
│       │   └── rag.py
│       ├── schemas/requests.py
│       ├── data/category_manifest.py
│       └── requirements.txt
└── .env
```

---

## 3. PostgreSQL & pgvector 설정

### 3.1 데이터베이스 연결 정보

| 파라미터 | 값 |
|---|---|
| 호스트 | `localhost` |
| 포트 | `5432` |
| 데이터베이스 | `knowledge_hub` |
| 소유자/사용자 | `<YOUR_DB_USER>` / `<YOUR_DB_PASSWORD>` |
| 슈퍼유저 | `postgres` / `<YOUR_DB_PASSWORD>` |

### 3.2 pgvector 확장

슈퍼유저 권한으로 설치 필요 (<YOUR_DB_USER>은 SUPERUSER 권한 없음):

```bash
PGPASSWORD=<YOUR_DB_PASSWORD> psql -U postgres -d knowledge_hub -c "CREATE EXTENSION IF NOT EXISTS vector;"
```

애플리케이션의 `init_db()`는 확장 생성을 try/except로 감싸 이미 존재하거나 권한이 없을 경우 건너뜀.

### 3.3 환경 변수

```env
# .env
POSTGRES_DB=knowledge_hub
POSTGRES_USER=<YOUR_DB_USER>
POSTGRES_PASSWORD=<YOUR_DB_PASSWORD>
DATABASE_URL=postgresql+asyncpg://<YOUR_DB_USER>:<YOUR_DB_PASSWORD>@localhost:5432/knowledge_hub
OLLAMA_GENERATION_MODEL=gemma3:4b
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
OBSIDIAN_VAULT_PATH=D:\Obsidian\BatteryHub
OBSIDIAN_VAULT_NAME=BatteryHub
```

```env
# src/frontend/.env.local
NEXT_PUBLIC_API_URL=http://localhost:8000
```

---

## 4. 데이터베이스 스키마

### 4.1 `articles` 테이블

```sql
CREATE TABLE articles (
    id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    bu_number            VARCHAR(20),
    section              VARCHAR(100),
    category             VARCHAR(100),
    title                TEXT NOT NULL,
    url                  TEXT UNIQUE NOT NULL,
    content_html         TEXT,
    content_text         TEXT,
    content_markdown     TEXT,
    published_date       DATE,
    updated_date         DATE,
    crawled_at           TIMESTAMPTZ DEFAULT NOW(),
    crawl_status         VARCHAR(20) DEFAULT 'pending',
    obsidian_path        TEXT,
    markdown_exported_at TIMESTAMPTZ,
    error_message        TEXT
);
```

### 4.2 `article_chunks` 테이블

```sql
CREATE TABLE article_chunks (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    article_id   UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
    chunk_index  INT  NOT NULL,
    chunk_text   TEXT NOT NULL,
    embedding    VECTOR(768),
    token_count  INT,
    embedded_at  TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (article_id, chunk_index)
);

CREATE INDEX idx_chunks_embedding ON article_chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);
```

### 4.3 `crawl_sessions` 테이블

```sql
CREATE TABLE crawl_sessions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    session_type    VARCHAR(20) NOT NULL,
    category        VARCHAR(100),
    status          VARCHAR(20) DEFAULT 'running',
    total_items     INT DEFAULT 0,
    processed_items INT DEFAULT 0,
    failed_items    INT DEFAULT 0,
    started_at      TIMESTAMPTZ DEFAULT NOW(),
    finished_at     TIMESTAMPTZ,
    error_log       JSONB DEFAULT '[]'
);
```

---

## 5. 소스 데이터

### 5.1 실제 크롤링 결과

#### A섹션: 알아야 할 기초

| 카테고리 | 아티클 | 크롤됨 | 청크 |
|---|---|---|---|
| Introduction | 3 | 3 | 35 |
| Crash Course on Batteries | 12 | 12 | 161 |
| Battery Types | 20 | 20 | 287 |
| Packaging and Safety | 15 | 15 | 225 |
| Charge Methods | 19 | 19 | 257 |
| Discharge Methods | 5 | 5 | 64 |

#### B섹션: 배터리와 나

| 카테고리 | 아티클 | 크롤됨 | 청크 |
|---|---|---|---|
| Smart Battery | 6 | 6 | 82 |
| From Birth to Retirement | 11 | 11 | 162 |
| How to Prolong Battery Life | 27 | 27 | 337 |
| Battery Testing and Monitoring | 30 | 30 | 388 |

#### C섹션: 전력원으로서의 배터리

| 카테고리 | 아티클 | 크롤됨 | 청크 |
|---|---|---|---|
| Amazing Value of a Battery | 12 | 12 | 185 |
| Information & Reference | 9 | 8 | 108 |
| Learning Tools | 4 | 4 | 38 |
| Battery Articles | 40 | 40 | 630 |

**합계: 213개 정의, 212개 크롤 완료, 2,959개 청크 임베딩**

---

## 6. 기능 명세

### 6.1 크롤링

**엔드포인트:** `POST /api/crawl/start`

- 0.5~1.5초 랜덤 딜레이로 순차 크롤링
- `markdownify`로 HTML을 Markdown으로 변환하여 저장
- 중복 제거: 이미 크롤된 URL 건너뜀
- SSE 진행 상황: `GET /api/crawl/progress/{session_id}`

### 6.2 임베딩

**엔드포인트:** `POST /api/embed/start`

- `RecursiveCharacterTextSplitter`: 1,600자(~400토큰), 200자 오버랩
- `OllamaEmbeddings(model="nomic-embed-text")`: 768차원 벡터
- 배치 크기: 20청크/Ollama 호출
- `re_embed=true`: 기존 청크 삭제 후 재삽입
- `re_embed=false`: 이미 청크가 있는 아티클 건너뜀
- SSE 진행 상황: `GET /api/embed/progress/{session_id}`

### 6.3 Obsidian 내보내기

**엔드포인트:** `POST /api/obsidian/export`

**볼트 폴더 구조:**
```
D:\Obsidian\BatteryHub\
└── Battery University\
    ├── A. Basics You Should Know\
    ├── B. The Battery and You\
    └── C. Batteries as Power Source\
```

파일명: `{BU번호} {제목}.md` / Obsidian URI: `obsidian://open?vault=BatteryHub&file=...`

### 6.4 RAG 검색

**엔드포인트:** `POST /api/search`

파이프라인: 한국어 감지 → 영어 번역(gemma3:4b) → 쿼리 임베딩 → pgvector 검색 → 컨텍스트 조합 → 답변 생성 → `obsidian_uri` 포함 응답 반환

---

## 7. API 레퍼런스

| 메서드 | 경로 | 설명 |
|---|---|---|
| `POST` | `/api/crawl/start` | 크롤링 작업 시작 |
| `GET` | `/api/crawl/progress/{id}` | SSE 진행 스트림 |
| `GET` | `/api/crawl/categories` | 카테고리 목록 |
| `GET` | `/api/crawl/articles` | 아티클 목록 |
| `POST` | `/api/embed/start` | 임베딩 작업 시작 |
| `GET` | `/api/embed/progress/{id}` | SSE 진행 스트림 |
| `GET` | `/api/embed/stats` | 임베딩 통계 |
| `POST` | `/api/obsidian/export` | Obsidian 내보내기 시작 |
| `GET` | `/api/obsidian/progress/{id}` | SSE 진행 스트림 |
| `GET` | `/api/obsidian/stats` | 내보내기 통계 |
| `GET` | `/api/obsidian/open-uri/{id}` | Obsidian 딥링크 URI 조회 |
| `POST` | `/api/search` | RAG 검색 |
| `GET` | `/api/health` | 시스템 헬스 체크 |

---

## 8. 프론트엔드 UI 설계

### 디자인 토큰

| 토큰 | 값 | 용도 |
|---|---|---|
| 배경 | `#0f1117` | 페이지 배경 |
| 서피스 | `#1a1d27` | 카드, 사이드바 |
| 경계선 | `#2a2d3a` | 구분선 |
| 주색상 | `blue-500` | 버튼, 활성 네비 |
| 강조색 | `cyan-400` | 청크 수, 점수 |
| Obsidian | `violet-600` | Obsidian 버튼/배지 |
| 성공 | `emerald-500` | 완료 상태 |
| 경고 | `amber-500` | 진행 중 상태 |
| 오류 | `red-500` | 실패 상태 |

---

## 9. 알려진 버그 및 수정 사항 (v1.2)

### SSE 레이스 컨디션 (수정 완료)

**문제:** 프론트엔드가 `session_id`를 받자마자 `EventSource`를 생성하는데, 백그라운드 태스크가 아직 시작되기 전이면 `get_progress(session_id)`가 `{"status": "not_found"}`를 반환하여 SSE가 즉시 종료된다. 결과적으로 진행 바가 0%에서 멈추고 `not_found` 뱃지가 표시된다.

**수정:** `/api/embed/start`와 `/api/obsidian/export`에서 `background_tasks.add_task()` 호출 전에 `_progress[session_id]`를 `status: "running"`으로 미리 초기화.

**수정 파일:** `api/embedding.py`, `api/obsidian.py`

### PendingRollbackError (수정 완료)

**문제:** 백그라운드 태스크 내 DB 예외 발생 시 SQLAlchemy async 세션이 pending-rollback 상태가 된다. `except` 블록에서 추가 DB 쓰기 시 `PendingRollbackError`로 전체 태스크가 크래시되어 진행 바가 멈춘다.

**수정:** `embedder.py`와 `obsidian_exporter.py`의 `except` 블록 첫 줄에 `await db.rollback()` 추가.

**수정 파일:** `services/embedder.py`, `services/obsidian_exporter.py`

---

## 10. D:\Dev\03.portfolio 통합

- **Base URL:** `http://localhost:8000`
- **핵심 엔드포인트:** `POST /api/search`
- CORS: `localhost:3000`, `localhost:3001` 허용
- 인증 불필요 (내부 로컬 전용)

---

## 11. 비기능 요구사항

| 요구사항 | 목표 | 실측 |
|---|---|---|
| 크롤링 속도 | ~1건/초 | ~1건/초 |
| 임베딩 처리량 | 5~10 청크/초 | ~8 청크/초 |
| Obsidian 내보내기 속도 | ~50건/초 | ~50건/초 |
| RAG 검색 지연 | 10초 이내 | ~3~5초 |
| 총 청크 수 | ~2,000 | 2,959 |
| pgvector 인덱스 | HNSW m=16 ef=64 | 구현 완료 |

---

## 12. Ollama 모델 요구사항

```bash
ollama pull gemma3:4b          # ~2.5 GB
ollama pull nomic-embed-text   # ~274 MB
```