목표
Phase 1의 목표는 일단 동작하게 만드는게 목표다.
동작이라 함은 FastAPI를 통해 로컬 AI 를 사용해 메모를 의미기반으로 찾고 내가 작성한 내용을 AI로 분류 후 메모를 옵시디언에 작성하는것이다. 이걸 API 스펙을 작성하면 다음과 같다.
POST /api/v1/notes/process # 새 노트 정리 및 저장
POST /api/v1/search # 의미 기반 노트 검색
POST /api/v1/batch/process # 미처리 노트 일괄 임베딩
GET /api/v1/notes/list # 노트 목록 조회
GET api/v1/notes/{note_path}/relationships # 특정 노트의 관련 노트 찾기
프로젝트 구조
1. obsidian_manager.py
- Obsidian 볼트와 파일 I/O를 담당
- 마크다운 파일을 읽고 쓰고, YAML frontmatter를 파싱하고, 메타데이터를 업데이트하는 역할
2. llm_service.py
- Ollama와 통신하는 모듈
- 메모를 받아서 구조화된 JSON으로 정리하고, 텍스트를 벡터로 변환하고, 노트 간 의미적 관계를 감지하는 기능을 담당
3. vector_db.py
- LanceDB 기반의 벡터 저장소
- 노트를 벡터로 저장하고, 유사도 기반으로 검색하는 핵심 기능
4. embedding_pipeline.py
- 위 세 모듈을 묶는 워크플로우
- 단일 노트 처리, 배치 처리, 유사 노트 탐색을 하나의 흐름으로 연결
AI 모델 선택 — Mistral
계획 단계에서는 Qwen 계열 모델을 고려했지만, 실제 MVP에는 Mistral 7B 를 사용했다. Ollama에 Qwen2.5-8b-instruct 가 찾아지지 않는다.;;;
일단 테스트를 위해서 Mistral 를 써보고 이상하면 변경하도록 하자.
임베딩도 동일하게 Mistral을 사용하고 있는데, 4096차원 벡터를 생성한다.
추후 한국어 특화 임베딩 모델로 교체할수 도 있다.
데이터가 어떻게 흐르는가
메모 하나를 입력했을 때 내부에서 일어나는 일을 순서대로 정리하면 이렇다.

