Skip to content

Commit da3eb1e

Browse files
authored
Merge pull request #16 from Team-StackUp/feature/us-09-chunking-embedding
Feature/us 09 chunking embedding - Chunking 및 Embedding 작성
2 parents c74d936 + dcc11b7 commit da3eb1e

33 files changed

Lines changed: 1242 additions & 130 deletions

.github/workflows/deploy-app.yml

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,25 +43,32 @@ jobs:
4343
--exclude='**/node_modules' \
4444
"$GITHUB_WORKSPACE/" "$DEPLOY_DIR/"
4545
46-
- name: Ensure .env exists and contains current LLM_API_KEY
46+
- name: Ensure .env exists and contains current secrets
4747
env:
4848
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
49+
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
4950
run: |
5051
cd "$DEPLOY_DIR"
5152
if [ ! -f .env ]; then
5253
cp .env.example .env
5354
fi
54-
# Write LLM_API_KEY via a temp file to keep the secret off the
55-
# process command line.
56-
tmp=$(mktemp)
57-
trap 'rm -f "$tmp"' EXIT
58-
awk -v key="$LLM_API_KEY" '
59-
BEGIN { written = 0 }
60-
/^LLM_API_KEY=/ { print "LLM_API_KEY=" key; written = 1; next }
61-
{ print }
62-
END { if (!written) print "LLM_API_KEY=" key }
63-
' .env > "$tmp"
64-
mv "$tmp" .env
55+
# 비밀 값은 임시 파일을 거쳐서 .env에 주입 — 명령줄 노출 회피.
56+
# 키마다 같은 awk 블록을 한 번씩 적용.
57+
inject_secret() {
58+
local name="$1"
59+
local value="$2"
60+
local tmp
61+
tmp=$(mktemp)
62+
awk -v k="$name" -v v="$value" '
63+
BEGIN { written = 0 }
64+
$0 ~ "^" k "=" { print k "=" v; written = 1; next }
65+
{ print }
66+
END { if (!written) print k "=" v }
67+
' .env > "$tmp"
68+
mv "$tmp" .env
69+
}
70+
inject_secret LLM_API_KEY "$LLM_API_KEY"
71+
inject_secret GEMINI_API_KEY "$GEMINI_API_KEY"
6572
chmod 600 .env
6673
6774
- name: Build and restart app services

ai/.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,14 @@ REPO_FETCH_TIMEOUT_SEC=30
3333
# 웹 이력서 관련
3434
WEB_FETCH_TIMEOUT_SEC=20
3535
WEB_MAX_HTML_BYTES=2000000
36+
37+
# 개발 및 테스트는 mock
38+
# 운영은 gemini
39+
EMBEDDING_PROVIDER=gemini
40+
EMBEDDING_MODEL=gemini-embedding-001
41+
EMBEDDING_DIM=1536
42+
EMBEDDING_CHUNK_SIZE=1000
43+
EMBEDDING_CHUNK_OVERLAP=200
44+
EMBEDDING_BATCH_SIZE=32
45+
46+
GEMINI_API_KEY=

ai/CLAUDE.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,14 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...)
164164

165165
## 7. RAG 파이프라인
166166

167-
### 7.1 인제스트
168-
1. 마크다운 입력
169-
2. 청킹 (LangChain `RecursiveCharacterTextSplitter`, chunk_size=1000, overlap=200)
170-
3. 임베딩 생성 (Gemini `text-embedding-004` 또는 OpenAI `text-embedding-3-small`)
171-
4. Core API 호출 → pgvector INSERT
167+
### 7.1 인제스트 (본 구현)
168+
1. 마크다운 입력 (`analyzer/_embedding_step.chunk_embed_and_upsert`)
169+
2. 청킹 — `rag/chunker.MarkdownChunker` (`RecursiveCharacterTextSplitter`, 기본 1000/200)
170+
3. 임베딩 생성 — `rag/embedder.EmbeddingProvider`. 현재 구현체:
171+
- `MockEmbeddingProvider` (default) — 차원 결정 보류, e2e 흐름 검증용
172+
- `openai` / `ollama` 구현체는 후속 PR
173+
4. Core API 호출 — `CoreClient.upsert_embeddings(document_id, model, dim, chunks)`
174+
`PUT /api/internal/documents/{id}/embeddings` (idempotent upsert)
172175

