Skip to content

Commit 669fd11

Browse files
committed
perf(interview): 꼬리질문 첫 토큰 지연 단축 + 타자기 연출
P1(AI): followup RAG에서 실패·저속한 LLM 리랭커를 기본 비활성화(followup_rerank_enabled=false)하고 candidate 확장 없이 top_k 직접 검색. _build_rag_context 전체에 타임아웃(followup_rag_timeout_sec=1.5s) 폴백 추가 → 첫 토큰까지 ~3s에서 ~1.5s 목표. P2(FE): SESSION_MESSAGE_DELTA가 짧은 질문에서 0.3s에 몰려 와도 보이도록 useTypewriter로 누적 텍스트를 점진 표시(StageQuestion/QuestionBubble). 긴 스트림은 남은 양 비례 step으로 따라잡아 지연 없음.
1 parent c4440af commit 669fd11

11 files changed

Lines changed: 255 additions & 12 deletions

File tree

ai/src/ai_server/config/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class Settings(BaseSettings):
4141
# 리랭킹: 하이브리드 검색으로 후보 N개(candidate_k)를 가져와 LLM 으로 재정렬 후 top_k 주입.
4242
rerank_enabled: bool = True
4343
rerank_candidate_k: int = 20
44+
# 꼬리질문 RAG 저지연: 리랭커(LLM 호출, 종종 실패)를 건너뛰고 검색 순서를 그대로 쓴다.
45+
followup_rerank_enabled: bool = False
46+
# RAG 컨텍스트 구성(임베딩+검색+리랭크) 전체 상한. 초과 시 (none) 으로 폴백해 첫 토큰을 막지 않는다.
47+
followup_rag_timeout_sec: float = 1.5
4448
# 질문 풀 초기 크기. Core 의 applyPool 이 questions[0] 만 INSERT 하므로 1 로 고정해 토큰 낭비 차단.
4549
# 후속 작업에서 풀 저장 도입 시 늘리기 (예: 5).
4650
questions_initial_pool_size: int = 1

