Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions ai/src/ai_server/analyzer/_embedding_step.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 공통 임베딩 모듈
# 공통 임베딩 모듈
from __future__ import annotations

import structlog
Expand All @@ -22,13 +22,35 @@ def __init__(self, *, code: str, message: str, retriable: bool) -> None:
self.retriable = retriable


# 문맥 프리픽스에 넣을 요약 최대 길이 (청크 비대화 방지).
_MAX_SUMMARY_CHARS = 200


def _contextualize(chunk_text: str, heading_path: str, summary: str) -> str:
"""Contextual Retrieval: 고립된 청크가 문서 맥락을 잃지 않도록
[문서요약 > 헤딩경로] 프리픽스를 붙인다 (Anthropic 기법의 결정적 변형 — LLM 호출 없음).
"""
parts: list[str] = []
s = " ".join((summary or "").split())
if s:
if len(s) > _MAX_SUMMARY_CHARS:
s = s[:_MAX_SUMMARY_CHARS].rstrip() + "…"
parts.append(s)
if heading_path:
parts.append(heading_path)
if not parts:
return chunk_text
return f"[{' > '.join(parts)}]\n\n{chunk_text}"


async def chunk_embed_and_upsert(
*,
document_id: int,
markdown: str,
chunker: MarkdownChunker,
embedder: EmbeddingProvider,
core_client: CoreClient,
summary: str = "",
log_prefix: str = "analyze",
) -> int:
chunks = chunker.split(markdown)
Expand All @@ -41,8 +63,11 @@ async def chunk_embed_and_upsert(
if not chunks:
return 0

# 임베딩·저장 대상은 문맥이 보강된 텍스트 (검색 정합도↑ + grounding↑).
contextualized = [_contextualize(c.text, c.heading_path, summary) for c in chunks]

try:
vectors = await embedder.embed([c.text for c in chunks])
vectors = await embedder.embed(contextualized)
except EmbeddingError as err:
raise EmbeddingStepError(
code=err.code, message=err.message, retriable=err.retriable
Expand All @@ -58,7 +83,7 @@ async def chunk_embed_and_upsert(
payloads = [
EmbeddingChunkPayload(
chunk_index=chunks[i].index,
chunk_text=chunks[i].text,
chunk_text=contextualized[i],
embedding=vectors[i],
)
for i in range(len(chunks))
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/analyzer/repository_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ async def emit(phase: str, message: str) -> None:
chunker=self._chunker,
embedder=self._embedder,
core_client=self._core_client,
summary=analysis.summary,
log_prefix="repository",
)
except EmbeddingStepError as err:
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/analyzer/resume_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ async def emit(phase: str, message: str) -> None:
chunker=self._chunker,
embedder=self._embedder,
core_client=self._core_client,
summary=analysis.summary,
log_prefix="resume",
)
except EmbeddingStepError as err:
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/analyzer/web_resume_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ async def analyze(
chunker=self._chunker,
embedder=self._embedder,
core_client=self._core_client,
summary=analysis.summary,
log_prefix="web_resume",
)
except EmbeddingStepError as err:
Expand Down
22 changes: 22 additions & 0 deletions ai/src/ai_server/chain/prompts/rerank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 검색 후보 리랭킹 프롬프트.
# 하이브리드 검색이 가져온 후보 청크들을 쿼리와 함께 한 번에 모델에 넣어,
# 관련도 높은 순으로 인덱스를 재정렬한다 (cross-encoder 대용, 호출 1회).

SYSTEM_PROMPT = (
"당신은 검색 결과 재정렬기(reranker)입니다. "
"주어진 쿼리에 대해 후보 청크들의 관련도를 평가하고, "
"가장 관련 높은 순서로 인덱스를 정렬하세요.\n"
"- 관련도는 쿼리의 의도에 대한 답이 청크에 실제로 담겨 있는 정도로 판단합니다.\n"
"- 관련 없는 청크는 결과에서 제외해도 됩니다.\n"
"- 응답은 반드시 지정된 JSON 스키마(ranked_indices)를 따릅니다."
)

HUMAN_PROMPT = (
"쿼리:\n{query}\n\n"
"후보 청크 (각 [i] 인덱스):\n"
"---\n"
"{candidates}\n"
"---\n\n"
"관련도가 높은 순으로 최대 {top_k}개의 인덱스를 ranked_indices 에 담아 반환하세요.\n"
"{format_instructions}"
)
11 changes: 7 additions & 4 deletions ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class Settings(BaseSettings):
ai_realtime_exchange: str = "stackup.realtime"
ai_realtime_routing_user: str = "realtime.user.notify"
feedback_rag_top_k: int = 5
# 리랭킹: 하이브리드 검색으로 후보 N개(candidate_k)를 가져와 LLM 으로 재정렬 후 top_k 주입.
rerank_enabled: bool = True
rerank_candidate_k: int = 20
# 질문 풀 초기 크기. Core 의 applyPool 이 questions[0] 만 INSERT 하므로 1 로 고정해 토큰 낭비 차단.
# 후속 작업에서 풀 저장 도입 시 늘리기 (예: 5).
questions_initial_pool_size: int = 1
Expand All @@ -51,16 +54,16 @@ class Settings(BaseSettings):
whisper_timeout_sec: float = 60.0
deepgram_api_key: str = ""
deepgram_base_url: str = "https://api.deepgram.com/v1"
deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2.
deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2.
deepgram_language: str = "ko"
deepgram_timeout_sec: float = 60.0

# 스트리밍 STT (실시간 음성 답변). "auto" 면 DEEPGRAM_API_KEY 보유 시 deepgram_live, 없으면 mock.
live_stt_provider: Literal["auto", "mock", "deepgram_live"] = "auto"
deepgram_live_url: str = "wss://api.deepgram.com/v1/listen"
deepgram_live_model: str = "nova-2" # 스트리밍은 nova-2(저지연). 한국어 지원.
deepgram_live_model: str = "nova-2" # 스트리밍은 nova-2(저지연). 한국어 지원.
deepgram_live_language: str = "ko"
deepgram_live_endpointing_ms: int = 800 # 무음 800ms → utterance end
deepgram_live_endpointing_ms: int = 800 # 무음 800ms → utterance end
voice_stream_internal_path: str = "/internal/voice/stream"

# TTS (질문 음성화). "auto" 면 openai 키 보유 시 openai, 없으면 mock.
Expand Down Expand Up @@ -125,7 +128,7 @@ class Settings(BaseSettings):
embedding_dim: int = 1536
embedding_chunk_size: int = 1000
embedding_chunk_overlap: int = 200
embedding_batch_size: int = 32
embedding_batch_size: int = 32

gemini_api_key: str = ""

Expand Down
9 changes: 7 additions & 2 deletions ai/src/ai_server/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ async def search_embeddings(
self,
*,
query_embedding: list[float],
query_text: str | None = None,
document_ids: list[int] | None = None,
top_k: int = 5,
) -> list[EmbeddingSearchHit]: ...
Expand Down Expand Up @@ -254,15 +255,19 @@ async def search_embeddings(
self,
*,
query_embedding: list[float],
query_text: str | None = None,
document_ids: list[int] | None = None,
top_k: int = 5,
) -> list[EmbeddingSearchHit]:
"""pgvector cosine topK 검색. 실패 시 빈 리스트 반환 (RAG 보강용이므로 fatal 아님)."""
body = {
"""임베딩 검색. query_text 가 주어지면 Core 가 벡터+BM25 RRF 하이브리드로,
없으면 pgvector cosine 단독으로 topK 반환. 실패 시 빈 리스트 (RAG 보강용이므로 fatal 아님)."""
body: dict = {
"queryEmbedding": query_embedding,
"documentIds": list(document_ids or []),
"topK": top_k,
}
if query_text:
body["queryText"] = query_text
path = "/api/internal/embeddings/search"
try:
if self._client is not None:
Expand Down
17 changes: 13 additions & 4 deletions ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from __future__ import annotations

from typing import Protocol

import structlog
from aio_pika.abc import AbstractIncomingMessage

Expand All @@ -17,6 +15,7 @@
VoiceAnalysisSummary,
)
from ai_server.rag.embedder import EmbeddingProvider
from ai_server.rag.reranker import NoopReranker, Reranker, rerank_hits

log = structlog.get_logger(__name__)

Expand All @@ -42,6 +41,8 @@ def __init__(
core_client: CoreClient,
embedder: EmbeddingProvider | None = None,
rag_top_k: int = 5,
reranker: Reranker | None = None,
candidate_k: int = 20,
) -> None:
self._generator = generator
self._publisher = publisher
Expand All @@ -50,6 +51,8 @@ def __init__(
self._core = core_client
self._embedder = embedder
self._rag_top_k = rag_top_k
self._reranker = reranker or NoopReranker()
self._candidate_k = max(candidate_k, rag_top_k)

async def handle(self, message: AbstractIncomingMessage) -> None:
async with message.process(requeue=False):
Expand Down Expand Up @@ -136,17 +139,23 @@ async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str:
if not last_answer:
return "(none)"
try:
query_vec = (await self._embedder.embed([last_answer]))[0]
query_vec = (
await self._embedder.embed([last_answer], task_type="RETRIEVAL_QUERY")
)[0]
hits = await self._core.search_embeddings(
query_embedding=query_vec,
query_text=last_answer,
document_ids=req.context_document_ids,
top_k=self._rag_top_k,
top_k=self._candidate_k,
)
except Exception as exc:
log.warn("feedback.rag.failed", error=str(exc), session_id=req.session_id)
return "(none)"
if not hits:
return "(none)"
hits = await rerank_hits(
self._reranker, query=last_answer, hits=hits, top_k=self._rag_top_k
)
return "\n---\n".join(
f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits
)
Expand Down
15 changes: 13 additions & 2 deletions ai/src/ai_server/messaging/consumers/followup_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
GenerateFollowupRequest,
)
from ai_server.rag.embedder import EmbeddingProvider
from ai_server.rag.reranker import NoopReranker, Reranker, rerank_hits

log = structlog.get_logger(__name__)

Expand All @@ -28,6 +29,8 @@ def __init__(
core_client: CoreClient | None = None,
embedder: EmbeddingProvider | None = None,
rag_top_k: int = 5,
reranker: Reranker | None = None,
candidate_k: int = 20,
) -> None:
self._generator = generator
self._publisher = publisher
Expand All @@ -36,6 +39,8 @@ def __init__(
self._core = core_client
self._embedder = embedder
self._rag_top_k = rag_top_k
self._reranker = reranker or NoopReranker()
self._candidate_k = max(candidate_k, rag_top_k)

async def handle(self, message: AbstractIncomingMessage) -> None:
async with message.process(requeue=False):
Expand Down Expand Up @@ -105,18 +110,24 @@ async def _build_rag_context(self, req: GenerateFollowupRequest) -> str:

query = f"{req.previous_question}\n\n{req.answer_text}"
try:
query_vec = (await self._embedder.embed([query]))[0]
query_vec = (
await self._embedder.embed([query], task_type="RETRIEVAL_QUERY")
)[0]
hits = await self._core.search_embeddings(
query_embedding=query_vec,
query_text=query,
document_ids=req.context_document_ids,
top_k=self._rag_top_k,
top_k=self._candidate_k,
)
except Exception as exc:
log.warn("followup.rag.failed", error=str(exc), session_id=req.session_id)
return "(none)"

if not hits:
return "(none)"
hits = await rerank_hits(
self._reranker, query=query, hits=hits, top_k=self._rag_top_k
)
return "\n---\n".join(
f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits
)
15 changes: 13 additions & 2 deletions ai/src/ai_server/messaging/consumers/questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
QuestionPoolCallbackPayload,
)
from ai_server.rag.embedder import EmbeddingProvider
from ai_server.rag.reranker import NoopReranker, Reranker, rerank_hits

log = structlog.get_logger(__name__)

Expand All @@ -30,6 +31,8 @@ def __init__(
core_client: CoreClient | None = None,
embedder: EmbeddingProvider | None = None,
rag_top_k: int = 5,
reranker: Reranker | None = None,
candidate_k: int = 20,
) -> None:
self._generator = generator
self._publisher = publisher
Expand All @@ -41,6 +44,8 @@ def __init__(
self._core = core_client
self._embedder = embedder
self._rag_top_k = rag_top_k
self._reranker = reranker or NoopReranker()
self._candidate_k = max(candidate_k, rag_top_k)

async def handle(self, message: AbstractIncomingMessage) -> None:
async with message.process(requeue=False):
Expand Down Expand Up @@ -120,18 +125,24 @@ async def _build_context(self, req: GenerateQuestionsRequest) -> str:

query = _build_initial_rag_query(req)
try:
query_vec = (await self._embedder.embed([query]))[0]
query_vec = (
await self._embedder.embed([query], task_type="RETRIEVAL_QUERY")
)[0]
hits = await self._core.search_embeddings(
query_embedding=query_vec,
query_text=query,
document_ids=document_ids,
top_k=self._rag_top_k,
top_k=self._candidate_k,
)
except Exception as exc:
log.warn("questions.rag.failed", error=str(exc), session_id=req.session_id)
return base_context

if not hits:
return base_context
hits = await rerank_hits(
self._reranker, query=query, hits=hits, top_k=self._rag_top_k
)
rag_context = "\n---\n".join(
f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits
)
Expand Down
8 changes: 8 additions & 0 deletions ai/src/ai_server/messaging/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from ai_server.messaging.connection import RabbitConnection
from ai_server.rag.chunker import MarkdownChunker
from ai_server.rag.embedder import build_embedding_provider
from ai_server.rag.reranker import build_reranker
from ai_server.messaging.consumers.feedback_consumer import FeedbackConsumer
from ai_server.messaging.consumers.followup_consumer import FollowupConsumer
from ai_server.messaging.consumers.questions_consumer import QuestionsConsumer
Expand Down Expand Up @@ -95,6 +96,7 @@ def __init__(self, settings: Settings) -> None:
model=settings.embedding_model,
gemini_api_key=settings.gemini_api_key,
)
reranker = build_reranker(settings, core_client=core_client)

# 이력서 PDF
resume_analyzer = ResumeAnalyzer(
Expand Down Expand Up @@ -171,6 +173,8 @@ def __init__(self, settings: Settings) -> None:
initial_pool_size=settings.questions_initial_pool_size,
core_client=core_client,
embedder=embedder,
reranker=reranker,
candidate_k=settings.rerank_candidate_k,
)

# 꼬리질문 생성 (US-19)
Expand All @@ -184,6 +188,8 @@ def __init__(self, settings: Settings) -> None:
callback_routing_key=settings.ai_callback_routing_questions,
core_client=core_client,
embedder=embedder,
reranker=reranker,
candidate_k=settings.rerank_candidate_k,
)

# 종합 피드백 생성 (US-24)
Expand All @@ -197,6 +203,8 @@ def __init__(self, settings: Settings) -> None:
callback_routing_key=settings.ai_callback_routing_feedback,
core_client=core_client,
embedder=embedder,
reranker=reranker,
candidate_k=settings.rerank_candidate_k,
rag_top_k=settings.feedback_rag_top_k,
)

Expand Down
Loading