이 전체 흐름이 외부 서버 없이 내 맥북 안에서만 돌아간다.
Obsidian 노트가 어떻게 생성되는가
실제로 생성된 노트의 frontmatter는 이런 모습이다.
title: "FastAPI 비동기 처리"
tags: ["fastapi", "python", "async"]
category: "Project"
created: "2026-02-20T09:18:31"
ai:
vectorized: true
embedding_id: "550e8400-..."
last_indexed: "2026-02-20T09:18:31"
source: "manual"
obsidian_manager.py — Obsidian 파일 I/O
Obsidian 볼트의 .md 파일을 읽고 쓰는 모든 작업을 담당한다.
class ObsidianVaultManager:
def __init__(self, vault_path: str)
def read_note(self, file_path: str) -> Dict
def write_note(self, file_path: str, title: str, body: str, frontmatter: Dict) -> bool
def list_notes(self) -> list
def update_frontmatter(self, file_path: str, updates: Dict) -> bool
def delete_note(self, file_path: str) -> bool
메서드가 5개로 단순하다. CRUD에서 Update는 frontmatter 전용으로만 만들었다. 노트 본문을 직접 수정하는 케이스가 현재는 없고 나중에 고도화 하면서 추가해보록 하자.
핵심 로직 — read_note()
def read_note(self, file_path: str) -> Dict:
# ...
if content.startswith('---'):
parts = content.split('---', 2)
if len(parts) >= 3:
frontmatter = yaml.safe_load(parts[1]) or {}
body = parts[2].strip()
return {
"path": file_path,
"frontmatter": frontmatter,
"body": body,
"full_content": content
}
마크다운 파일을 --- 구분자로 쪼개서 frontmatter와 본문을 분리한다. split('---', 2)에서 2가 핵심인데, 세 번째 이후 ---는 본문의 일부일 수 있으므로 최대 2번만 자른다.
반환값에 frontmatter, body, full_content를 모두 담아서 호출하는 쪽에서 필요한 것만 꺼내 쓸 수 있게 했다.
핵심 로직 — write_note()
def write_note(self, file_path: str, title: str, body: str, frontmatter: Dict) -> bool:
frontmatter_str = yaml.dump(
frontmatter,
default_flow_style=False,
allow_unicode=True,
sort_keys=False
)
content = f"---\n{frontmatter_str}---\n\n{body}"
allow_unicode=True로 한국어가 이스케이프되지 않게 했고, sort_keys=False로 딕셔너리 키 순서를 유지했다. Obsidian에서 title이 항상 첫 줄에 오도록 frontmatter를 넘기는 순서가 중요하다.
2. llm_service.py — Ollama LLM 통신
class OllamaService:
def check_health(self) -> bool
def organize_note(self, raw_text: str) -> Dict
def get_embedding(self, text: str) -> List[float]
def detect_relationships(self, note_content: str, other_notes: List[str]) -> List[Dict]
def get_available_models(self) -> List[str]
퍼블릭 메서드는 5개지만, 내부적으로는 각 기능을 프롬프트 생성 → API 호출 → 응답 파싱의 3단계 프라이빗 메서드로 분리했다.
# organize_note 내부 흐름
def organize_note(self, raw_text: str) -> Dict:
prompt = self._create_organize_prompt(raw_text) # 1. 프롬프트 생성
response = self._call_ollama(prompt) # 2. API 호출
return self._parse_json_response(response, raw_text) # 3. 파싱
이렇게 쪼개면 프롬프트만 바꾸거나, API 호출 로직만 바꾸거나, 파싱 로직만 바꿀 때 다른 코드에 영향을 주지 않는다.
핵심 로직 — organize_note()
프롬프트 구조는 이렇다.
다음 메모를 PARA 방식으로 정리해줘.
메모: {raw_text}
다음 JSON 형식으로 응답해줘:
{
"title": "노트 제목 (10자 이내)",
"category": "Project/Area/Resource/Archive",
"summary": "한 줄 요약",
"tags": ["태그1", "태그2"],
"content": "정리된 본문 내용"
}
응답은 JSON만 반환하세요.
Ollama API 호출 시 "format": "json" 옵션을 넘기는 게 중요하다. 이 옵션이 있으면 모델이 JSON 외의 텍스트를 앞뒤에 붙이지 않도록 강제된다.
def _parse_json_response(self, response_dict: Dict, raw_text: str) -> Dict:
try:
return json.loads(response_dict["response"])
except json.JSONDecodeError:
# 파싱 실패 시 원본 텍스트를 content에 담아서 반환
return {
"title": "Untitled",
"category": "Resource",
"content": raw_text # 원본 보존
}
핵심 로직 — get_embedding()
def get_embedding(self, text: str) -> List[float]:
response = self._call_embedding_api(text)
return self._extract_embedding_vector(response)
Mistral은 4096차원 벡터를 생성한다. 이 숫자가 LanceDB 테이블 스키마와 반드시 일치해야 하는데, 나중에 모델을 교체할 때 차원이 달라지면 DB를 재구축해야 한다. 그래서 rebuild_embeddings() API를 별도로 만들었다.
설계 포인트
slm_model과 embedding_model을 생성자에서 분리해서 받는다.
def __init__(self, base_url: str, slm_model: str, embedding_model: str = None, ...):
self.embedding_model = embedding_model or slm_model
현재는 둘 다 Mistral을 쓰지만, 나중에 임베딩 모델을 다른 모델로 교체할 때 설정 파일 한 줄만 바꾸면 된다.
3. vector_db.py — LanceDB 벡터 저장소
class VectorDBManager:
def add_note(path, title, content, embedding, tags) -> str
def search(query_embedding, top_k) -> List[Dict]
def get_note_by_id(note_id) -> Optional[Dict]
def get_note_by_path(path) -> Optional[Dict]
def update_note(note_id, updates) -> bool
def delete_note(note_id) -> bool
def delete_all() -> bool
def search_by_tags(tags, top_k) -> List[Dict]
def count() -> int
저장되는 데이터 구조
note_data = {
"id": str(uuid4()), # 고유 식별자
"path": path, # 노트 파일 경로
"title": title, # 노트 제목
"content": content, # 노트 본문
"embedding": embedding, # 4096차원 벡터
"tags": tags or [], # 태그 목록
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat()
}
핵심 로직 — add_note()
def add_note(self, ...):
if self.table is None:
# 첫 노트 추가 시 테이블 생성
self.table = self.db.create_table(
self.table_name,
data=[note_data],
mode="overwrite"
)
else:
self.table.add([note_data])
LanceDB는 첫 데이터를 넣을 때 스키마가 확정된다. self.table is None 체크로 최초 생성과 이후 추가를 분기했다. 이 분기가 없으면 빈 DB에 add를 호출할 때 에러가 난다.
핵심 로직 — search()
def search(self, query_embedding: List[float], top_k: int = 5) -> List[Dict]:
results = self.table.search(query_embedding).limit(top_k).to_list()
return results
LanceDB의 벡터 검색은 이게 전부다. 내부적으로 ANN(Approximate Nearest Neighbor) 알고리즘으로 유사한 벡터를 찾아 _distance 필드에 거리값을 담아서 반환한다. 값이 작을수록 더 유사하다.
핵심 로직 — update_note()
def update_note(self, note_id: str, updates: Dict) -> bool:
note = self.get_note_by_id(note_id)
note.update(updates)
self.delete_note(note_id) # 기존 삭제
self.table.add([note]) # 새로 추가
LanceDB는 인플레이스 업데이트를 지원하지 않아서 삭제 후 재삽입 방식을 쓴다. 노트 수가 많아지면 성능 이슈가 생길 수 있는 부분이라 추후 꼭 수정이 필요하다.
설계 포인트
search_by_tags()는 벡터 검색이 아니라 전체 스캔 방식이다.
def search_by_tags(self, tags: List[str], top_k: int = 10) -> List[Dict]:
all_notes = self.table.search().to_list()
matching_notes = [
note for note in all_notes
if any(tag in note.get("tags", []) for tag in tags)
]
return matching_notes[:top_k]
노트가 수천 개를 넘어가면 느려질 수 있다. 지금 규모에서는 문제없지만, 나중에 LanceDB의 필터 기능으로 교체할 여지를 남겨뒀다.
4. embedding_pipeline.py — 전체 워크플로우 통합
class EmbeddingPipeline:
def process_single_note(file_path) -> bool
def process_batch(skip_vectorized=True) -> Dict
def find_similar_notes(file_path, top_k) -> List[Dict]
def get_note_relationships(file_path) -> Dict
def get_statistics() -> Dict
def rebuild_embeddings() -> Dict
핵심 로직 — process_single_note()
이게 이 서비스의 핵심 흐름이다.
def process_single_note(self, file_path: str) -> bool:
# 1. 파일 읽기
note = self.vault.read_note(file_path)
# 2. 이미 임베딩된 경우 스킵
if note["frontmatter"].get("ai", {}).get("vectorized"):
return True
# 3. 길이 제한 (Mistral 토큰 한계 대응)
body_text = note["body"][:4000]
# 4. 임베딩 생성
embedding = self.llm.get_embedding(body_text)
# 5. LanceDB에 저장
note_id = self.vector_db.add_note(
path=file_path,
title=note["frontmatter"].get("title", "Untitled"),
content=note["body"],
embedding=embedding,
tags=note["frontmatter"].get("tags", [])
)
# 6. Frontmatter에 벡터화 완료 표시
self.vault.update_frontmatter(file_path, {
"ai": {
"vectorized": True,
"embedding_id": note_id,
"last_indexed": datetime.now().isoformat(),
"embedding_dimension": len(embedding)
}
})
2번 스킵 로직이 중요하다. frontmatter의 ai.vectorized가 True면 건너뛴다. 배치 처리할 때 이미 처리된 노트를 반복 처리하지 않기 위한 장치다.
3번 길이 제한도 실제 운영하면서 추가한 부분이다. Mistral의 컨텍스트 윈도우 한계 때문에 긴 노트는 앞 4000자만 임베딩에 사용한다.
핵심 로직 — process_batch()
def process_batch(self, skip_vectorized: bool = True) -> Dict:
notes = self.vault.list_notes()
processed, failed, skipped = 0, 0, 0
for file_path in notes:
if skip_vectorized:
note = self.vault.read_note(file_path)
if note["frontmatter"].get("ai", {}).get("vectorized"):
skipped += 1
continue
if self.process_single_note(file_path):
processed += 1
else:
failed += 1
return {"processed": processed, "failed": failed, "skipped": skipped, "total": len(notes)}
skip_vectorized=False로 호출하면 전체 재처리가 된다. rebuild_embeddings()에서 이 옵션을 활용해서 모델 교체 후 전체 재임베딩을 지원한다.
핵심 로직 — rebuild_embeddings()
def rebuild_embeddings(self) -> Dict:
# 1. 모든 노트의 ai 메타데이터 초기화
for file_path in self.vault.list_notes():
note["frontmatter"]["ai"] = {"vectorized": False}
self.vault.update_frontmatter(file_path, note["frontmatter"])
# 2. VectorDB 전체 삭제
self.vector_db.delete_all()
# 3. 배치 재처리
return self.process_batch(skip_vectorized=False)
모델을 바꿨을 때 기존 벡터가 새 모델과 호환되지 않으므로 전체를 지우고 다시 만든다. 시간이 오래 걸리지만 데이터 일관성을 위해 필요한 작업이다.
5. main.py — FastAPI 서버
# 순서대로 초기화, 각각 실패해도 서버는 뜬다
vault_manager = ObsidianVaultManager(config["vault"]["path"])
llm_service = OllamaService(...)
vector_db = VectorDBManager(...)
embedding_pipeline = EmbeddingPipeline(vault_manager, llm_service, vector_db)
각 초기화를 try/except로 감싸서 하나가 실패해도 서버 자체는 뜨도록 했다. API 호출 시 초기화 실패 여부를 체크해서 에러를 반환한다.
핵심 로직 — process_note() 타이밍 분석
timings = {}
step_start = time.perf_counter()
structured = llm_service.organize_note(request.content)
timings["organize_note"] = time.perf_counter() - step_start
# ... 각 단계마다 측정
각 단계별 처리 시간을 측정해서 로그로 남긴다. 실제로 돌려보면 organize_note와 embedding 단계가 전체 시간의 80% 이상을 차지한다. 나중에 병목 구간을 최적화할 때 이 로그가 기준이 된다.
이제 실행해보자. 총 두개를 실행해야 하는데 model 서버인 ollama 와 우리가 제작한 python 을 실행하면 된다.
Ollama
# 서버 시작
ollama serve
# 서버 종료
# 방법 1: 터미널에서 Ctrl+C 누르기
# (서버 실행 중인 터미널에서)
Ctrl+C
# 방법 2: 다른 터미널에서 프로세스 종료
pkill -f "ollama serve"
# 방법 3: Mac/Linux에서
killall ollama
Pyrhon 코드 실행
# 터미널 1: Ollama 서버 시작
ollama serve
# 터미널 2: FastAPI 서버 실행
python src/main.py
# 그러면 http://127.0.0.1:8000에서 접근 가능
# http://127.0.0.1:8000/docs (Swagger UI)
결과
되긴....했는데 결과가 너무 안좋다...ㅜ
일단 결과는 다음과 같다.
나는 POST /api/v1/notes/process API 에 '뭐 먹고 살아야 하지?' 라고 작성하였고 결과는 healthy diet duideline 이란 제목의 말도 안되는 노트가 생성되었다.