ai/src/ai_server/messaging/consumers/followup_consumer.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def __init__(
5050
tts=None,
5151
storage=None,
5252
tts_voice: str = "",
53+
rerank_enabled: bool = True,
54+
rag_timeout_sec: float = 1.5,
5355
) -> None:
5456
self._generator = generator
5557
self._publisher = publisher
@@ -65,6 +67,8 @@ def __init__(
6567
self._tts = tts
6668
self._storage = storage
6769
self._tts_voice = tts_voice
70+
self._rerank_enabled = rerank_enabled
71+
self._rag_timeout_sec = rag_timeout_sec
6872

6973
async def handle(self, message: AbstractIncomingMessage) -> None:
7074
async with message.process(requeue=False):
@@ -233,8 +237,22 @@ async def _synth_segment(
233237
async def _build_rag_context(self, req: GenerateFollowupRequest) -> str:
234238
if not self._core or not self._embedder or not req.context_document_ids:
235239
return "(none)"
240+
try:
241+
return await asyncio.wait_for(
242+
self._do_build_rag_context(req), timeout=self._rag_timeout_sec
243+
)
244+
except asyncio.TimeoutError:
245+
log.warning(
246+
"followup.rag.timeout",
247+
session_id=req.session_id,
248+
timeout_sec=self._rag_timeout_sec,
249+
)
250+
return "(none)"
236251

252+
async def _do_build_rag_context(self, req: GenerateFollowupRequest) -> str:
237253
query = f"{req.previous_question}\n\n{req.answer_text}"
254+
# 리랭크 비활성 시 candidate 확장 없이 top_k 만 검색(LLM 리랭커 호출 회피).
255+
search_k = self._candidate_k if self._rerank_enabled else self._rag_top_k
238256
try:
239257
query_vec = (
240258
await self._embedder.embed([query], task_type="RETRIEVAL_QUERY")
@@ -243,17 +261,18 @@ async def _build_rag_context(self, req: GenerateFollowupRequest) -> str:
243261
query_embedding=query_vec,
244262
query_text=query,
245263
document_ids=req.context_document_ids,
246-
top_k=self._candidate_k,
264+
top_k=search_k,
247265
)
248266
except Exception as exc:
249267
log.warn("followup.rag.failed", error=str(exc), session_id=req.session_id)
250268
return "(none)"
251269

252270
if not hits:
253271
return "(none)"
254-
hits = await rerank_hits(
255-
self._reranker, query=query, hits=hits, top_k=self._rag_top_k
256-
)
272+
if self._rerank_enabled:
273+
hits = await rerank_hits(
274+
self._reranker, query=query, hits=hits, top_k=self._rag_top_k
275+
)
257276
return "\n---\n".join(
258277
f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits
259278
)

ai/src/ai_server/messaging/runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ def __init__(self, settings: Settings) -> None:
213213
tts=tts,
214214
storage=storage,
215215
tts_voice=settings.openai_tts_voice,
216+
rerank_enabled=settings.followup_rerank_enabled,
217+
rag_timeout_sec=settings.followup_rag_timeout_sec,
216218
)
217219

218220
# 종합 피드백 생성 (US-24)

ai/tests/test_followup_consumer.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,163 @@ async def test_callback_includes_answer_message_id():
480480
payload = publisher.publish.await_args.kwargs["payload"]
481481
assert payload.answer_message_id == 502 # _envelope 의 answerMessageId
482482
assert payload.followup_message_id == 503 # _envelope 의 followupMessageId
483+
484+
485+
# ---------------------------------------------------------------------------
486+
# 꼬리질문 RAG 저지연 픽스: rerank_enabled=False, timeout 폴백
487+
# ---------------------------------------------------------------------------
488+
489+
490+
def _make_req() -> "GenerateFollowupRequest":
491+
from ai_server.model.messages.followup import GenerateFollowupRequest
492+
493+
return GenerateFollowupRequest(
494+
session_id=99,
495+
parent_message_id=501,
496+
answer_message_id=502,
497+
followup_message_id=503,
498+
previous_question="결제 outbox 어떻게 구현?",
499+
answer_text="RabbitMQ로 보냈습니다.",
500+
mode="TECHNICAL",
501+
job_category="BACKEND",
502+
context_document_ids=[1],
503+
)
504+
505+
506+
def _make_hit(document_id: int = 1, chunk_index: int = 0, chunk_text: str = "x"):
507+
from types import SimpleNamespace
508+
509+
return SimpleNamespace(
510+
document_id=document_id, chunk_index=chunk_index, chunk_text=chunk_text
511+
)
512+
513+
514+
@pytest.mark.asyncio
515+
async def test_rag_rerank_skipped_when_disabled():
516+
"""rerank_enabled=False: search_embeddings 는 top_k 만 요청, reranker.rerank 는 호출 안 됨."""
517+
import asyncio
518+
519+
from ai_server.messaging.consumers.followup_consumer import FollowupConsumer
520+
from ai_server.messaging.idempotency import LruIdempotencyStore
521+
522+
hit = _make_hit(chunk_text="이 청크가 반환돼야 한다")
523+
524+
embedder = MagicMock()
525+
embedder.embed = AsyncMock(return_value=[[0.0]])
526+
527+
core = MagicMock()
528+
core.search_embeddings = AsyncMock(return_value=[hit])
529+
530+
fake_reranker = MagicMock()
531+
fake_reranker.rerank = AsyncMock(return_value=[0])
532+
533+
generator = MagicMock()
534+
generator.generate = AsyncMock()
535+
publisher = MagicMock()
536+
publisher.publish = AsyncMock()
537+
538+
consumer = FollowupConsumer(
539+
generator=generator,
540+
publisher=publisher,
541+
idempotency=LruIdempotencyStore(max_size=10),
542+
callback_routing_key="callback.questions",
543+
core_client=core,
544+
embedder=embedder,
545+
rag_top_k=5,
546+
reranker=fake_reranker,
547+
candidate_k=20,
548+
rerank_enabled=False,
549+
)
550+
551+
req = _make_req()
552+
result = await consumer._build_rag_context(req)
553+
554+
# 청크 텍스트가 결과에 포함돼야 한다
555+
assert "이 청크가 반환돼야 한다" in result
556+
# rerank 비활성 시 candidate 확장 없이 rag_top_k 만 검색
557+
call_kwargs = core.search_embeddings.await_args.kwargs
558+
assert call_kwargs["top_k"] == 5 # rag_top_k, NOT candidate_k(20)
559+
# 리랭커 rerank 는 절대 호출되지 않음
560+
fake_reranker.rerank.assert_not_awaited()
561+
562+
563+
@pytest.mark.asyncio
564+
async def test_rag_rerank_used_when_enabled():
565+
"""rerank_enabled=True: search_embeddings 는 candidate_k, reranker.rerank 는 호출됨."""
566+
hit = _make_hit(chunk_text="후보 청크")
567+
568+
embedder = MagicMock()
569+
embedder.embed = AsyncMock(return_value=[[0.0]])
570+
571+
core = MagicMock()
572+
core.search_embeddings = AsyncMock(return_value=[hit])
573+
574+
fake_reranker = MagicMock()
575+
fake_reranker.rerank = AsyncMock(return_value=[0])
576+
577+
generator = MagicMock()
578+
generator.generate = AsyncMock()
579+
publisher = MagicMock()
580+
publisher.publish = AsyncMock()
581+
582+
consumer = FollowupConsumer(
583+
generator=generator,
584+
publisher=publisher,
585+
idempotency=LruIdempotencyStore(max_size=10),
586+
callback_routing_key="callback.questions",
587+
core_client=core,
588+
embedder=embedder,
589+
rag_top_k=5,
590+
reranker=fake_reranker,
591+
candidate_k=20,
592+
rerank_enabled=True,
593+
)
594+
595+
req = _make_req()
596+
result = await consumer._build_rag_context(req)
597+
598+
assert "후보 청크" in result
599+
call_kwargs = core.search_embeddings.await_args.kwargs
600+
assert call_kwargs["top_k"] == 20 # candidate_k
601+
# 리랭커 rerank 는 호출돼야 한다
602+
fake_reranker.rerank.assert_awaited_once()
603+
604+
605+
@pytest.mark.asyncio
606+
async def test_rag_timeout_returns_none():
607+
"""rag_timeout_sec 초과 시 _build_rag_context 가 '(none)' 을 반환한다."""
608+
import asyncio
609+
610+
from ai_server.messaging.consumers.followup_consumer import FollowupConsumer
611+
from ai_server.messaging.idempotency import LruIdempotencyStore
612+
613+
async def _slow_embed(texts, *, task_type=""):
614+
await asyncio.sleep(0.1) # 타임아웃(0.01s) 보다 훨씬 길다
615+
return [[0.0]]
616+
617+
embedder = MagicMock()
618+
embedder.embed = _slow_embed
619+
620+
core = MagicMock()
621+
core.search_embeddings = AsyncMock(return_value=[])
622+
623+
generator = MagicMock()
624+
generator.generate = AsyncMock()
625+
publisher = MagicMock()
626+
publisher.publish = AsyncMock()
627+
628+
consumer = FollowupConsumer(
629+
generator=generator,
630+
publisher=publisher,
631+
idempotency=LruIdempotencyStore(max_size=10),
632+
callback_routing_key="callback.questions",
633+
core_client=core,
634+
embedder=embedder,
635+
rag_top_k=5,
636+
rag_timeout_sec=0.01,
637+
)
638+
639+
req = _make_req()
640+
result = await consumer._build_rag_context(req)
641+
642+
assert result == "(none)"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import { useTypewriter } from './useTypewriter'
4+
5+
describe('useTypewriter', () => {
6+
beforeEach(() => vi.useFakeTimers())
7+
afterEach(() => vi.useRealTimers())
8+
9+
it('enabled=false 면 전체 텍스트 즉시', () => {
10+
const { result } = renderHook(() => useTypewriter('안녕하세요', false))
11+
expect(result.current).toBe('안녕하세요')
12+
})
13+
14+
it('enabled 면 점진적으로 드러나 결국 전체에 도달', () => {
15+
const { result } = renderHook(() => useTypewriter('가나다라마바사아자차', true))
16+
// 처음엔 일부만(또는 비어있음)
17+
const initial = result.current.length
18+
act(() => { vi.advanceTimersByTime(35 * 12) })
19+
expect(result.current).toBe('가나다라마바사아자차')
20+
expect(initial).toBeLessThan('가나다라마바사아자차'.length)
21+
})
22+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
3+
// 스트리밍 텍스트를 일정 속도로 점진 표시(타자기). enabled=false면 전체 즉시 반환.
4+
// fullText 가 늘어나면 따라가고, 남은 양에 비례해 step 을 키워 길고 빠른 스트림은 지연 없이 따라잡는다.
5+
export function useTypewriter(fullText: string, enabled: boolean): string {
6+
const [count, setCount] = useState(enabled ? 0 : fullText.length)
7+
const targetRef = useRef(fullText)
8+
targetRef.current = fullText
9+
10+
useEffect(() => {
11+
if (!enabled) {
12+
setCount(targetRef.current.length)
13+
return
14+
}
15+
const id = setInterval(() => {
16+
setCount((c) => {
17+
const target = targetRef.current.length
18+
if (c >= target) return c
19+
const remaining = target - c
20+
const step = Math.max(2, Math.ceil(remaining / 6))
21+
return Math.min(target, c + step)
22+
})
23+
}, 35)
24+
return () => clearInterval(id)
25+
}, [enabled])
26+
27+
const shown = enabled ? Math.min(count, fullText.length) : fullText.length
28+
return fullText.slice(0, shown)
29+
}

frontend/src/features/interview/model/useLiveInterview.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { OptimisticAnswer } from './optimistic'
1515
import { applyDelta, isStreamingMessage, FOLLOWUP_GENERATING_TEXT } from './streamingBuffer'
1616
import type { DeltaPayload } from './streamingBuffer'
1717

18-
export type ThreadItem = Message & { key: string }
18+
export type ThreadItem = Message & { key: string; streaming?: boolean }
1919
export type ConnectionStatus = 'connecting' | 'open' | 'closed'
2020

2121
export function useLiveInterview(sessionId: number) {
@@ -58,7 +58,7 @@ export function useLiveInterview(sessionId: number) {
5858
const mergedMessages = serverMessages.map((m) => {
5959
const buffered = deltaBuffer[m.id ?? -1]
6060
if (buffered !== undefined && isStreamingMessage(m, buffered)) {
61-
return { ...m, content: buffered }
61+
return { ...m, content: buffered, streaming: true as const }
6262
}
6363
return m
6464
})

frontend/src/features/interview/ui/live/ConversationThread.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function ConversationThread({
2727
<div ref={containerRef} className="flex h-full flex-col gap-3 overflow-y-auto px-4 py-6">
2828
{items.map((item) =>
2929
isQuestion(item) ? (
30-
<QuestionBubble key={item.key} message={item} autoPlay={item.key === lastQuestionKey} />
30+
<QuestionBubble key={item.key} message={item} autoPlay={item.key === lastQuestionKey} streaming={item.streaming} />
3131
) : (
3232
<AnswerBubble key={item.key} message={item} />
3333
),

frontend/src/features/interview/ui/live/InterviewStage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export function InterviewStage({
119119
{awaitingQuestion || !currentQuestion ? (
120120
<ThinkingState transcribing={transcribing} />
121121
) : (
122-
<StageQuestion question={currentQuestion} segmented={wasSegmented(currentQuestion.id ?? -1)} />
122+
<StageQuestion question={currentQuestion} segmented={wasSegmented(currentQuestion.id ?? -1)} streaming={currentQuestion?.streaming ?? false} />
123123
)}
124124
</div>
125125

frontend/src/features/interview/ui/live/QuestionBubble.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { StatusBadge } from '@/shared/ui/StatusBadge'
33
import { categoryLabel } from '../../lib/categoryLabel'
44
import { useTtsPlayback } from '../../lib/media/useTtsPlayback'
55
import { FOLLOWUP_GENERATING_TEXT } from '../../model/streamingBuffer'
6+
import { useTypewriter } from '../../lib/useTypewriter'
67

78
function PlayIcon({ playing }: { playing: boolean }) {
89
return (
@@ -15,13 +16,17 @@ function PlayIcon({ playing }: { playing: boolean }) {
1516
export function QuestionBubble({
1617
message,
1718
autoPlay = false,
19+
streaming = false,
1820
}: {
1921
message: Message
2022
autoPlay?: boolean
23+
streaming?: boolean
2124
}) {
2225
const label = categoryLabel(message.category)
2326
const hasMeta = Boolean(label || message.targetEvidence)
2427
const ttsReady = message.ttsStatus === 'SUCCEEDED'
28+
const isSentinel = message.content === FOLLOWUP_GENERATING_TEXT
29+
const shownText = useTypewriter(message.content ?? '', !!streaming && !isSentinel)
2530

2631
const { playing, toggle, audioNode } = useTtsPlayback({
2732
sessionId: message.sessionId,
@@ -42,14 +47,14 @@ export function QuestionBubble({
4247
</div>
4348
)}
4449
<div className="rounded-lg rounded-tl-sm bg-surface-raised px-4 py-3 text-body text-fg shadow-sm">
45-
{message.content === FOLLOWUP_GENERATING_TEXT ? (
50+
{isSentinel ? (
4651
<span className="inline-flex gap-1 text-fg-muted" aria-label="질문 생성 중">
4752
<span className="animate-pulse"></span>
4853
<span className="animate-pulse [animation-delay:150ms]"></span>
4954
<span className="animate-pulse [animation-delay:300ms]"></span>
5055
</span>
5156
) : (
52-
<p className="whitespace-pre-wrap">{message.content}</p>
57+
<p className="whitespace-pre-wrap">{shownText}</p>
5358
)}
5459
{ttsReady && (
5560
<div className="mt-2 flex items-center gap-2 border-t border-border pt-2">

0 commit comments

Comments
 (0)