feat: local AI server scaffolding (FastAPI, RAG, embeddings). Port policy (>=26000), README/API docs, scripts.
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Virtual envs
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Editors/IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Cache/Build
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
206
README.md
Normal file
206
README.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
### 로컬 AI 서버 (Mac mini M4 Pro 64GB)
|
||||||
|
|
||||||
|
이 저장소는 Apple Silicon(M4 Pro, RAM 64GB) 환경에서 로컬 AI 모델을 실행해 API 서버로 활용하기 위한 기본 구성과 가이드를 제공합니다.
|
||||||
|
|
||||||
|
## 현재 설치 상태
|
||||||
|
|
||||||
|
- **러너**: Ollama 0.11.4 확인됨
|
||||||
|
- **Ollama 로컬 모델**:
|
||||||
|
- qwen2.5:1.5b
|
||||||
|
- mistral:7b
|
||||||
|
- **LM Studio 로컬 모델**:
|
||||||
|
- gemma-3-4b-it
|
||||||
|
|
||||||
|
## 하드웨어 요약 (권장 기준)
|
||||||
|
|
||||||
|
- **칩셋**: Apple M4 Pro (Metal/ANE 가속 활용 가능)
|
||||||
|
- **메모리**: 64GB 통합 메모리
|
||||||
|
- **권장 동시성**: 1–3 세션(프롬프트 길이에 따라 조절)
|
||||||
|
|
||||||
|
## 권장 모델 (M4 Pro 64GB)
|
||||||
|
|
||||||
|
- **일반 대화/업무**
|
||||||
|
- Llama 3.1 8B Instruct: 품질·속도 밸런스 좋음, 긴 문서 요약/대화에 적합
|
||||||
|
- Qwen2.5 7B Instruct: 정보 회수/한글 대응 우수, 속도 양호
|
||||||
|
- Mistral 7B Instruct: 경량/속도 지향, 기본 품질 안정적
|
||||||
|
- Gemma 2 9B IT: 간결한 답변과 대화 품질 균형
|
||||||
|
|
||||||
|
- **코딩 보조**
|
||||||
|
- Qwen2.5-Coder 7B: 코드 생성/수정/해설에 실용적, 메모리 요구도 낮음
|
||||||
|
- DeepSeek-Coder 6.7B 또는 16B(Lite): 코드 품질 강점, 16B는 속도·메모리 여유 필요
|
||||||
|
|
||||||
|
- **초경량**
|
||||||
|
- Phi-3.5/3.1 Mini(3–4B): 간단 질의응답/요약, 서버 부하가 낮음
|
||||||
|
|
||||||
|
- 참고: 14–32B급도 구동 가능하나(예: Qwen2.5 14B/32B), 긴 컨텍스트/동시성 시 메모리 여유가 적어질 수 있음. 70B급은 64GB 환경에서 가능하더라도 속도·안정성 상 비권장.
|
||||||
|
|
||||||
|
## 설치 (Ollama)
|
||||||
|
|
||||||
|
아래 명령으로 권장 모델을 내려받을 수 있습니다. 태그는 상황에 따라 업데이트될 수 있으니 `ollama run <model>` 시 안내를 확인하세요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 일반 대화
|
||||||
|
ollama pull llama3.1:8b-instruct
|
||||||
|
ollama pull qwen2.5:7b-instruct
|
||||||
|
ollama pull mistral:7b
|
||||||
|
ollama pull gemma2:9b-instruct
|
||||||
|
|
||||||
|
# 코딩 보조
|
||||||
|
ollama pull qwen2.5-coder:7b
|
||||||
|
ollama pull deepseek-coder:6.7b
|
||||||
|
|
||||||
|
# 초경량
|
||||||
|
ollama pull phi3:mini
|
||||||
|
```
|
||||||
|
|
||||||
|
이미 설치된 모델 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama list
|
||||||
|
```
|
||||||
|
|
||||||
|
모델 실행(대화형 테스트):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama run qwen2.5:7b-instruct
|
||||||
|
```
|
||||||
|
|
||||||
|
## REST API로 바로 쓰기 (Ollama 내장 서버)
|
||||||
|
|
||||||
|
Ollama는 기본적으로 `http://localhost:11434`에서 API를 제공합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 단발성 텍스트 생성
|
||||||
|
curl http://localhost:11434/api/generate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "qwen2.5:7b-instruct",
|
||||||
|
"prompt": "한국어로 이 모델의 장점을 3가지로 요약해줘",
|
||||||
|
"stream": false
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Chat 형식
|
||||||
|
curl http://localhost:11434/api/chat \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "llama3.1:8b-instruct",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "로컬 LLM 서버 운영 팁을 알려줘"}
|
||||||
|
],
|
||||||
|
"stream": false
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
TIP: 긴 문서를 다루려면 `num_ctx`(컨텍스트 길이)와 `num_thread`를 모델/하드웨어에 맞춰 조정하세요. 과도하게 늘리면 속도와 메모리 사용량이 크게 증가합니다.
|
||||||
|
|
||||||
|
## 포트 정책
|
||||||
|
|
||||||
|
- **AI 서버 표준 포트**: 26000 이상 사용 권장 (예: 26000)
|
||||||
|
- 환경 변수 `AI_SERVER_PORT`로 조정 가능. 기본값 26000.
|
||||||
|
|
||||||
|
개발 서버 실행 스크립트(`scripts/dev_server.sh`)는 위 정책을 따릅니다.
|
||||||
|
|
||||||
|
## API 개요 (Paperless/시놀로지 연동)
|
||||||
|
|
||||||
|
- 기본 베이스 모델(24/7): `BASE_MODEL` (기본: `qwen2.5:7b-instruct`)
|
||||||
|
- 온디맨드 부스팅 모델: `BOOST_MODEL` (기본: `qwen2.5:14b-instruct`)
|
||||||
|
- 임베딩(RAG): `EMBEDDING_MODEL` (기본: `nomic-embed-text`), 인덱스 파일 `INDEX_PATH` (기본: `data/index.jsonl`)
|
||||||
|
- 문서화: `http://localhost:26000/docs` (FastAPI 자동 문서)
|
||||||
|
|
||||||
|
### 헬스체크
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:26000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 검색(Search, RAG용)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:26000/search \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"query": "질문 내용",
|
||||||
|
"top_k": 5
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 채팅(Chat, RAG/부스팅 자동)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:26000/chat \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "문서 내용 기반으로 요약해줘"}
|
||||||
|
],
|
||||||
|
"use_rag": true,
|
||||||
|
"top_k": 5,
|
||||||
|
"force_boost": false,
|
||||||
|
"options": {"num_ctx": 32768, "temperature": 0.3}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
필드 설명:
|
||||||
|
- `use_rag`: 인덱스(`INDEX_PATH`)에서 상위 청크를 검색해 시스템 프롬프트로 주입
|
||||||
|
- `force_boost`: 강제로 부스팅 모델 사용(고난도/장문)
|
||||||
|
- `options`: Ollama 옵션(예: `num_ctx`, `temperature` 등)
|
||||||
|
|
||||||
|
### 인덱스 갱신(Upsert)
|
||||||
|
|
||||||
|
Paperless/시놀로지에서 추출한 본문 텍스트를 직접 인덱스에 추가합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:26000/index/upsert \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"rows": [
|
||||||
|
{"id": "paperless:123", "text": "문서 본문 텍스트", "source": "paperless"}
|
||||||
|
],
|
||||||
|
"embed": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 인덱스 리로드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:26000/index/reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paperless 훅(Webhook) 자리표시자
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:26000/paperless/hook \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"document_id": 123, "title": "문서제목", "tags": ["finance"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
해당 훅은 문서 도착을 통지받는 용도로 제공됩니다. 실제 본문 텍스트는 Paperless API로 조회해 `/index/upsert`로 추가하세요.
|
||||||
|
|
||||||
|
## 시놀로지 메일/오피스 연동 가이드(요약)
|
||||||
|
|
||||||
|
- **검색/QA 호출 엔드포인트**: `http://<AI서버IP>:26000/search`, `http://<AI서버IP>:26000/chat`
|
||||||
|
- **권장 흐름**:
|
||||||
|
- 메일/문서 본문 → `/index/upsert`로 인덱스 추가(임베딩 생성)
|
||||||
|
- 사용자 질의 → `/chat` 호출(`use_rag=true`) → 관련 청크 Top-k 주입 후 응답
|
||||||
|
- **모델 라우팅**:
|
||||||
|
- 기본: 베이스 모델(7B/8B)
|
||||||
|
- 장문/고난도: `force_boost=true` 또는 메시지 길이에 따라 자동 부스팅(14B)
|
||||||
|
|
||||||
|
## 환경 변수
|
||||||
|
|
||||||
|
- `AI_SERVER_PORT`(기본 26000): 서버 포트
|
||||||
|
- `OLLAMA_HOST`(기본 `http://localhost:11434`): Ollama API 호스트
|
||||||
|
- `BASE_MODEL`(기본 `qwen2.5:7b-instruct`)
|
||||||
|
- `BOOST_MODEL`(기본 `qwen2.5:14b-instruct`)
|
||||||
|
- `EMBEDDING_MODEL`(기본 `nomic-embed-text`)
|
||||||
|
- `INDEX_PATH`(기본 `data/index.jsonl`)
|
||||||
|
- `PAPERLESS_BASE_URL`, `PAPERLESS_TOKEN`(선택): Paperless API 연동 시 사용
|
||||||
|
|
||||||
|
## 이 저장소 사용 계획
|
||||||
|
|
||||||
|
1) Ollama API를 감싸는 경량 서버(Express 또는 FastAPI) 추가
|
||||||
|
2) 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공
|
||||||
|
3) 헬스체크/모델 선택/리밋/로깅 옵션 제공
|
||||||
|
|
||||||
|
우선 본 문서로 설치/선택 가이드를 정리했으며, 다음 단계에서 서버 스켈레톤과 샘플 클라이언트를 추가할 예정입니다.
|
||||||
|
|
||||||
35
data/index.jsonl
Normal file
35
data/index.jsonl
Normal file
File diff suppressed because one or more lines are too long
1717
data/기계진동 이론과 응용(제5판)_Chapter 10 유한 요소법 입문.txt
Normal file
1717
data/기계진동 이론과 응용(제5판)_Chapter 10 유한 요소법 입문.txt
Normal file
File diff suppressed because it is too large
Load Diff
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
requests==2.32.4
|
||||||
|
pydantic==2.8.2
|
||||||
|
pypdf==6.0.0
|
||||||
|
tiktoken==0.11.0
|
||||||
13
scripts/dev_server.sh
Executable file
13
scripts/dev_server.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11434}
|
||||||
|
export BASE_MODEL=${BASE_MODEL:-qwen2.5:7b-instruct}
|
||||||
|
export BOOST_MODEL=${BOOST_MODEL:-qwen2.5:14b-instruct}
|
||||||
|
export EMBEDDING_MODEL=${EMBEDDING_MODEL:-nomic-embed-text}
|
||||||
|
export INDEX_PATH=${INDEX_PATH:-data/index.jsonl}
|
||||||
|
export AI_SERVER_PORT=${AI_SERVER_PORT:-26000}
|
||||||
|
|
||||||
|
source .venv/bin/activate
|
||||||
|
exec uvicorn server.main:app --host 0.0.0.0 --port "$AI_SERVER_PORT" --reload
|
||||||
|
|
||||||
85
scripts/embed_ollama.py
Normal file
85
scripts/embed_ollama.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text: str, max_chars: int = 1200, overlap: int = 200) -> List[str]:
|
||||||
|
chunks: List[str] = []
|
||||||
|
start = 0
|
||||||
|
n = len(text)
|
||||||
|
while start < n:
|
||||||
|
end = min(start + max_chars, n)
|
||||||
|
chunk = text[start:end].strip()
|
||||||
|
if chunk:
|
||||||
|
chunks.append(chunk)
|
||||||
|
if end == n:
|
||||||
|
break
|
||||||
|
start = max(0, end - overlap)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def embed_texts_ollama(texts: List[str], model: str = "nomic-embed-text", host: str = "http://localhost:11434") -> List[List[float]]:
|
||||||
|
url = f"{host}/api/embeddings"
|
||||||
|
vectors: List[List[float]] = []
|
||||||
|
for t in texts:
|
||||||
|
resp = requests.post(url, json={"model": model, "prompt": t}, timeout=120)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
vectors.append(data["embedding"]) # type: ignore[index]
|
||||||
|
return vectors
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Build simple vector index using Ollama embeddings")
|
||||||
|
parser.add_argument("--text", default=None, help="Path to extracted .txt; default = first in data/")
|
||||||
|
parser.add_argument("--model", default="nomic-embed-text", help="Ollama embedding model name")
|
||||||
|
parser.add_argument("--host", default="http://localhost:11434", help="Ollama host")
|
||||||
|
parser.add_argument("--out", default="data/index.jsonl", help="Output JSONL path")
|
||||||
|
parser.add_argument("--max-chars", type=int, default=1200, help="Max characters per chunk")
|
||||||
|
parser.add_argument("--overlap", type=int, default=200, help="Characters overlap between chunks")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
data_dir = Path("data")
|
||||||
|
if args.text:
|
||||||
|
text_path = Path(args.text)
|
||||||
|
else:
|
||||||
|
txts = sorted(data_dir.glob("*.txt"))
|
||||||
|
if not txts:
|
||||||
|
raise SystemExit("data/*.txt가 없습니다. 먼저 scripts/pdf_stats.py로 PDF를 추출하세요.")
|
||||||
|
text_path = txts[0]
|
||||||
|
|
||||||
|
text = text_path.read_text(encoding="utf-8")
|
||||||
|
chunks = chunk_text(text, max_chars=args.max_chars, overlap=args.overlap)
|
||||||
|
|
||||||
|
vectors = embed_texts_ollama(chunks, model=args.model, host=args.host)
|
||||||
|
|
||||||
|
out_path = Path(args.out)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with out_path.open("w", encoding="utf-8") as f:
|
||||||
|
for i, (chunk, vec) in enumerate(zip(chunks, vectors)):
|
||||||
|
row: Dict[str, Any] = {
|
||||||
|
"id": f"{text_path.stem}:{i}",
|
||||||
|
"text": chunk,
|
||||||
|
"vector": vec,
|
||||||
|
"source": text_path.name,
|
||||||
|
}
|
||||||
|
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"source_text": str(text_path),
|
||||||
|
"embedding_model": args.model,
|
||||||
|
"host": args.host,
|
||||||
|
"chunks": len(chunks),
|
||||||
|
"index_path": str(out_path),
|
||||||
|
}
|
||||||
|
print(json.dumps(meta, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
15
scripts/install_server.sh
Executable file
15
scripts/install_server.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VENV_DIR=".venv"
|
||||||
|
|
||||||
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
|
python3 -m venv "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
echo "[ok] server deps installed in $VENV_DIR"
|
||||||
|
|
||||||
99
scripts/pdf_stats.py
Normal file
99
scripts/pdf_stats.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def detect_hangul_ratio(text: str) -> float:
|
||||||
|
han = len(re.findall(r"[\u3131-\u318E\uAC00-\uD7A3]", text))
|
||||||
|
total = max(len(text), 1)
|
||||||
|
return han / total
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: Path) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Extract full text from PDF and estimate token count")
|
||||||
|
parser.add_argument("pdf", nargs="?", help="Path to PDF; if omitted, first PDF in repo root is used")
|
||||||
|
parser.add_argument("--outdir", default="data", help="Output directory for extracted text")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
repo_root = Path(os.getcwd())
|
||||||
|
if args.pdf:
|
||||||
|
pdf_path = Path(args.pdf)
|
||||||
|
else:
|
||||||
|
# pick the first PDF in repo root
|
||||||
|
cands = sorted(repo_root.glob("*.pdf"))
|
||||||
|
if not cands:
|
||||||
|
print("{}")
|
||||||
|
return
|
||||||
|
pdf_path = cands[0]
|
||||||
|
|
||||||
|
# Lazy import with helpful error if missing
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
except Exception as e:
|
||||||
|
raise SystemExit(
|
||||||
|
"pypdf가 설치되어 있지 않습니다. 가상환경 생성 후 'pip install pypdf tiktoken'을 실행하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tokenizer
|
||||||
|
try:
|
||||||
|
import tiktoken
|
||||||
|
enc = tiktoken.get_encoding("cl100k_base")
|
||||||
|
def count_tokens(s: str) -> int:
|
||||||
|
return len(enc.encode(s))
|
||||||
|
tokenizer = "tiktoken(cl100k_base)"
|
||||||
|
except Exception:
|
||||||
|
def count_tokens(s: str) -> int:
|
||||||
|
# fallback heuristic
|
||||||
|
return int(len(s) / 3.3)
|
||||||
|
tokenizer = "heuristic_div_3.3"
|
||||||
|
|
||||||
|
reader = PdfReader(str(pdf_path))
|
||||||
|
num_pages = len(reader.pages)
|
||||||
|
|
||||||
|
# Full extraction
|
||||||
|
all_text_parts = []
|
||||||
|
for i in range(num_pages):
|
||||||
|
try:
|
||||||
|
page_text = reader.pages[i].extract_text() or ""
|
||||||
|
except Exception:
|
||||||
|
page_text = ""
|
||||||
|
all_text_parts.append(page_text)
|
||||||
|
full_text = "\n\n".join(all_text_parts).strip()
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
chars = len(full_text)
|
||||||
|
tokens = count_tokens(full_text)
|
||||||
|
hangul_ratio = detect_hangul_ratio(full_text)
|
||||||
|
size_bytes = pdf_path.stat().st_size
|
||||||
|
|
||||||
|
# Save text
|
||||||
|
outdir = Path(args.outdir)
|
||||||
|
ensure_dir(outdir)
|
||||||
|
txt_name = pdf_path.stem + ".txt"
|
||||||
|
out_txt = outdir / txt_name
|
||||||
|
out_txt.write_text(full_text, encoding="utf-8")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"pdf": str(pdf_path),
|
||||||
|
"pages": num_pages,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
"chars": chars,
|
||||||
|
"tokens": tokens,
|
||||||
|
"hangul_ratio": round(hangul_ratio, 4),
|
||||||
|
"tokenizer": tokenizer,
|
||||||
|
"text_path": str(out_txt),
|
||||||
|
}
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
16
scripts/venv_setup.sh
Executable file
16
scripts/venv_setup.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VENV_DIR=".venv"
|
||||||
|
|
||||||
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
|
python3 -m venv "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pypdf tiktoken
|
||||||
|
|
||||||
|
echo "[ok] venv ready at $VENV_DIR"
|
||||||
|
|
||||||
21
server/config.py
Normal file
21
server/config.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
ollama_host: str = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
||||||
|
base_model: str = os.getenv("BASE_MODEL", "qwen2.5:7b-instruct")
|
||||||
|
boost_model: str = os.getenv("BOOST_MODEL", "qwen2.5:14b-instruct")
|
||||||
|
embedding_model: str = os.getenv("EMBEDDING_MODEL", "nomic-embed-text")
|
||||||
|
index_path: str = os.getenv("INDEX_PATH", "data/index.jsonl")
|
||||||
|
|
||||||
|
# Paperless (user will provide API details)
|
||||||
|
paperless_base_url: str = os.getenv("PAPERLESS_BASE_URL", "")
|
||||||
|
paperless_token: str = os.getenv("PAPERLESS_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
73
server/index_store.py
Normal file
73
server/index_store.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def cosine_similarity(vec_a: List[float], vec_b: List[float]) -> float:
|
||||||
|
if not vec_a or not vec_b or len(vec_a) != len(vec_b):
|
||||||
|
return 0.0
|
||||||
|
dot = sum(a * b for a, b in zip(vec_a, vec_b))
|
||||||
|
na = math.sqrt(sum(a * a for a in vec_a))
|
||||||
|
nb = math.sqrt(sum(b * b for b in vec_b))
|
||||||
|
if na == 0.0 or nb == 0.0:
|
||||||
|
return 0.0
|
||||||
|
return dot / (na * nb)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IndexRow:
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
vector: List[float]
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
class JsonlIndex:
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.rows: List[IndexRow] = []
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
self.rows.clear()
|
||||||
|
if not self.path.exists():
|
||||||
|
return
|
||||||
|
with self.path.open("r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
obj = json.loads(line)
|
||||||
|
self.rows.append(IndexRow(
|
||||||
|
id=obj["id"],
|
||||||
|
text=obj["text"],
|
||||||
|
vector=obj["vector"],
|
||||||
|
source=obj.get("source", "")
|
||||||
|
))
|
||||||
|
|
||||||
|
def search(self, query_vec: List[float], top_k: int = 5) -> List[Tuple[IndexRow, float]]:
|
||||||
|
scored: List[Tuple[IndexRow, float]] = []
|
||||||
|
for row in self.rows:
|
||||||
|
score = cosine_similarity(query_vec, row.vector)
|
||||||
|
scored.append((row, score))
|
||||||
|
scored.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
return scored[:top_k]
|
||||||
|
|
||||||
|
def append(self, new_rows: List[IndexRow]) -> int:
|
||||||
|
if not new_rows:
|
||||||
|
return 0
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.path.open("a", encoding="utf-8") as f:
|
||||||
|
for r in new_rows:
|
||||||
|
obj = {"id": r.id, "text": r.text, "vector": r.vector, "source": r.source}
|
||||||
|
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||||
|
self.rows.extend(new_rows)
|
||||||
|
return len(new_rows)
|
||||||
|
|
||||||
|
def reload(self) -> int:
|
||||||
|
self._load()
|
||||||
|
return len(self.rows)
|
||||||
|
|
||||||
144
server/main.py
Normal file
144
server/main.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .ollama_client import OllamaClient
|
||||||
|
from .index_store import JsonlIndex
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Local AI Server", version="0.1.0")
|
||||||
|
ollama = OllamaClient(settings.ollama_host)
|
||||||
|
index = JsonlIndex(settings.index_path)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
model: str | None = None
|
||||||
|
messages: List[Dict[str, str]]
|
||||||
|
use_rag: bool = True
|
||||||
|
top_k: int = 5
|
||||||
|
force_boost: bool = False
|
||||||
|
options: Dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
top_k: int = 5
|
||||||
|
|
||||||
|
class UpsertRow(BaseModel):
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
source: str | None = None
|
||||||
|
|
||||||
|
class UpsertRequest(BaseModel):
|
||||||
|
rows: List[UpsertRow]
|
||||||
|
embed: bool = True
|
||||||
|
model: str | None = None
|
||||||
|
batch: int = 16
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"base_model": settings.base_model,
|
||||||
|
"boost_model": settings.boost_model,
|
||||||
|
"embedding_model": settings.embedding_model,
|
||||||
|
"index_loaded": len(index.rows) if index else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/search")
|
||||||
|
def search(req: SearchRequest) -> Dict[str, Any]:
|
||||||
|
if not index.rows:
|
||||||
|
return {"results": []}
|
||||||
|
qvec = ollama.embeddings(settings.embedding_model, req.query)
|
||||||
|
results = index.search(qvec, top_k=req.top_k)
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{"id": r.id, "score": float(score), "text": r.text[:400], "source": r.source}
|
||||||
|
for r, score in results
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chat")
|
||||||
|
def chat(req: ChatRequest) -> Dict[str, Any]:
|
||||||
|
model = req.model
|
||||||
|
if not model:
|
||||||
|
# 라우팅: 메시지 길이/force_boost 기준 간단 분기
|
||||||
|
total_chars = sum(len(m.get("content", "")) for m in req.messages)
|
||||||
|
model = settings.boost_model if (req.force_boost or total_chars > 2000) else settings.base_model
|
||||||
|
|
||||||
|
context_docs: List[str] = []
|
||||||
|
if req.use_rag and index.rows:
|
||||||
|
q = "\n".join([m.get("content", "") for m in req.messages if m.get("role") == "user"]).strip()
|
||||||
|
if q:
|
||||||
|
qvec = ollama.embeddings(settings.embedding_model, q)
|
||||||
|
hits = index.search(qvec, top_k=req.top_k)
|
||||||
|
context_docs = [r.text for r, _ in hits]
|
||||||
|
|
||||||
|
sys_prompt = ""
|
||||||
|
if context_docs:
|
||||||
|
sys_prompt = (
|
||||||
|
"당신은 문서 기반 비서입니다. 제공된 컨텍스트만 신뢰하고, 모르면 모른다고 답하세요.\n\n"
|
||||||
|
+ "\n\n".join(f"[DOC {i+1}]\n{t}" for i, t in enumerate(context_docs))
|
||||||
|
)
|
||||||
|
|
||||||
|
messages: List[Dict[str, str]] = []
|
||||||
|
if sys_prompt:
|
||||||
|
messages.append({"role": "system", "content": sys_prompt})
|
||||||
|
messages.extend(req.messages)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = ollama.chat(model, messages, stream=False, options=req.options)
|
||||||
|
return {"model": model, "response": resp}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/index/upsert")
|
||||||
|
def index_upsert(req: UpsertRequest) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
if not req.rows:
|
||||||
|
return {"added": 0}
|
||||||
|
model = req.model or settings.embedding_model
|
||||||
|
new_rows = []
|
||||||
|
for r in req.rows:
|
||||||
|
vec = ollama.embeddings(model, r.text) if req.embed else []
|
||||||
|
new_rows.append({
|
||||||
|
"id": r.id,
|
||||||
|
"text": r.text,
|
||||||
|
"vector": vec,
|
||||||
|
"source": r.source or "api",
|
||||||
|
})
|
||||||
|
# convert to IndexRow and append
|
||||||
|
from .index_store import IndexRow
|
||||||
|
to_append = [IndexRow(**nr) for nr in new_rows]
|
||||||
|
added = index.append(to_append)
|
||||||
|
return {"added": added}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"index_upsert_error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/index/reload")
|
||||||
|
def index_reload() -> Dict[str, Any]:
|
||||||
|
total = index.reload()
|
||||||
|
return {"total": total}
|
||||||
|
|
||||||
|
|
||||||
|
# Paperless webhook placeholder (to be wired with user-provided details)
|
||||||
|
class PaperlessHook(BaseModel):
|
||||||
|
document_id: int
|
||||||
|
title: str | None = None
|
||||||
|
tags: List[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/paperless/hook")
|
||||||
|
def paperless_hook(hook: PaperlessHook) -> Dict[str, Any]:
|
||||||
|
# NOTE: 확장 지점 - paperless API를 조회하여 문서 텍스트/메타데이터를 받아
|
||||||
|
# scripts/embed_ollama.py와 동일 로직으로 인덱스를 업데이트할 수 있습니다.
|
||||||
|
return {"status": "ack", "document_id": hook.document_id}
|
||||||
|
|
||||||
29
server/ollama_client.py
Normal file
29
server/ollama_client.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaClient:
|
||||||
|
def __init__(self, host: str) -> None:
|
||||||
|
host = host.strip()
|
||||||
|
if not host.startswith("http://") and not host.startswith("https://"):
|
||||||
|
host = "http://" + host
|
||||||
|
self.host = host.rstrip("/")
|
||||||
|
|
||||||
|
def embeddings(self, model: str, text: str) -> List[float]:
|
||||||
|
url = f"{self.host}/api/embeddings"
|
||||||
|
resp = requests.post(url, json={"model": model, "prompt": text}, timeout=120)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data["embedding"]
|
||||||
|
|
||||||
|
def chat(self, model: str, messages: List[Dict[str, str]], stream: bool = False, options: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
||||||
|
url = f"{self.host}/api/chat"
|
||||||
|
payload: Dict[str, Any] = {"model": model, "messages": messages, "stream": stream}
|
||||||
|
if options:
|
||||||
|
payload["options"] = options
|
||||||
|
resp = requests.post(url, json=payload, timeout=600)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
BIN
기계진동 이론과 응용(제5판)_Chapter 10 유한 요소법 입문.pdf
Normal file
BIN
기계진동 이론과 응용(제5판)_Chapter 10 유한 요소법 입문.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user