일단 post 요청을 보내면 아래와 같이 숨김파일로 같은 노트위치에 .vector-index 가 생성된것을 볼 수 있다.

일단 파일 내에는 이렇게 구정되었다.
/Users/sumin/Documents/Obsidian Vault/SecendBrain/.vector-index/
├── notes.lance ← 메인 인덱스 파일
├── _transactions/ ← 변경 기록 (데이터 무결성)
├── _versions/ ← 버전 스냅샷 (과거 복원용)
└── data/
├── data_0.lance ← 실제 벡터 데이터
├── data_1.lance
└── ...
수정해야 하는 부분
lim_service.py
1. detect_relationships
- 최대 10개까지만 연관성을 찾는데 고도화가 필요하다.
- 방법 1: 최근 10개?
- 방법 2 : 모든 노트에 태그를 달아서 태그 기준으로 전부 훑어보기?
2. _create_organize_prompt
- 다음 메모를 para 방식으로 정리해줘 <- PARA 노트 작성법으로 해놨는데 과연 이게 성능을 떨어뜨리지 않을까? 보다 나은 방법이 있을까?
- 지금은 내 짧은 메모 마다 매번 md 파일로 계속 쌓이는데 별로인거 같은데?
- 차라리 Archive 라는 폴더 내에 정리하는것은 어떨까?
3. organize_note
- 30자 이하의 텍스트는 평균 5초 걸림
- 영어로 저장함 -> 한국어로 바꾸어야 함
4. 모델
- mistral 모델을 사용하였을때 의미가 이상하게 작성됨 (모델을 변경해야 할듯)
- "뭐 먹고 살아야 하지?" -> "Healthy Diet Guidelines"
5. embedding_pipline.py
- 4000 자 이상이면 인베딩 하지 않음
마치며
지금 대충 개발은 했지만 거친 부분이 많다. 한국어 노트에서 임베딩 품질이 기대보다 낮게 나온다.

'AI에서 살아남기(to. Android Developer) > 1인 프로젝트' 카테고리의 다른 글
| [Virgin road] React Bits 의 Ballpit 코드를 수정해보자. (0) | 2026.03.09 |
|---|---|
| 리엑트로 PDF 를 만들어보자 (0) | 2026.03.09 |
| [Oh! my SecendBrain] 2. 아이디어와 서비스 개요 (0) | 2026.02.20 |
| [Oh! my SecendBrain] 1. 소개 (0) | 2026.02.20 |
| (모두의 웹툰) Part7 : 인프라 구축기 [ver 2] (0) | 2025.12.19 |