멀티스텝 AI 파이프라인 구현 검토 - 100-hours-a-week/KTB4-13th-wiki GitHub Wiki
flowchart TB
EMB["② /embeddings — bge-m3-2026q3 (dim 1024)<br/>모든 벡터의 단일 좌표계"]
V[("커머스 복제 테이블 · AI Postgres 안<br/>v_books · v_user_purchases · v_user_library<br/>v_user_reviews · v_book_popularity<br/>(원본은 BE MySQL · 단방향 복제 · 역방향 없음)")]
subgraph UP["상류 — 취향 형성 (파이프라인 D)"]
P5["⑤ /preferences/extractions (V2)<br/>야간배치: 대화→취향사실 LLM추출→임베딩"]
P6["⑥ /preferences/profile (V1)<br/>온보딩·기억변경 트리거<br/>벡터 가중평균 집계 (LLM·임베딩 재호출 없음)"]
P5 -.->|memories with vector| P6
P6 --> PROF[("user_profiles<br/>centroid + tag_weights + computed_at<br/>개인화 단일 소스")]
end
subgraph LLMFREE["LLM 비의존 (LLM 장애에도 동작)"]
P1["① /search (V1)<br/>BM25/pg_trgm + 벡터 + RRF + 규칙<br/>0건 → fallback 배너"]
P4["④ /recommendations/feed (V1)<br/>규칙신호 + centroid 유사도 가중합<br/>목록 비저장, 커서만"]
end
subgraph LLMDEP["LLM 의존"]
P3["③ /recommendations/chat<br/>V1 텍스트 / V2 이미지<br/>LLM = spec 델타 + 10 후보 중 3권 선택 + 이유 2종(short+long)<br/>후보 풀·점수는 결정론 / 최종 3권·순위는 LLM"]
P7["⑦ /agent/act (V2)<br/>지시어해소 → 의도분류 → tool루프(≤3) → grounding"]
end
EMB -->|"쿼리·문서·취향사실 임베딩"| P1
EMB --> P3
EMB --> P5
V -.->|인기| P1
V -.->|이력·인기| P3
V -.->|이력·인기| P4
V -.->|이력| P6
PROF -.->|centroid| P3
PROF -.->|centroid| P4
P6 -.->|"computed_at 이후 이력은 채점 시 복제 테이블에서 읽어 가산"| P3
P6 -.->|"computed_at 이후 이력은 채점 시 복제 테이블에서 읽어 가산"| P4
P1 -.->|"0건 배너 → 사용자 클릭"| P3
P3 -->|"cards + reason_long"| DET["도서 상세 페이지<br/>BE가 보관한 긴 이유 그대로 표시<br/>(AI 재호출 없음)"]
P3 -.->|context_cards| P7
P7 -->|"cart/order/inventory tool"| BE["BE 커머스<br/>(V1엔 없는 유일한 역방향 호출.<br/>DB 복제는 BE→AI 단방향뿐)"]
flowchart LR
A[질의 + 화면 필터·정렬] --> B[BM25/pg_trgm 키워드 검색]
A --> C["② /embeddings 호출<br/>bge-m3-2026q3, purpose:query"]
C --> D[pgvector ANN 검색]
B --> E[RRF 결합]
D --> E
E --> F["규칙 스코어링<br/>sort=popular면 v_book_popularity 반영"]
F --> G{0건?}
G -- Yes --> H["results: [], fallback 배너 신호<br/>'AI 추천에게 물어볼까요?'"]
G -- No --> I[Top-K + next_cursor]
- LLM을 쓰지 않는다.
- 개인화하지 않는다.
- 0건은 에러가 아니라 200 + 배너 신호다.
-
강등
- 벡터 검색 불가(임베딩 업스트림 장애·벡터 인덱스 이상·초기 적재 중) 시
X-Degraded: keyword-only로 키워드 결과만 200. - 키워드·벡터가 둘 다 안 되면(AI Postgres 장애 등) 500. BE MySQL 장애는 500이 아니다 — 낡은 복제본으로 계속 응답.
- 벡터 검색 불가(임베딩 업스트림 장애·벡터 인덱스 이상·초기 적재 중) 시
-
v_book_popularity에 행이 없으면 인기 항을 0으로 두고 200.- 복제 지연·행 부재는
X-Degraded를 붙이지 않는다. - AI Postgres 자체가 응답 불능이면 전면 500.
- 복제 지연·행 부재는
flowchart TD
A[발화 + 이전 spec + exclude_book_ids] --> S["요청 spec 6키 스키마 검증 (LLM 호출 전)<br/>위반 시 422 spec_schema_violation<br/>→ 호출자가 초기 spec으로 1회 재시도"]
S --> B["LLM 호출 1: spec 델타 추출<br/>6키 고정 스키마"]
B --> C[서버가 기존 spec에 델타 병합]
C --> D["하이브리드 후보 10권<br/>(①과 같은 BM25+벡터+RRF 재사용,<br/>spec.semantic/filters 적용)"]
D --> E["결합 스코어링<br/>질의 유사도 + 취향 유사도<br/>(centroid + computed_at 이후 이력 가산)"]
E --> F["후보에서 제외:<br/>구매한 책 · 별점 1~2 책 · exclude"]
F --> G{후보 있음?}
G -- Yes --> H["LLM 호출 2 (1회):<br/>10 후보 중 3권 선택 + 그 3권에<br/>reason_short + reason_long + match_basis"]
H --> I["고른 순서 = rank · match_score는 후보 점수 그대로(rank 순 ≠ 내림차순)<br/>reason_short 못 만든 카드는 뺌 (3장 미만 가능)<br/>reason_long만 비면 카드 남기고 reason_long:null"]
I --> J["응답: cards + reply + followup:null<br/>+ buttons:[] + degraded:false"]
G -- No/여전히 모호 --> K["cards:[], followup에 되묻는 문장"]
flowchart LR
A2["(V2) image_ref 도착<br/>BE presigned URL"] --> B2[표지 인식]
B2 --> C2{단일 확실 매칭?}
C2 -- Yes --> D2["recognition.recognized:true + book_id<br/>spec.anchor_book을 그 book_id로 갱신<br/>유사 도서 카드 + library_add 버튼"]
C2 -- 여럿 --> E2["recognition.candidates[] 최대3 (confidence 내림차순)<br/>spec.anchor_book은 아직 null 유지<br/>confirm_book/retake 버튼"]
C2 -- 실패 --> F2["candidates:[] + 안내 reply (200)"]
D2 --> G2["cards는 recognized:true일 때만 채움"]
E2 --> G2
핵심 설계 포인트:
-
LLM이 두 번, 목적이 다르게 들어간다.
- (1) 발화 해석(spec 델타 추출), (2) 결정론적 스코어링이 만든 후보 10권 중 3권을 고르고 그 3권에 이유 2종을 한 호출로 생성(
reason_short+reason_long+match_basis). - 후보 풀(10권)과 각 후보의
match_score, 제외 규칙은 하이브리드 검색 + 유사도 결합 스코어링이 결정론적으로 만든다 - LLM은 그 위에서 최종 3권과 순위만 정하고(고른 순서 =
rank, 그래서match_score는 rank 순으로 내림차순이 아닐 수 있다), 그 3권에 자연어 설명을 붙인다. - LLM이 후보를 새로 검색하거나 점수를 다시 매기지 않으므로 근거 없는 추천을 지어낼 여지를 좁힌다.
- (1) 발화 해석(spec 델타 추출), (2) 결정론적 스코어링이 만든 후보 10권 중 3권을 고르고 그 3권에 이유 2종을 한 호출로 생성(
-
reason_long이 여기서 마지막으로 생성된다.-
reason_short와 같은 호출·같은match_basis에서 나오므로 카드와 상세가 어긋날 수 없고 상세 진입에 LLM 지연이 없으며, 취향 기억을 지웠을 때 옛 취향이 담긴 이유 문장이 AI 서버에 남는 문제가 사라진다.
-
-
긴 이유의 보관은 BE 몫이다.
- BE가 카드와 함께
reason_long을 받아 두었다가 상세 페이지에서 그대로 쓴다. 같은 책을 다시 열어도 AI를 부르지 않는다.reason_long이null이면 BE는 상세의 이유 영역을 숨긴다.
- BE가 카드와 함께
-
되묻기는 별도 분기가 아니라 이 파이프라인의 기본 출력 형태다.
- 카드를 못 만들면
cards:[]+followup에 되묻는 문장. 새 엔드포인트나 판별자가 없다. 다음 턴에 또 spec 델타를 받아 자연스럽게 다턴 대화가 된다.
- 카드를 못 만들면
-
이미지 턴(V2)은 서브플로우이지 별도 엔드포인트가 아니다.
- 단일 매칭이면 AI가
spec.anchor_book을 채우지만, 후보가 여럿이면 채우지 않고 사용자가 고르게 한다("확정은 사람이 한다"). 표지를 못 읽은 경우와 읽었으나 카탈로그에 없는 경우를 구분하지 않고 같은 안내 reply를 준다.
- 단일 매칭이면 AI가
-
이력·인기는 요청에 없다.
- 서버가
user_id로v_user_purchases·v_user_library·v_user_reviews를,book_id로v_book_popularity를 복제 테이블에서 직접 읽는다. 프로필computed_at(= 반영한 이력 행들의 최대 시각) 이후 이력은 이 채점 스텝에서 복제 테이블을 읽어 같은 가중치 표로 더한다. 단 복제 지연(이력 3종 허용 5분) 동안은 방금 산 책이 아직 사본에 없어 카드에 잠깐 다시 뜰 수 있다 — 명세가 수용한 유일한 체감 지연이다.
- 서버가
-
LLM 장애 강등: 델타 추출과 이유 생성(호출 1·2)을 건너뛰고 요청의 spec으로 후보 검색·채점만 수행해 상위 3권을 낸다.
-
degraded:true,reason_short는 규칙으로,reason_long은null. 스트리밍이라 헤더를 못 붙여 본문data.degraded로 알린다.
-
-
스트리밍:
이벤트 타입은
delta·done·error셋이지만 V1은done하나만 보낸다(envelope 전체).error가 따로 있는 이유는 200 헤더가 나간 뒤엔 상태 코드를 못 바꾸기 때문 — 클라이언트는error이벤트의message를 상태 코드 자리에 쓴다.
flowchart LR
A["⑥ 취향 프로필 조회<br/>centroid + tag_weights + computed_at"] --> B["규칙 신호<br/>작가·카테고리·태그·이력·인기"]
A --> C[centroid 코사인 유사도]
VW[("v_user_* (이력) · v_book_popularity (인기)<br/>AI Postgres 복제 테이블")] -.->|computed_at 이후 이력 가산| B
B --> D[가중합 = match_score]
C --> D
D --> E{cold_start?}
E -- Yes --> F["개인화 신호 끄고<br/>인기·신간만, match_score=0"]
E -- No --> G["정렬·필터<br/>(surface=recommend_more일 때만 sort/filters)"]
G --> H["next_cursor 발급<br/>(서버는 목록을 저장하지 않음)"]
H -.->|다음 요청 cursor| A
-
목록을 저장하지 않는다.
- 커서는 "어디까지 봤는지" 이어붙일 위치만 담고, 서버는 그 지점부터 다시 계산한다.
- 프로필·인기 집계가 스크롤 도중 바뀌면 410 없이 "지금 값 기준"으로 이어붙인다
- 순서 재현성은 포기하고 가용성을 택했다. 커서 발급 이후 생긴 조회·구매 이력은 제외 대상에서 뺀다(카드 보고 돌아와 스크롤해도 목록이 밀리지 않게).
-
LLM이 없다. 피드 항목에는 추천 이유 문구가 아예 없다
-
reason_short·reason_long·match_basis셋 다 안 낸다.
-
-
복제 테이블에 행이 없을 때
이력이 비면 개인화가 약해지고, 인기 행이 없으면 그 항 0점일 뿐 200(복제 지연·행 부재는
X-Degraded아님).벡터 인덱스 이상·모델 교체 구간에만
X-Degraded: rule-only로 취향 유사도를 빼고 규칙 점수만으로 정렬.centroid가 없으면 이건cold_start다. AI Postgres 응답 불능은 전면 500. -
filters로 0건이면items:[]·next_cursor:null로 200.필터를 임의로 풀지 않는다.
filters없이 0건인 경우는 없다.
두 단계가 시점이 다르게 이어진다. 무거운 연산(LLM 추출·임베딩)은 ⑤에서 끝나 있고, ⑥은 그 결과와 복제 테이블을 집계하는 단계다.
flowchart TD
subgraph NIGHTLY["⑤ /preferences/extractions — 야간 배치 (V2)"]
A["활동 있던(종료된) 대화 세션<br/>세션 단위 호출"] --> B["LLM: 세션 전체에서 취향 사실 추출<br/>type/value, confidence 0.5 미만은 반환 안 함"]
B --> C["각 사실 임베딩<br/>bge-m3-2026q3"]
C --> D["BE가 취향 테이블에 한 행씩 저장<br/>(source_conversation_id로 재처리 시 교체)"]
end
subgraph REBUILD["⑥ /preferences/profile — 재계산 (V1, 동기)"]
E["트리거: 온보딩 완료 직후 · 취향 기억 변경 시<br/>(구매·리뷰가 늘었다고 부르지 않음)"] --> F["입력: onboarding + memories[]<br/>(memories는 이미 vector 포함)"]
F --> G["복제 테이블 조회: v_user_purchases ∪ v_user_library ∪ v_user_reviews<br/>(user_id로. 요청 본문에 없음)"]
G --> H["가중평균 centroid<br/>좋아한책 ∪ 작가벡터 ∪ 카테고리·태그 사전벡터<br/>∪ 기억벡터 ∪ 이력 책벡터(구매3 / 리뷰4~5점2 / 담기1)"]
H --> I["태그 가중치 집계<br/>(리뷰1~2점 −2: centroid엔 빼고 카테고리 점수만 깎음)"]
I --> J["user_profiles upsert<br/>computed_at = 반영한 이력 행들의 최대 시각 (계산 시각 아님)<br/>(user_id 단위 직렬, last-writer-wins)<br/>순위 영향값 바뀔 때만 profile_version +1"]
end
D -.->|다음 재계산 트리거 시 memories로 전달| E
J --> K["파이프라인 B·C가 참조하는<br/>개인화 단일 소스"]
-
⑥은 "AI 파이프라인"이라기보다 가중평균 계산이다.
좋아한 책·이력 책은 적재 시 문서벡터 재사용, 작가/카테고리/태그는 사전 임베딩, 기억은 ⑤가 이미 임베딩.
그래서 ⑥은 LLM도 임베딩 업스트림도 호출하지 않고, 503·504가 없다.
-
매번 처음부터 다시 계산한다.
기억을 지워도 그 몫만 분리해 뺄 수 없기 때문에 남은 것 전체로 재계산.
그래서 온보딩 응답도 기억만 바뀔 때 함께 다시 보낸다.
이게 왜 별도 스텝이 필요한지의 근거: 삭제·추가가 뒤섞인 상태에서 일관된 프로필을 유지하려면 "전량 재계산"이 항상 따라와야 한다.
-
computed_at은 응답에 안 나가는 내부값이고, "계산 시각"이 아니라 "프로필이 실제로 반영한 이력 행들의 최대 시각"이다.- ③·④가 채점할 때 그보다 나중의 이력만 골라 점수에 더한다(같은 가중치 표).
- 이력을 복제 테이블에서 읽으므로 계산 시점에 아직 복제되지 않은 이력이 있을 수 있는데, 계산 시각을 기준으로 삼으면 그 이력이 프로필에도 가산 대상에도 없어 영구 누락된다.
- 반영한 이력이 없으면 값이 비고 그때는 이력 전부를 가산한다.
-
멱등:
idempotency_key가 같고 본문이 같으면 재계산을 건너뛰고 저장된 결과를 200으로 반환.- 그 사이 구매가 생겨 실제 결과가 달라질 수 있어도, 멱등 규약은 "저장된 결과를 그대로 돌려주는 것"이라 응답은 일관된다. 같은 키에 다른 본문이면 409.
-
동시성:
user_id단위 직렬 처리 — 온보딩 완료와 기억 변경이 겹쳐 들어와도 나중에 끝난 요청 결과가 남는다. -
이 프로필이 파이프라인 B·C의 개인화 단일 소스다. 취향이 형성되는 이 파이프라인이 실은 나머지 파이프라인의 입력을 만드는 상류 공정이다.
flowchart TD
A[발화 + context_cards + focused_book_id] --> B["지시어 해소<br/>'N번' → context_cards[N-1], '이거' → focused_book_id"]
B --> C{"confidence 0.6 이상?"}
C -- No --> D["되물음 발화, tool 미실행 (200)"]
C -- Yes --> E["의도 분류<br/>담기·수량변경·장바구니조회·주문요약·재고·골라담기·비교"]
E --> F["tool 루프 (상한 3스텝, 독립 호출 병렬)<br/>골라담기: recommendations.candidates(내부)<br/>→ inventory.check/book.detail로 가격·재고 재검증<br/>→ 조합 전수열거로 결정적 선택"]
F --> G{grounding 검증}
G -- "수치 근거 있음" --> H["reply + buttons + resolved_reference (+selection), 200"]
G -- "일부 근거 없음" --> I["해당 문장만 축약, 200 (tool_call.grounded:false 기록)"]
G -- "핵심 주장 근거 없음" --> J["발화 전체 폐기, 422 grounding_failed"]
- 검색·피드·에이전트가 하나의 랭킹 로직을 공유한다
-
후보 생성(
recommendations.candidates)은 엔드포인트가 아니라 내부 모듈이고, ④(홈 피드)와 같은 채점 로직을 재사용한다.
-
후보 생성(
-
grounding 검증이 이 파이프라인에서 매우 중요하다
- 발화에 들어가는 모든 수치(가격·재고·수량·합계·별점)는 반드시 그 턴에 실행한
tool_calls[].result에서만 온다. 근거가 없으면 문장을 축약(부분 실패, 200)하거나 발화 전체를 버린다(핵심 주장이 근거 미확보면 422). -
context_cards의 스냅샷 가격을 그대로 쓰지 않고inventory.check로 재검증하는 것도 같은 이유 — (직전 턴 카드가 최신 가격을 보장하지 않는다.) -
selection.chosen[].price·total_price는 재검증된 값이다.
- 발화에 들어가는 모든 수치(가격·재고·수량·합계·별점)는 반드시 그 턴에 실행한
- 선택형("3만원 안에서 2권")은 LLM이 조합을 판단하는 게 아니라 결정적 알고리즘(조합 전수 열거 → 예산·재고 필터 →
match_score합 최대)이 고른다.- 파이프라인 B에서 LLM이 후보 10권 중 3권을 고르는 것과 달리, 여기는 예산·재고 제약이 걸린 조합 최적화라 선택 자체를 코드가 한다.
-
되물음·범위 밖 안내·BE tool 실패 흡수는 모두 200이다.
- 답변으로 안내하고
tool_calls에 실패를 기록한다. 422는 grounding 전체 실패 하나뿐.
- 답변으로 안내하고
-
멱등키가 tool 단위로 파생된다(
{idempotency_key}:{tool_call_index})- 한 턴에 쓰기 tool이 여러 번 나갈 수 있어, 턴 전체 재시도 판정(본문 키)과 개별 쓰기 중복 방지(파생 키)를 분리해야 하는 멀티스텝 특유의 문제가 있다.
def agent_act(req: AgentRequest) -> AgentResponse:
if cached := get_idempotent_result(req.idempotency_key): # 같은 키·같은 본문 → 저장된 결과
return cached
# 1. 지시어 해소 ("N번" → context_cards[N-1], "이거" → focused_book_id)
ref = resolve_reference(req.message, req.context_cards, req.focused_book_id)
if ref.confidence < 0.6:
return AgentResponse(reply=make_reask(ref), tool_calls=[], # tool 미실행, 200
resolved_reference=ref, selection=None,
buttons=[confirm_reference_button(ref)])
# 2. 의도 분류
intent = classify_intent(req.message) # add | qty_edit | cart_view | order_preview | stock | select | compare
# 3. tool 루프 — 상한 3스텝, 독립 호출은 병렬, 쓰기엔 파생 멱등키
calls: list[ToolCall] = []
selection = None
if intent == "select": # "3만원 안에서 2권"
cands = call_tool("recommendations.candidates", {"size": 20}, calls) # 내부 모듈, 감사 기록
verified = call_tool("inventory.check", # 스냅샷 신뢰 안 함
{"book_ids": [c.book_id for c in cands.candidates]}, calls)
selection = deterministic_pick(cands, verified, req.constraints) # 조합 전수열거, LLM 아님
buttons = [confirm_selection_button(selection.chosen)]
elif intent == "add":
idem = f"{req.idempotency_key}:{len(calls)}"
call_tool("cart.add", {"book_id": ref.book_ids[0], "qty": 1, "_idem": idem}, calls)
buttons = [navigate_button("CART-001")]
elif intent == "qty_edit": # 수량 변경 / 삭제
idem = f"{req.idempotency_key}:{len(calls)}"
call_tool("cart.update", # 수량 0 = 삭제
{"book_id": ref.book_ids[0], "qty": req.target_qty, "_idem": idem}, calls)
buttons = [navigate_button("CART-001")]
elif intent == "compare":
for bid in ref.book_ids:
call_tool("book.detail", {"book_id": bid}, calls) # 병렬
buttons = [confirm_reference_button(ref)]
else: # stock / cart_view / order_preview / 범위 밖
buttons = handle_read_or_out_of_scope(intent, ref, calls)
# 4. grounding — 발화의 모든 수치·상태는 calls[].result에서만
draft = llm_compose_reply(intent, ref, calls, selection)
reply, calls = enforce_grounding(draft, calls)
if core_claim_ungrounded(reply, intent): # 축약해도 핵심 주장이 근거 미확보
raise GroundingFailed(422) # 발화 전체 폐기
result = AgentResponse(reply=reply, tool_calls=calls, resolved_reference=ref,
selection=selection, buttons=buttons)
save_idempotent_result(req.idempotency_key, result)
return result
def enforce_grounding(draft: str, calls: list[ToolCall]) -> tuple[str, list[ToolCall]]:
facts = index_numbers_and_states(calls) # 가격·재고·수량·합계·별점
kept = []
for s in split_sentences(draft):
needed = extract_claims(s) # 이 문장이 주장하는 수치·상태
if all(claim in facts for claim in needed):
kept.append(s)
else:
mark_ungrounded(calls, needed) # 해당 tool_call.grounded = False, 문장 제거
return " ".join(kept), calls핵심은 LLM이 마지막 조립(llm_compose_reply)에만 들어가고, 그 출력이 곧바로 enforce_grounding을 통과해야 한다는 것 — 선택·검증·계산은 전부 결정론적 코드가 하고, LLM은 이미 계산된 사실을 문장으로 옮긴다.
왜 "추천 이유 상세" 스텝이 파이프라인에서 사라졌나
v3.1까지 독립 파이프라인이던 '추천 이유 상세'(/books/{bookId}/match-reason + reason_ref 캐시) 는 최종 명세에서 통째로 제거됐다(v4에 이 라벨은 없다). 멀티스텝 설계 관점에서 이건 "스텝을 줄이는 것도 설계"라는 사례다.
| 캐시 있는 별도 엔드포인트 (v3.1) | ③에 병합 (최종) | |
|---|---|---|
| 이유 생성 시점 | 카드 생성 시 reason_short, 상세 진입 시 reason_long (2회) |
카드 생성 시 둘 다 (1회) |
| 정합성 |
reason_short와 reason_long이 다른 호출 → 어긋날 수 있음 |
같은 호출·같은 match_basis → 못 어긋남 |
| 상세 진입 지연 | 캐시 미스면 LLM 지연 | LLM 지연 없음 (BE가 보관한 값 표시) |
| 없어지는 것 | — | 캐시 테이블 · TTL · 권한 규칙 · llm-off 캐시 재조립 로직 |
| 대가 | — | 안 열어보는 카드의 reason_long도 생성 → 턴당 출력 토큰 증가 |
| 취향 삭제 시 | 옛 취향이 담긴 이유가 AI 캐시에 잔류 | AI는 저장 안 함 → 잔류 문제 없음 |
판단: 상세 페이지 조회량이 카드 생성량보다 크게 적지 않고(추천을 거친 조회에만 이유가 있음), 캐시 인프라·정합성·프라이버시 비용이 "안 열어보는 카드의 토큰 몇 개"보다 크다. 그래서 스텝을 하나 없애고 그 산출물의 보관 책임을 BE로 넘겼다.
| 구성요소 | 선택 | 선택 이유 | 기대 효과 |
|---|---|---|---|
| 텍스트 임베딩 |
bge-m3-2026q3(dim 1024) |
API 계약으로 고정. | 인덱스·프로필·기억 벡터가 전부 호환 |
| (별도 트랙) e5-small | 벤치 결과 기준 후보 | 추론 최적화 실험에서 하이브리드 recall·지연·비용 우위 확인 | 계약과 분리된 트랙. |
| 검색 결합 | RRF | 스케일 다른 두 랭킹(BM25·벡터)을 정규화 없이 합치는 표준 기법 | ① 검색과 ③ 후보검색이 같은 로직 재사용 |
| 벡터 저장소 | AI 전용 PostgreSQL + pgvector (HNSW) | BE가 MySQL이라 DB 공유 불가(구 "Postgres 공유" 안 무효). 도서 임베딩·취향 프로필·멱등 기록을 한 Postgres에 둠 → 전용 벡터DB 불필요 | AI 쪽 데이터스토어 1개. 대신 BE→AI 단방향 복제 파이프라인이 필요 |
| 커머스 데이터 접근 | BE MySQL → AI Postgres 단방향 복제된 실제 테이블 AI엔 BE MySQL 연결 없음 | 요청 본문으로 실어 나르기엔 크고, 원본은 어차피 BE가 소유. 컬럼 집합·허용 지연·스키마 변경 통보를 계약면으로 두면 BE 내부 스키마 변경과 무관 | 이력·인기 값의 단일 출처 → 검색 인기순 = 추천 인기 항. 허용 지연은 신선도 예산 표(가격·재고 5분 / 신간+임베딩 1h / 인기 24h / 이력 5분). 행 없으면 해당 항 0점 200, AI Postgres 응답 불능은 500 |
| 대화·이유 생성 LLM | 소형 텍스트 LLM, 상용 API 우선 | 스트리밍 필요(③), 초기 구축비용 낮음. 호출 지점이 코드에 2곳(③ 델타·③ 이유)뿐이라 교체 용이 | 빠른 출시. 트래픽 증가 시 자체 서빙 손익분기 재평가 (단계 6) |
| 오케스트레이션 프레임워크 | 미채택 (V1 전체) | ③은 고정 선형 체인(LLM 2회 + 결정론적 검색·채점) ⑦도 최대 3스텝 고정 tool 세트 | 의존성 최소화. ⑦의 tool loop이 복잡해지면 재평가 |
| 표지 인식기 | 생성형 VLM 미채택 이미지 인코더(자체) + OCR + RRF 최근접 검색 | "사진 → book_id" 단일 인식에 생성 모델 불필요. 검색 파이프라인의 RRF를 그대로 재사용 | 단계 5에서 확정. 구체 인코더 모델은 실측 후 선정 |
과제 1번 예시는 "LangChain으로 질의·검색·응답을 체인으로 구성"을 전제한다. V1에서 도입하지 않는다.
| 파이프라인 | 구조 | 프레임워크가 해결할 문제가 있나 | 판정 |
|---|---|---|---|
| ③ 대화 추천 | 델타추출(LLM) → 하이브리드검색 → 결합스코어링 → 3권 선택+이유생성(LLM 1회) → 카드 조립. 고정 선형, 분기는 "후보 0 → followup" 하나 | 체인이 정적이라 그래프 오케스트레이션 이득 없음. 프롬프트·파서·재시도는 얇은 함수로 충분 | 미채택 |
| ①④·⑥ | LLM이 없거나(①④), 순수 집계(⑥) | 애초에 "체인"이 아님 | 해당 없음 |
| ⑤ 야간 추출 (V2) | 세션당 LLM 1회 + 임베딩 1회 배치 | 선형 2스텝. 배치 스케줄링은 프레임워크 밖 문제 | 미채택 |
| ⑦ 에이전트 (V2) | 지시어해소 → 의도분류 → tool 루프(≤3스텝) → grounding → 조립 | 반복 tool 호출·상태 누적·중단조건·부분실패 | 미채택하되 재평가 트리거 명시 |
-
재평가 트리거 (⑦ 한정)
ⓐ tool 루프 상한이 3스텝을 넘음,
ⓑ tool을 동적으로 선택해야 함(고정 7종 밖),
ⓒ 턴 간 상태를 서버가 들고 있어야 함(현재 무상태),
ⓓ 스텝별 재시도·롤백이 함수로 관리 불가능해짐. 이 중 둘 이상이면 LangGraph류 상태그래프 도입 — 노드=스텝, 엣지=중단조건, 체크포인트=파생 멱등키.
-
기대 효과: V1 의존성 트리를 얇게 유지(FastAPI + 임베딩 SDK + LLM SDK)
→ 배포·디버깅·장애 추적이 단순. LLM 호출 지점이 코드에 2곳으로 명시적으로 드러나 grounding·강등 로직을 그 지점에 직접 붙일 수 있다.
프레임워크 추상화 뒤로 숨었다면 "LLM은 정해진 후보 위에서만 고르고 검색·채점은 결정론적 코드가 한다"는 파이프라인 B·E의 역할 경계를 코드로 강제하기 어려웠을 것.
-
def hybrid_candidates(text_query: str, filters: dict, size: int) -> list[Book]:
kw_hits = bm25_search(text_query, filters)
query_vec = call_embeddings_api([text_query], purpose="query")[0] # bge-m3-2026q3
vec_hits = pgvector_search(query_vec, filters)
return reciprocal_rank_fusion(kw_hits, vec_hits, k=60)[:size]
def search(query: str, filters: dict, cursor: str | None, size: int = 15) -> SearchResponse:
fused = hybrid_candidates(query, filters, size)
ranked = apply_business_rules(fused, popularity=read_replica("v_book_popularity")) # 복제 테이블. 행 없으면 인기항 0
if not ranked:
return SearchResponse(results=[], next_cursor=None,
fallback={"message": "원하는 책을 못 찾았어요. AI 추천에게 물어볼까요?"})
return SearchResponse(results=ranked, next_cursor=make_cursor(ranked, query, filters))def recommend_chat(user_id: int, message: str | None, image_ref: str | None,
prev_spec: Spec, recent_turns: list, exclude_book_ids: list) -> ChatResponse:
if image_ref:
return handle_image_turn(image_ref, prev_spec, user_id) # 인식, cards는 recognized일 때만
validate_spec_schema(prev_spec) # 요청 spec이 6키 스키마 위반 → 422 spec_schema_violation
# (LLM 호출 전. 호출자가 초기 spec으로 1회 재시도)
# LLM 호출 1 — 발화 해석
delta = llm_extract_spec_delta(message, prev_spec, recent_turns) # 6키만
spec = merge_spec(prev_spec, delta) # 서버가 기존 spec에 델타 병합
# 결정론적 후보 풀 + 채점 — LLM 아님
profile = load_user_profile(user_id) # centroid + computed_at
hist = read_history_replica(user_id) # 복제 테이블 v_user_purchases/library/reviews
excluded = set(exclude_book_ids) | set(spec["exclude"]) \
| hist.purchased | hist.rated_low # 산 책·별점 1~2 제외
cands = hybrid_candidates(spec["semantic"] or "", spec["filters"], size=10)
cands = [c for c in cands if c.book_id not in excluded]
scored = {c.book_id: (c, combine_score(
query_sim=c.vec_sim,
taste_sim=cosine(c.vec, profile.centroid),
post_profile_hist=hist.since(profile.computed_at), # computed_at(반영시각) 이후 이력 가산
popularity=read_replica("v_book_popularity", c.book_id)))
for c in cands}
if not scored:
return ChatResponse(reply=..., spec=spec, cards=[],
followup=make_reclarify_question(spec),
buttons=[], degraded=False)
# LLM 호출 2 (1회) — 10 후보 중 3권 선택 + 그 3권에 이유 2종
picked = llm_pick_and_write(scored, spec, profile)
# → [{book_id, reason_short, reason_long, match_basis}] 최대 3개. 배열 순서가 곧 rank
cards = []
for rank, r in enumerate(picked, start=1):
if r.book_id not in scored or r.reason_short is None: # 후보 밖이거나 한 줄 이유 실패 → 제외
continue
book, score = scored[r.book_id]
cards.append(Card(book_id=book.book_id, rank=rank,
match_score=int(score), # 후보 점수 그대로. rank 순과 무관
reason_short=r.reason_short,
reason_long=r.reason_long, # null 가능 → BE가 상세 이유영역 숨김
match_basis=r.match_basis))
return ChatResponse(reply=compose_reply(cards), spec=spec, cards=cards,
followup=None, buttons=[], degraded=False)
# LLM 장애 강등: 위 두 호출을 건너뛰고 요청의 spec으로 채점만 → 점수 상위 3권,
# degraded=True, reason_short는 규칙, reason_long=nulldef rebuild_profile(user_id: int, idempotency_key: str, onboarding: dict, memories: list) -> ProfileResult:
if cached := get_idempotent_result(idempotency_key): # 24h 보관, 같은 본문이면 재계산 스킵
return cached
if any(m["dim"] != INDEX_DIM for m in memories):
raise InvalidRequest(400) # 차원 불일치
hist = read_history_replica(user_id) # 요청 본문에 없음 — user_id로 복제 테이블 조회
liked_vecs = [book_doc_vector(b) for b in onboarding.get("liked_book_ids", [])[:50]]
author_vecs = [author_vector(m["value"]) for m in memories if m["type"] == "author"]
tag_vecs = preset_vectors(onboarding.get("categories", []) + onboarding.get("tags", []))
mem_vecs = [m["vector"] for m in memories[-500:]] # 이미 ⑤에서 임베딩 — 재호출 없음
hist_vecs = hist.weighted_doc_vectors() # 구매3 / 리뷰4~5점2 / 담기1, 리뷰1~2점 제외
centroid = weighted_average(liked_vecs + author_vecs + tag_vecs + mem_vecs + hist_vecs)
tag_weights = aggregate_tags(onboarding, memories, hist) # 리뷰1~2점은 여기서 −2
reflected_at = hist.max_row_ts() # 실제로 읽어 반영한 이력 행들의 최대 시각. now() 아님 —
# 복제 늦게 온 이력이 프로필·가산 양쪽에서 영구 누락되지 않게.
# 반영한 이력이 없으면 None → ③④가 이력 전부 가산
version = upsert_user_profile(user_id, centroid, tag_weights, computed_at=reflected_at) # user_id 직렬
result = ProfileResult(cold_start=not (liked_vecs or mem_vecs or hist.any()),
profile_version=version)
save_idempotent_result(idempotency_key, result)
return result-
검색·홈 피드
- (①④): LLM 없이 설계된 것 자체가 "장애 격리"라는 명확한 서비스 요구를 충족한다 — LLM이 죽어도 책을 찾고 살 수 있어야 한다는 원칙이 명세에 그대로 적혀 있다.
- 멀티스텝(BM25+벡터+RRF, 규칙+centroid 가중합)은 필수이되,
- LLM 단계는 의도적으로 배제한 것이 이 파이프라인의 핵심 설계 결정.
-
대화 추천
- (③): 멀티스텝 필수 — "LLM이 검색하고 순위를 매긴다"가 아니라 "결정론적 검색·스코어링이 후보 10권과 점수를 만들고, LLM은 그중 3권과 순서만 고른 뒤 이유를 붙인다"는 역할 분리가 실제 이점.
- 후보 밖의 책을 LLM이 끌어오거나 점수를 다시 매길 수 없어 환각이 추천 결과를 왜곡할 여지를 구조적으로 줄인다.
- 선택과 이유 생성을 한 호출로 합친 것은 스텝 수·인프라를 줄인 별도 설계 결정
-
취향 형성
- (⑤+⑥): 배치(⑤, 무거운 LLM+임베딩)와 동기 재계산(⑥, 가벼운 집계)을 시점 자체로 분리한 게 핵심 이점
- 사용자가 온보딩을 마치자마자 기다리는 ⑥ 경로에는 LLM이 아예 없어 빠르고 503·504가 없다.
-
computed_at(반영 시각) 보정으로 "채점 시점에 복제 테이블을 읽어 최신 이력을 더하는" 스텝이 프로필 재계산과 분리돼, 구매·리뷰가 늘 때마다 무거운 재계산을 부를 필요가 없다.
-
쇼핑 에이전트
- (⑦, V2): tool loop과 grounding 검증까지 필요한 진짜 멀티스텝
- 여기서 프레임워크(LangGraph류) 도입 여부는 스텝 수·tool 동적성이 늘어날 때 재평가 대상.
-
비전 다단계 파이프라인은 불필요
- 표지 인식은 단일 인식 호출(인코더+OCR+RRF)로 ③에 흡수된다.
- 표지 인식 인코더의 구체 모델(자체 서빙 이미지 인코더 vs 상용 Vision API)
- ③의 결합 스코어링(질의 유사도 + centroid 유사도 + 프로필 이후 이력 + 인기) 가중치 계수
- ⑦ tool loop이 향후 스텝 수·동적 tool 선택으로 복잡해질 경우 프레임워크(LangGraph 등) 도입 재검토
- BE MySQL → AI Postgres 복제 수단(CDC / 논리 복제 등)
- 회원 탈퇴 시 취향 프로필 삭제 통지
- 임베딩 벡터 확정 차원