173176
### 7.2 검색
174177
1. 쿼리 텍스트 → 임베딩

ai/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ dependencies = [
1717
"aiofiles>=24.1.0",
1818
"pypdf>=5.1.0",
1919
"trafilatura>=2.0.0",
20+
"langchain-text-splitters>=0.3.0",
21+
"google-genai>=1.0.0",
2022
"langchain>=1.2.13",
2123
"langchain-core>=1.2.22",
2224
"langchain-community>=0.4.1",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# 공통 임베딩 모듈
2+
from __future__ import annotations
3+
4+
import structlog
5+
6+
from ai_server.core.client import (
7+
CoreClient,
8+
CoreEmbeddingUpsertError,
9+
EmbeddingChunkPayload,
10+
)
11+
from ai_server.rag.chunker import MarkdownChunker
12+
from ai_server.rag.embedder import EmbeddingError, EmbeddingProvider
13+
14+
log = structlog.get_logger(__name__)
15+
16+
17+
class EmbeddingStepError(Exception):
18+
def __init__(self, *, code: str, message: str, retriable: bool) -> None:
19+
super().__init__(message)
20+
self.code = code
21+
self.message = message
22+
self.retriable = retriable
23+
24+
25+
async def chunk_embed_and_upsert(
26+
*,
27+
document_id: int,
28+
markdown: str,
29+
chunker: MarkdownChunker,
30+
embedder: EmbeddingProvider,
31+
core_client: CoreClient,
32+
log_prefix: str = "analyze",
33+
) -> int:
34+
chunks = chunker.split(markdown)
35+
log.info(
36+
f"{log_prefix}.chunk.done",
37+
document_id=document_id,
38+
chunk_count=len(chunks),
39+
)
40+
41+
if not chunks:
42+
return 0
43+
44+
try:
45+
vectors = await embedder.embed([c.text for c in chunks])
46+
except EmbeddingError as err:
47+
raise EmbeddingStepError(
48+
code=err.code, message=err.message, retriable=err.retriable
49+
) from err
50+
51+
if len(vectors) != len(chunks):
52+
raise EmbeddingStepError(
53+
code="EMBED_COUNT_MISMATCH",
54+
message=(f"embedder가 chunk {len(chunks)}개 중 {len(vectors)}개만 반환"),
55+
retriable=True,
56+
)
57+
58+
payloads = [
59+
EmbeddingChunkPayload(
60+
chunk_index=chunks[i].index,
61+
chunk_text=chunks[i].text,
62+
embedding=vectors[i],
63+
)
64+
for i in range(len(chunks))
65+
]
66+
67+
try:
68+
upserted = await core_client.upsert_embeddings(
69+
document_id=document_id,
70+
model=embedder.model,
71+
dim=embedder.dim,
72+
chunks=payloads,
73+
)
74+
except CoreEmbeddingUpsertError as err:
75+
raise EmbeddingStepError(
76+
code=err.code, message=err.message, retriable=err.retriable
77+
) from err
78+
79+
log.info(
80+
f"{log_prefix}.embed.upserted",
81+
document_id=document_id,
82+
chunk_count=upserted,
83+
model=embedder.model,
84+
dim=embedder.dim,
85+
)
86+
return upserted

ai/src/ai_server/analyzer/repository_analyzer.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@
44

55
import structlog
66

7+
from ai_server.analyzer._embedding_step import (
8+
EmbeddingStepError,
9+
chunk_embed_and_upsert,
10+
)
711
from ai_server.analyzer.sources.github_repo import (
812
GitHubRepoSourceExtractor,
913
RepositoryFetchError,
1014
)
1115
from ai_server.chain.document_analysis_chain import DocumentAnalyzer
1216
from ai_server.core.client import CoreClient, CoreTokenError
17+
from ai_server.rag.chunker import MarkdownChunker
18+
from ai_server.rag.embedder import EmbeddingProvider
1319
from ai_server.storage.base import ObjectStorage
1420

1521
log = structlog.get_logger(__name__)
@@ -28,10 +34,10 @@ class RepositoryAnalysisResult:
2834
summary: str
2935
tech_stack: list[str]
3036
document_path: str
37+
embedding_chunk_count: int
3138

3239

33-
# 코어서버에서 토큰 받아오고 레포 가져온다
34-
# 이후 LLM 분석하고 마크다운 저장
40+
# Core에서 사용자별 GitHub token 수령 → 레포 fetch → LLM 분석 → 마크다운 저장 → 청킹·임베딩
3541
class RepositoryAnalyzer:
3642
def __init__(
3743
self,
@@ -40,12 +46,16 @@ def __init__(
4046
core_client: CoreClient,
4147
chain: DocumentAnalyzer,
4248
storage: ObjectStorage,
49+
chunker: MarkdownChunker,
50+
embedder: EmbeddingProvider,
4351
analyzed_key_template: str,
4452
) -> None:
4553
self._extractor = extractor
4654
self._core_client = core_client
4755
self._chain = chain
4856
self._storage = storage
57+
self._chunker = chunker
58+
self._embedder = embedder
4959
self._analyzed_key_template = analyzed_key_template
5060

5161
async def analyze(
@@ -55,6 +65,7 @@ async def analyze(
5565
repo_full_name: str,
5666
default_branch: str = "main",
5767
user_id: int | None,
68+
analyzed_document_id: int,
5869
) -> RepositoryAnalysisResult:
5970
if user_id is None:
6071
raise RepositoryAnalyzeError(
@@ -72,9 +83,7 @@ async def analyze(
7283
access_token = await self._core_client.fetch_github_token(user_id)
7384
except CoreTokenError as err:
7485
raise RepositoryAnalyzeError(
75-
code=err.code,
76-
message=err.message,
77-
retriable=err.retriable,
86+
code=err.code, message=err.message, retriable=err.retriable
7887
) from err
7988

8089
log.info(
@@ -90,9 +99,7 @@ async def analyze(
9099
)
91100
except RepositoryFetchError as err:
92101
raise RepositoryAnalyzeError(
93-
code=err.code,
94-
message=err.message,
95-
retriable=err.retriable,
102+
code=err.code, message=err.message, retriable=err.retriable
96103
) from err
97104

98105
if not source.text.strip():
@@ -121,8 +128,23 @@ async def analyze(
121128
md_chars=len(analysis.markdown),
122129
)
123130

131+
try:
132+
chunk_count = await chunk_embed_and_upsert(
133+
document_id=analyzed_document_id,
134+
markdown=analysis.markdown,
135+
chunker=self._chunker,
136+
embedder=self._embedder,
137+
core_client=self._core_client,
138+
log_prefix="repository",
139+
)
140+
except EmbeddingStepError as err:
141+
raise RepositoryAnalyzeError(
142+
code=err.code, message=err.message, retriable=err.retriable
143+
) from err
144+
124145
return RepositoryAnalysisResult(
125146
summary=analysis.summary,
126147
tech_stack=list(analysis.tech_stack),
127148
document_path=out_key,
149+
embedding_chunk_count=chunk_count,
128150
)

ai/src/ai_server/analyzer/resume_analyzer.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@
44

55
import structlog
66

7+
from ai_server.analyzer._embedding_step import (
8+
EmbeddingStepError,
9+
chunk_embed_and_upsert,
10+
)
711
from ai_server.analyzer.sources.base import SourceExtractor
812
from ai_server.chain.document_analysis_chain import DocumentAnalyzer
13+
from ai_server.core.client import CoreClient
14+
from ai_server.rag.chunker import MarkdownChunker
15+
from ai_server.rag.embedder import EmbeddingProvider
916
from ai_server.storage.base import ObjectStorage
1017

1118
log = structlog.get_logger(__name__)
@@ -24,28 +31,36 @@ class ResumeAnalysisResult:
2431
summary: str
2532
tech_stack: list[str]
2633
document_path: str
34+
embedding_chunk_count: int
2735

2836

29-
# 스토리지에서 가져오고 LLM을 통해 분석함
37+
# 스토리지에서 가져오고 LLM을 통해 분석 후 청킹·임베딩까지 처리
3038
class ResumeAnalyzer:
3139
def __init__(
3240
self,
3341
*,
3442
extractor: SourceExtractor,
3543
chain: DocumentAnalyzer,
3644
storage: ObjectStorage,
45+
chunker: MarkdownChunker,
46+
embedder: EmbeddingProvider,
47+
core_client: CoreClient,
3748
analyzed_key_template: str,
3849
) -> None:
3950
self._extractor = extractor
4051
self._chain = chain
4152
self._storage = storage
53+
self._chunker = chunker
54+
self._embedder = embedder
55+
self._core_client = core_client
4256
self._analyzed_key_template = analyzed_key_template
4357

4458
async def analyze(
4559
self,
4660
*,
4761
resume_id: int,
4862
file_path: str,
63+
analyzed_document_id: int,
4964
) -> ResumeAnalysisResult:
5065
log.info(
5166
"resume.extract.start",
@@ -81,8 +96,23 @@ async def analyze(
8196
md_chars=len(analysis.markdown),
8297
)
8398

99+
try:
100+
chunk_count = await chunk_embed_and_upsert(
101+
document_id=analyzed_document_id,
102+
markdown=analysis.markdown,
103+
chunker=self._chunker,
104+
embedder=self._embedder,
105+
core_client=self._core_client,
106+
log_prefix="resume",
107+
)
108+
except EmbeddingStepError as err:
109+
raise ResumeAnalyzeError(
110+
code=err.code, message=err.message, retriable=err.retriable
111+
) from err
112+
84113
return ResumeAnalysisResult(
85114
summary=analysis.summary,
86115
tech_stack=list(analysis.tech_stack),
87116
document_path=out_key,
117+
embedding_chunk_count=chunk_count,
88118
)

ai/src/ai_server/analyzer/sources/github_repo.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
log = structlog.get_logger(__name__)
1212

1313

14-
# 프로젝트 설정 파일을 먼저 읽는다
14+
# 프로젝트 설정 파일을 먼저 읽는다
1515
_PRIORITY_FILES: tuple[str, ...] = (
1616
"package.json",
1717
"pyproject.toml",
@@ -68,7 +68,7 @@ class _RepoConfig:
6868
timeout_sec: float
6969

7070

71-
# 리드미, 주요 소스를 읽는다
71+
# 리드미, 주요 소스를 읽는다
7272
class GitHubRepoSourceExtractor(SourceExtractor):
7373
def __init__(
7474
self,
@@ -87,7 +87,7 @@ def __init__(
8787
max_file_bytes=max_file_bytes,
8888
timeout_sec=timeout_sec,
8989
)
90-
self._client = client
90+
self._client = client
9191

9292
async def extract(
9393
self,

ai/src/ai_server/analyzer/sources/web.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def __init__(self, *, code: str, message: str, retriable: bool) -> None:
1919
self.retriable = retriable
2020

2121

22-
# 라이브러리로 본문 추출
22+
# 라이브러리로 본문 추출
2323
class WebSourceExtractor(SourceExtractor):
2424
def __init__(
2525
self,

0 commit comments

Comments
 (0)