데이터 컨텍스트 보강 설계 - 100-hours-a-week/KTB4-4th-wiki GitHub Wiki
4단계는 확정된 취향 프로필에서 검색 문장을 만들고, 임베딩·벡터 검색·랭킹·추천 이유 생성을 차례로 실행한다고 정했다. 검색할 상품을 어떤 데이터로 만들고, 상품과 취향을 어떤 의미로 비교하며, 검색 결과를 모델에 어떤 근거로 넘길지는 정하지 않았다. 5단계는 그 안쪽을 정한다.
니쥬는 문서 질의응답 서비스가 아니다. 외부 문서를 찾아 답변에 붙이는 전통적인 RAG보다, 취향을 검색 표현으로 바꾸고 상품 카탈로그에서 후보를 찾은 뒤 그 근거로 추천 이유를 쓰는 **검색 증강 추천(Retrieval-Augmented Recommendation)**에 가깝다. 따라서 대화와 프로필 추출에는 검색을 붙이지 않고, 리스트 생성 잡의 상품 검색과 추천 이유 생성에만 컨텍스트 보강을 적용한다.
| 앞 단계에서 넘어온 것 | 이 문서에서 답하는 곳 |
|---|---|
query·weight·filter로 나뉜 ProfileItem.linkRole
|
검색 계약 · 검색 쿼리 스키마 |
| 검색 문장 재작성 → 임베딩 → pgvector 검색 | 온라인 검색 흐름 |
| PostgreSQL + pgvector, Upstage Embed 2 1024차원 | 저장 스키마 · 인덱싱 |
| 하드필터와 벡터 검색을 한 SQL에 넣는 결정 | 상품 필터 속성 · 검색 SQL |
self·gift가 후보 풀을 공유하고 각 20개를 반환하는 계약 |
후보 병합 · 모드별 선별 |
카탈로그 배치를 batch에서 실행하는 구조 |
오프라인 카탈로그 흐름 |
| 결과가 부족해도 쿼리를 완화하지 않는 결정 | 범위와 실패 경계 |
비전·음성·이미지·영상 생성 파이프라인은 서비스 범위에 없다. 상품 이미지는 화면 표시용 URL로 보관하지만 이미지 임베딩이나 Visual RAG에는 사용하지 않는다. 이미지 자체가 추천 품질에 필요하다는 근거가 생기면 별도 실험으로 검토한다.
1. 상품 원본과 검색 표현을 분리한다 — 다이어그램과 설계 이유
크롤링한 상품명·카테고리·가격·판매처는 원본 사실이다. content·usage·gift 설명과 벡터는 그 사실에서 다시 만들 수 있는 파생값이다. 둘을 한 행의 자유문장으로 섞지 않는다.
flowchart LR
SRC["상품 페이지<br/>크롤링"] --> RAW["수집 원본<br/>출처 · 시각 · 원문 해시"]
RAW --> CORE["상품 기본 정보<br/>이름 · 카테고리 · 가격 · 링크"]
RAW --> SEM["의미 속성<br/>특징 · 관심사 · 사용 맥락 · 선물 특성"]
RAW --> FILTER["필터 속성<br/>알레르기 · 향 · 소재"]
CORE --> DOC["검색 문서 생성<br/>고정 템플릿"]
SEM --> DOC
DOC --> C["content 문서"]
DOC --> U["usage 문서"]
DOC --> G["gift 문서"]
C --> EMB["Upstage Embed 2<br/>1024차원"]
U --> EMB
G --> EMB
EMB --> PG[("PostgreSQL + pgvector")]
- 상품 원본은 검색 쿼리나 임베딩 모델이 바뀌어도 유지되는 사실이다.
- 검색 문서에는 생성 규칙과 버전을 남긴다. 표현을 바꿔도 상품을 다시 크롤링하지 않고 문서와 벡터만 재생성한다.
- LLM이 상품별 자유문장을 바로 쓰면 표현 편차와 추측이 섞인다. 먼저 구조화 속성을 만들고 고정 템플릿으로 검색 문서를 조립한다.
- 하드필터 속성은 벡터에 숨기지 않는다. 알레르기처럼 반드시 지켜야 하는 값은 PostgreSQL 컬럼으로 검사한다.
2. 상품과 쿼리를 같은 의미 축으로 투영한다 — 다이어그램과 설계 이유
먼저 content·usage·gift라는 검색 의미 축을 정의한다. 상품은 축별 검색 문서를 만들고, 검색 쿼리도 어느 축을 검색하는지 viewType으로 명시한다.
flowchart LR
PI["ProfileItem<br/>'캠핑장에서 핸드드립'"] --> QP["SearchQuery 생성"]
QP --> QC["content query<br/>휴대용 캠핑 커피 용품"]
QP --> QU["usage query<br/>캠핑에서 커피를 내려 마실 때 쓰는 제품"]
QP --> QG["gift query<br/>캠핑과 커피를 좋아하는 사람의 선물"]
QC -.코사인 유사도.-> PC["product.content"]
QU -.코사인 유사도.-> PU["product.usage"]
QG -.코사인 유사도.-> PG["product.gift"]
- 현재 쿼리 문장에 맞춰 상품 설명을 쓰면 쿼리 프롬프트를 바꿀 때 카탈로그가 함께 무효가 된다.
- 상품 설명을 먼저 고정하고 쿼리만 반복해서 고도화하면 설명에 없는 사용 맥락과 선물 의미를 검색할 수 없다.
- 두 표현이 공유할 의미 축을 먼저 정하면 description과 query를 각각 실험할 수 있다.
-
content하나를 기준선으로 먼저 측정하고,usage·gift는 골드셋에서 개선이 확인될 때 채택한다. 세 벡터는 가정이지 검증 결과가 아니다.
3. 검색은 후보를 만들고 모델은 근거를 문장으로 바꾼다 — 다이어그램과 설계 이유
flowchart LR
P["확정된 TasteProfile"] --> S["query 역할 항목 선별"]
S --> Q["구조화 SearchQuery<br/>최대 6개"]
Q --> E["쿼리 임베딩<br/>배치 1회"]
E --> V["pgvector 검색<br/>하드필터 포함"]
V --> M["productId 합집합<br/>matchedSignals 누적"]
M --> RS["self 유사도 순위"]
M --> RG["gift 유사도 순위"]
RS --> DS["다양성 선별<br/>20개"]
RG --> DG["다양성 선별<br/>20개"]
DS --> CS["상품 사실 + 매칭 근거"]
DG --> CG["상품 사실 + 공개 가능한 근거"]
CS --> L["추천 이유 모델"]
CG --> L
L --> OUT["self + gift"]
- LLM은 카탈로그에 없는 상품을 만들 수 있으므로 상품 ID 선택권을 주지 않는다.
- 벡터 검색은 후보 생성이다. 최종 20개는 여러 쿼리의 후보를 합치고 중복을 제거한 뒤 선별한다.
- 취향 항목마다
20 / N개를 미리 배정하지 않는다. 약한 취향이 강한 취향과 같은 슬롯을 차지하고, 여러 취향에 걸린 상품을 처리하기 어렵기 때문이다. - 추천 이유 모델에는 최종 상품과 실제 매칭 근거만 준다. 검색 결과 전체나 대화 원문을 넘기지 않는다.
4. 검색 표현은 골드셋으로 고른다 — 검증 흐름과 설계 이유
flowchart LR
G["골드셋<br/>프로필 · 모드 · 관련 상품 · 금지 상품"] --> B0["B0<br/>content + direct query"]
G --> E1["E1<br/>content + rewritten query"]
G --> E2["E2<br/>content·usage + direct query"]
G --> E3["E3<br/>content·usage·gift + view query"]
B0 --> C["Recall@K · nDCG@K<br/>금지 위반 · 지연"]
E1 --> C
E2 --> C
E3 --> C
C --> D["개선된 구성만 채택"]
- description과 query를 한 번에 바꾸면 어느 변경이 품질을 올렸는지 알 수 없다.
- 추천은 정답 상품 하나가 없으므로 관련도를
높음·중간·낮음·무관·금지로 라벨링한다. - 현재 검색기가 찾은 결과만 골드셋 후보로 쓰면 현재 방식의 사각지대가 정답에서 빠진다. 여러 검색 방식과 사람이 고른 상품을 합쳐 평가 후보를 만든다.
- 지표가 기준선을 넘지 못한 LLM 재작성과 추가 벡터는 도입하지 않는다.
| 기능 | 검색 컨텍스트 | 판단 |
|---|---|---|
| 대화 응답 | 없음 | 사용자의 대화 이력만으로 문장을 만든다 |
| 델타 추출 | 없음 |
[STATE]와 직전 두 발화만 필요하다 |
| 프로필 요약 | 없음 | 확정된 공개 항목만 사용한다 |
| 검색 쿼리 생성 | 취향 택소노미 · 동의어 | 사용자 표현을 상품 검색 축으로 투영한다 |
| 상품 후보 생성 | 상품 검색 문서 · 벡터 · 필터 속성 | 이 단계가 검색 증강의 본체다 |
| 순위와 선별 | 유사도 · 취향 확신도 · 매칭 근거 | 모델을 호출하지 않는 계산이다 |
| 추천 이유 | 선택된 상품 사실 · matchedSignals
|
검색 근거에 닫힌 문장만 만든다 |
RAG 도입 전에는 모델이나 외부 검색 API가 알고 있는 상품만 추천할 수 있다. 도입 후에는 AI 서버가 보유한 카탈로그 안에서만 상품을 고르고, 어떤 취향 쿼리가 어떤 상품을 데려왔는지 추적할 수 있다. 최신성은 모델 파라미터가 아니라 카탈로그 배치 주기로 관리한다.
| 소스 | 저장하는 것 | 쓰는 곳 | 선택 이유 |
|---|---|---|---|
| 상품 목록·상세 페이지 | 상품명, 카테고리, 가격, 이미지, 판매처, 구매 URL | 화면 표시 · 검색 문서의 사실 | 실제 구매 가능한 상품이 추천 대상이다 |
| 상품 상세 설명 | 소재, 규격, 기능, 구성 |
features · 필터 속성 · content 문서 |
상품명만으로 알 수 없는 차이를 보완한다 |
| 리뷰 또는 판매처 요약 | 사용 맥락, 선물 언급, 반복 특성 |
usageContexts · giftTraits
|
상품의 사용 상황과 선물 맥락은 상세 정보만으로 부족하다 |
| 내부 취향 택소노미 |
TasteNode, 별칭, 계층 |
쿼리 보조 · 카탈로그 커버리지 | 상품 카테고리와 사용자 취향은 같은 분류가 아니다 |
| 사용자 피드백 요약 | 좋아요, 싫어요, 이미 있음, 숨김 | 제외 목록 · 향후 순위 보정 | 실제 추천 결과의 품질 신호다 |
| 개발자 검수 데이터 | 상품 의미 속성의 정답 예시 | 속성 추출 골드셋 | 크롤링 원본만으로 자동 추출을 검증할 기준이 없다 |
수집처와 수집 방식은 아직 정하지 않았다. API·제휴 피드·크롤링 중 어떤 방법을 쓰더라도 sourceProvider, sourceProductId, sourceUrl, crawledAt을 남긴다. 수집 허용 범위와 리뷰 이용 가능 여부를 확인하기 전에는 원문 장기 보관을 전제로 하지 않는다. 리뷰를 사용할 수 있더라도 필요한 구조화 속성과 요약만 남기고 원문 보관 여부는 별도로 결정한다.
상품 카테고리와 취향 택소노미를 하나로 합치지 않는다.
| 체계 | 답하는 질문 | 예 | 쓰는 곳 |
|---|---|---|---|
| 상품 카테고리 | 이 상품은 무엇인가 | 스포츠/레저 → 캠핑 → 캠핑식기 | 화면 분류 · 가격 분포 · 정확 조건 |
| 취향 택소노미 | 어떤 취향과 연결되는가 | 아웃도어 → 캠핑 → 캠핑커피 | 검색 보조 · 근거 · 커버리지 |
하나의 상품은 여러 취향 노드에 연결될 수 있다. 티타늄 머그컵은 상품 카테고리로는 캠핑식기 하나지만, 취향으로는 캠핑커피·백패킹·미니멀 장비와 동시에 연결될 수 있다.
TasteNode는 처음부터 닫힌 enum으로 만들지 않는다. L1·L2는 개발자가 시드로 만들고, 매핑되지 않은 취향 표현을 로그에 쌓아 반복되는 값을 후보 노드로 올린다. L3의 전체 범위와 활성화 기준은 초기 카탈로그를 보고 정한다.
| 구분 | 예 | 변경 원인 | 다시 크롤링하는가 |
|---|---|---|---|
| 원본 사실 | 상품명, 가격, 판매처, URL | 판매처 변경 | 필요 |
| 구조화 의미 | 특징, 사용 맥락, 선물 특성 | 추출 프롬프트·검수 규칙 변경 | 불필요. 원본에서 재추출 |
| 검색 문서 |
contentText, usageText, giftText
|
문서 템플릿 변경 | 불필요 |
| 임베딩 | 1024차원 벡터 | 모델·문서 버전 변경 | 불필요. 검색 문서에서 재계산 |
크롤링한 상품의 정체성과 화면 표시 값을 보관한다.
CREATE TABLE catalog_product (
product_id BIGSERIAL PRIMARY KEY,
canonical_key TEXT NOT NULL UNIQUE,
source_provider TEXT NOT NULL,
source_product_id TEXT NOT NULL,
source_url TEXT NOT NULL,
title TEXT NOT NULL,
brand TEXT,
product_category_id BIGINT,
price_krw INTEGER,
image_url TEXT,
seller_name TEXT,
in_stock BOOLEAN,
status TEXT NOT NULL,
crawled_at TIMESTAMPTZ NOT NULL,
availability_checked_at TIMESTAMPTZ,
source_content_hash TEXT NOT NULL,
UNIQUE (source_provider, source_product_id)
);canonical_key는 같은 상품이 여러 판매처에서 수집되어도 피드백과 추천 이력이 하나의 내부 상품을 가리키게 하기 위한 키다. 동일 상품 판정 규칙은 수집처가 정해진 뒤 확정한다.
검색 문서를 만들기 위한 구조화 의미다. LLM이 자유문장을 직접 만드는 대신 JSON 스키마로 이 값을 추출한다.
CREATE TABLE product_semantics (
product_id BIGINT PRIMARY KEY
REFERENCES catalog_product(product_id),
product_type TEXT NOT NULL,
features TEXT[] NOT NULL DEFAULT '{}',
matched_interests TEXT[] NOT NULL DEFAULT '{}',
usage_contexts TEXT[] NOT NULL DEFAULT '{}',
gift_traits TEXT[] NOT NULL DEFAULT '{}',
review_summary TEXT,
extraction_version TEXT NOT NULL,
source_content_hash TEXT NOT NULL,
extracted_at TIMESTAMPTZ NOT NULL
);source_content_hash가 catalog_product의 현재 값과 다르면 이 파생값은 낡은 것으로 본다. features·usageContexts·giftTraits는 상품 원본 또는 허용된 리뷰에서 근거를 찾을 수 있는 값만 저장한다.
벡터 유사도가 아니라 정확 조건으로 처리할 값이다.
CREATE TABLE product_filter_feature (
product_id BIGINT PRIMARY KEY
REFERENCES catalog_product(product_id),
allergens TEXT[] NOT NULL DEFAULT '{}',
allergen_status TEXT NOT NULL,
materials TEXT[] NOT NULL DEFAULT '{}',
scent_level TEXT NOT NULL,
needs_size BOOLEAN,
needs_install BOOLEAN,
is_fragile BOOLEAN,
contains_alcohol BOOLEAN,
is_consumable BOOLEAN,
extraction_version TEXT NOT NULL,
verified_at TIMESTAMPTZ
);allergenStatus는 verified 또는 unknown이다. 상세 페이지에 알레르기 표기가 없다는 사실을 "알레르기 물질 없음"으로 해석하지 않는다.
| 사용자 상태 | 상품 상태 | 처리 |
|---|---|---|
| 알레르기 없음 | 어떤 상태든 | 다른 조건으로 검색 |
| 알레르기 있음 |
verified, 교집합 없음 |
후보 가능 |
| 알레르기 있음 |
verified, 교집합 있음 |
제외 |
| 알레르기 있음 | unknown |
안전을 위해 제외 |
온보딩에서 사용자의 알레르기를 받아도 상품에 대응 속성이 없으면 필터는 동작하지 않는다. 상품 알레르기 정보는 상품명이나 카테고리만 보고 LLM이 추측하게 하지 않고, 원본 표기 또는 검수된 데이터에서만 verified로 올린다.
CREATE TABLE taste_node (
taste_node_id BIGSERIAL PRIMARY KEY,
node_key TEXT NOT NULL UNIQUE,
parent_id BIGINT REFERENCES taste_node(taste_node_id),
level SMALLINT NOT NULL,
label TEXT NOT NULL,
aliases TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL
);
CREATE TABLE product_taste_node (
product_id BIGINT REFERENCES catalog_product(product_id),
taste_node_id BIGINT REFERENCES taste_node(taste_node_id),
relation_type TEXT NOT NULL,
confidence NUMERIC NOT NULL,
mapping_source TEXT NOT NULL,
mapping_version TEXT NOT NULL,
PRIMARY KEY (product_id, taste_node_id, relation_type)
);relationType은 content·usage·gift 중 하나다. mappingSource는 manual·rule·llm을 구분한다. 모델이 만든 매핑과 개발자가 확인한 매핑을 같은 신뢰도로 다루지 않는다.
CREATE TABLE embedding_space (
embedding_space_id BIGSERIAL PRIMARY KEY,
model_name TEXT NOT NULL,
model_version TEXT NOT NULL,
dimensions INTEGER NOT NULL,
distance_metric TEXT NOT NULL,
document_schema_version TEXT NOT NULL,
active BOOLEAN NOT NULL,
UNIQUE (model_name, model_version, document_schema_version)
);상품과 쿼리가 같은 임베딩 모델을 썼다는 것만으로 충분하지 않다. 검색 문서 조립 규칙이 바뀌면 분포도 달라지므로 documentSchemaVersion까지 하나의 임베딩 공간으로 묶는다.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE product_search_document (
product_id BIGINT REFERENCES catalog_product(product_id),
view_type TEXT NOT NULL,
embedding_space_id BIGINT REFERENCES embedding_space(embedding_space_id),
document_text TEXT NOT NULL,
document_hash TEXT NOT NULL,
generator_version TEXT NOT NULL,
embedding VECTOR(1024) NOT NULL,
embedded_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (product_id, view_type, embedding_space_id)
);한 상품은 활성 임베딩 공간에서 최대 세 행을 가진다. 임베딩 모델을 바꿀 때 새 embeddingSpaceId로 전체 벡터를 채우고 검증한 뒤 active를 전환한다. 기존 공간을 먼저 덮어쓰지 않는다.
초기 목표는 상품 약 1만 개, 벡터 최대 3종으로 3만 벡터다. 2단계 판단대로 먼저 exact nearest-neighbor 검색을 사용한다. 일반 필터 인덱스를 만들고 실제 검색 p95가 병목이 될 때 HNSW를 비교한다.
CREATE INDEX idx_catalog_product_status_stock
ON catalog_product (status, in_stock);
CREATE INDEX idx_catalog_product_category
ON catalog_product (product_category_id);
CREATE INDEX idx_product_filter_allergens
ON product_filter_feature USING GIN (allergens);
CREATE INDEX idx_product_filter_materials
ON product_filter_feature USING GIN (materials);
CREATE INDEX idx_search_document_space_view
ON product_search_document (embedding_space_id, view_type);HNSW는 기본 설계가 아니라 비교 대상이다.
CREATE INDEX idx_product_search_hnsw
ON product_search_document
USING hnsw (embedding vector_cosine_ops);근사 인덱스 도입 시에는 exact 검색 결과를 기준으로 recall과 필터 적용 후 반환 개수를 비교한다. 하드필터는 항상 SQL WHERE에 남기며, ANN이 후보를 적게 반환하더라도 금지 조건을 완화하지 않는다.
검색 문서는 product_semantics와 상품 사실을 고정된 순서로 조립한다. 없는 값을 채우지 않고, 배열은 정렬과 중복 제거 후 넣는다.
예시 상품은 티타늄 이중벽 캠핑 머그컵이다.
[content]
상품 유형: 캠핑용 머그컵
카테고리: 캠핑식기
소재: 티타늄
특징: 경량, 보온, 내구성
관련 관심사: 캠핑, 커피, 백패킹
[usage]
사용 상황: 캠핑장에서 커피나 차를 마실 때
사용 상황: 무게를 줄여야 하는 야외 활동
관련 관심사: 캠핑커피, 백패킹
[gift]
어울리는 대상: 캠핑과 커피를 즐기는 사람
선물 특성: 오래 사용할 수 있는 장비, 실용적인 취미용품
선택 위험: 사이즈 선택 없음, 설치 없음
| 문서 | 반드시 넣는 값 | 넣지 않는 값 |
|---|---|---|
content |
상품 유형, 카테고리, 객관적 특징, 소재 | 구매를 유도하는 마케팅 문구 |
usage |
검증된 사용 상황, 관련 관심사 | 원본에 없는 사용법 추측 |
gift |
대상 취향, 선물 특성, 선택 위험 | "누구나 좋아한다" 같은 근거 없는 표현 |
content는 필수다. usage·gift는 해당 구조화 값이 없으면 벡터를 만들지 않는다. 빈 문서를 억지로 임베딩하면 의미 없는 벡터가 검색된다.
검색 문장은 문자열 배열이 아니라 출처와 목적을 가진 객체다.
{
"queryId": "q_12",
"sourceSignalKey": "hobbies:캠핑장에서-핸드드립:2026-09-02T14:32:00Z",
"sourceField": "hobbies",
"sourceValue": "캠핑장에서 핸드드립을 즐김",
"mode": "both",
"intentType": "both",
"viewType": "usage",
"queryText": "캠핑장에서 핸드드립 커피를 만들고 마실 때 사용하는 제품",
"tasteNodeIds": ["outdoor.camping.camping_coffee"],
"strategy": "direct",
"weight": 0.91,
"hardFilters": {
"allergens": [],
"excludeStrongScent": true,
"excludeProductIds": []
}
}| 필드 | 역할 |
|---|---|
sourceSignalKey |
어떤 취향 항목에서 나온 검색인지 추적한다. API 필드가 아니라 field + value + firstSeenAt으로 만든 잡 내부 키다 |
mode |
self·gift·both 중 어느 목록에 점수를 줄지 정한다 |
viewType |
같은 종류의 상품 검색 문서와 비교한다 |
strategy |
direct·taxonomy_expand·llm_rewrite를 구분한다 |
weight |
원본 ProfileItem.confidence를 전달한다 |
hardFilters |
의미 검색과 독립적으로 반드시 적용할 조건이다 |
하드필터를 queryText에 섞지 않는다.
나쁜 예
향이 강하지 않고 견과류가 없으며 캠핑에서 커피를 마실 때 좋은 선물
좋은 예
queryText = 캠핑에서 커피를 즐기는 사람에게 어울리는 선물
hardFilter = 강한 향 제외 · 견과류 알레르기 제외
검색문 재작성 모델은 hardFilters, sourceValue, confidence를 바꿀 권한이 없다. 모델은 queryText·viewType·tasteNodeIds 후보만 구조화 출력하고, 서버가 원본 프로필에서 나머지를 다시 채운다. tasteNodeIds도 현재 활성 노드와 원본 taxonomyPath 범위를 벗어나면 버린다.
세 처리는 모두 자연어를 다루지만 발생 시점과 변경할 수 있는 값이 다르다. 하나의 "재작성"으로 묶으면 사용자가 거부한 취향을 검색 단계에서 다시 넣거나, 결과 수를 채우려고 안전 조건을 완화할 수 있다.
| 용어 | 발생 시점 | 목적 | 보호 규칙 |
|---|---|---|---|
| 프로필 정정 | 사용자가 분석 결과를 거부하거나 수정할 때 | 취향 데이터 자체를 바로잡는다 | 거부된 항목을 모델이 임의로 복원하지 않는다 |
| 검색 쿼리 생성·확장 | 프로필 확정 후, 최초 검색 전에 | 같은 취향을 상품 검색에 적합하게 표현한다 |
sourceValue · confidence · mode · 하드필터 |
| 검색 부족 폴백 | 최소 유사도를 통과한 후보가 목표 개수보다 적을 때 | 의미와 안전 조건을 유지하면서 다른 표현으로 재검색한다 | 알레르기 · 기피 · 보유 · 가격 조건과 최소 유사도 |
프로필 정정은 검색 품질 문제가 아니다. 사용자가 분석 결과에서 항목을 삭제·수정·추가하면 프로필 추출 단계가 새 버전을 만들고 다시 확인받는다. 이 문서의 검색 파이프라인은 사용자가 확정한 최신 프로필만 입력으로 받으며, 거부된 값을 동의어나 유사 취향으로 바꾸어 되살리지 않는다. 개인화된 취향 분석 자체를 원하지 않는 경우에는 프로필을 만들지 않으며, 비개인화 목록을 제공할지는 별도 제품 정책으로 정한다.
검색 쿼리 생성·확장은 확정된 취향의 의미를 바꾸지 않고 상품 문서와 비교하기 좋은 표현으로 투영하는 전처리다. 예를 들어 "주말에 밖에서 커피를 마신다"를 "야외에서 커피를 만들고 마실 때 사용하는 제품"으로 바꿀 수 있지만, 사용자에게 없던 등산 취향이나 선물 의도를 추가할 수는 없다.
검색 부족 폴백은 다음 순서로 수행한다.
-
direct로 검색한다. - 결과가 부족하면 같은 TasteNode의 활성 별칭을 사용한
taxonomy_expand로 다시 검색한다. - 그래도 부족하면 원본 신호와 서버 소유 필드를 고정한
llm_rewrite로 다시 검색한다. 이 단계는 E1과 금지 위반 평가를 통과한 경우에만 운영에서 사용한다. - 각 시도의 후보를
productId로 합치고 중복을 제거한 뒤 동일한 최소 유사도와 하드필터를 적용한다. - 끝까지 부족하면 관련 없는 상품으로 20개를 채우지 않고 통과한 상품만 반환한다. 후보가 없으면
items: []와emptyReason을 반환한다.
폴백 쿼리는 원래 쿼리를 대체하는 동일 신호의 다른 표현이며 쿼리 수를 무한히 늘리는 수단이 아니다. 부족이 발생한 tasteNodeId, 필터 통과 수, 최소 유사도 통과 수를 카탈로그 커버리지 로그에 남겨 다음 상품군 수집 근거로 사용한다.
| 전략 | 만드는 방법 | 예 | 역할 |
|---|---|---|---|
direct |
사용자 표현을 최소 정리 | 캠핑장에서 핸드드립 | 기준선 · 모델 실패 시 비교 대상 |
taxonomy_expand |
활성 TasteNode 별칭을 추가 | 캠핑커피, 야외 커피 | 정규화 표현 보강 |
llm_rewrite |
상품 검색 목적의 문장으로 변환 | 캠핑에서 커피를 만들고 마실 때 쓰는 제품 | 암묵적 사용 맥락 보강 |
최초 구현은 direct로 기준선을 만든다. 4단계가 배치한 검색문 재작성 모델은 llm_rewrite 실험에 사용한다. 골드셋에서 direct보다 검색 품질이 높고 금지 위반이 늘지 않을 때만 기본 전략으로 전환한다.
프로필의 query 역할 항목은 최대 12개지만 파생 쿼리는 초기 maxQueries = 6으로 제한한다. 확신도만으로 자르면 한 모드의 신호가 사라질 수 있으므로 다음 순서로 고른다.
-
self에만 유효한 신호 중 가장 높은 항목 -
gift에만 유효한 신호 중 가장 높은 항목 - 두 모드에 유효한 신호
- 남은 슬롯을 확신도 순으로 채움
해당 신호가 없는 단계는 건너뛴다. 같은 sourceSignalKey와 viewType 조합은 하나만 만든다. 문장만 조금 다른 쿼리를 여러 개 만들어 후보 수를 부풀리지 않는다.
mode는 모델이 아니라 서버가 intentType과 필드에서 파생한다.
| 조건 | SearchQuery.mode |
|---|---|
intentType == need 또는 consumables
|
self |
intentType == want, wants, unaffordable, deferralSignal == true
|
gift |
intentType == both |
both |
| 프로필 신호 | 우선 view | 이유 |
|---|---|---|
| 관심사·원하는 물건 | content |
상품 종류와 직접 연결된다 |
| 취미·구체적 활동 | usage |
무엇을 하는 상황인지가 중요하다 |
| 구매 보류·받고 싶은 것 | gift |
선물 대상과 맥락이 중요하다 |
선호·생활 방식 (weight) |
검색 쿼리로 만들지 않음 | 후보를 데려오지 않고 랭킹 보조로만 쓴다 |
기피·제약·보유 (filter) |
검색 쿼리로 만들지 않음 | SQL 하드필터로 처리한다 |
한 항목이 여러 view에 걸칠 수 있어도 최초 검색은 가장 직접적인 view 하나만 사용한다. 다중 view 팬아웃은 골드셋에서 recall 부족이 확인될 때 추가한다.
카탈로그 배치는 사용자 요청과 무관하게 batch에서 돈다.
flowchart LR
C["① 상품 크롤링"] --> N["② 식별·정규화<br/>중복 판정"]
N --> X["③ 의미·필터 속성 추출<br/>구조화 출력"]
X --> V["④ 근거·스키마 검증"]
V --> T["⑤ 검색 문서 렌더링<br/>content·usage·gift"]
T --> E["⑥ 임베딩 배치"]
E --> W["⑦ PostgreSQL 저장"]
W --> Q["⑧ 골드셋 검색 회귀"]
V -.검증 실패.-> H["검수 대기"]
Q -.회귀.-> B["활성 공간 전환 중단"]
| 단계 | 입력 | 출력 | 실패하면 |
|---|---|---|---|
| ① 크롤링 | 수집 대상 URL | 원본 상품 | 재시도 후 해당 상품 보류 |
| ② 식별·정규화 | 원본 상품 | catalogProduct |
중복 후보를 검수 대기로 보냄 |
| ③ 속성 추출 | 상세·허용된 리뷰 | 의미 JSON · 필터 JSON | 해당 파생값을 저장하지 않음 |
| ④ 검증 | 구조화 속성 + 원본 | 검증된 속성 | 안전 속성은 unknown, 나머지는 검수 대기 |
| ⑤ 문서 생성 | 상품 사실 + 의미 속성 | 검색 문서 최대 3개 | 상품 원본은 유지 |
| ⑥ 임베딩 | 검색 문서 | 1024차원 벡터 | 해당 문서를 검색에서 제외 |
| ⑦ 저장 | 원본·파생값·벡터 | 활성 카탈로그 | 트랜잭션 롤백 |
| ⑧ 회귀 검사 | 평가 쿼리 | 검색 지표 | 새 임베딩 공간을 활성화하지 않음 |
파생값은 원본보다 먼저 활성화하지 않는다. 상품 행을 먼저 저장하고, 필수 content 문서와 벡터가 준비된 상품만 추천 검색의 active 대상으로 둔다.
{
"productType": "티타늄 캠핑 머그컵",
"features": ["경량", "이중벽", "보온"],
"matchedInterests": ["캠핑", "커피", "백패킹"],
"usageContexts": [
"캠핑장에서 커피나 차를 마실 때",
"무게를 줄여야 하는 야외 활동"
],
"giftTraits": [
"오래 사용할 수 있는 취미 장비",
"사이즈 선택이 필요 없는 선물"
],
"filters": {
"allergens": [],
"allergenStatus": "unknown",
"materials": ["티타늄"],
"scentLevel": "none",
"needsSize": false,
"needsInstall": false,
"containsAlcohol": false,
"isConsumable": false
}
}JSON 스키마 준수는 의미 정확성을 보장하지 않는다. 추출 모델과 프롬프트는 필드별 정확도와 원본 근거 일치율로 검증한다. 특히 알레르기·주류 여부 같은 안전 필드는 모델 출력만으로 verified가 되지 않는다.
하드필터를 적용한 상품 중에서 쿼리와 가까운 검색 문서를 찾는다.
SELECT
p.product_id,
p.title,
d.view_type,
1 - (d.embedding <=> :query_embedding) AS similarity
FROM product_search_document AS d
JOIN catalog_product AS p
ON p.product_id = d.product_id
JOIN product_filter_feature AS f
ON f.product_id = p.product_id
WHERE d.embedding_space_id = :embedding_space_id
AND d.view_type = :view_type
AND p.status = 'active'
AND p.in_stock = TRUE
AND p.product_id <> ALL(:exclude_product_ids)
AND (
cardinality(:user_allergens) = 0
OR (
f.allergen_status = 'verified'
AND NOT (f.allergens && :user_allergens)
)
)
AND (
:exclude_strong_scent = FALSE
OR f.scent_level <> 'strong'
)
ORDER BY d.embedding <=> :query_embedding
LIMIT :top_k;topK의 초기값은 쿼리당 80으로 둔다. 최대 6개 쿼리에서 중복을 제거하면 4단계의 목표 후보 300~500개에 접근한다는 가정이다. 실제 중복률과 골드셋 recall을 측정한 뒤 조정하며, 80 자체를 제품 요구사항으로 고정하지 않는다.
같은 상품이 여러 쿼리에 걸리면 하나만 남기되 근거는 버리지 않는다.
def merge_candidates(matches: list[VectorMatch]) -> dict[str, Candidate]:
candidates = {}
for match in matches:
candidate = candidates.setdefault(
match.product_id,
Candidate(product=match.product, matched_signals=[]),
)
candidate.matched_signals.append(
MatchedSignal(
query_id=match.query_id,
source_signal_key=match.source_signal_key,
view_type=match.view_type,
similarity=match.similarity,
confidence=match.query_weight,
)
)
return candidates초기 랭킹은 사용자가 정한 대로 벡터 유사도를 중심으로 둔다. 별도의 판매량·리뷰·학습 랭커를 이 단계에 추가하지 않는다.
matchScore = cosineSimilarity × ProfileItem.confidence
selfScore = self 또는 both 쿼리에서 얻은 matchScore의 최댓값
giftScore = gift 또는 both 쿼리에서 얻은 matchScore의 최댓값
여러 취향에 동시에 걸린 횟수는 동점 정렬에만 사용한다. 아직 가중치로 더하지 않는다. 근거가 없는 임의 계수가 유사도보다 결과를 더 크게 바꾸는 것을 막기 위해서다.
def rank(candidate: Candidate, mode: str) -> tuple[float, int]:
eligible = [
signal for signal in candidate.matched_signals
if signal.mode in (mode, "both")
]
if not eligible:
return (0.0, 0)
score = max(s.similarity * s.confidence for s in eligible)
return (score, len(eligible))취향 항목이 N개라고 20 / N개를 먼저 배정하지 않는다. 다음 순서로 선별한다.
- 모드별 점수가 있는 후보를 점수 내림차순으로 정렬한다.
- 각 유효 쿼리의 최고 점수 상품이 최소 한 번 검토되도록 1차 순회한다.
- 같은 상품을
productId로 중복 제거한다. - 남은 슬롯은 전체 점수 순으로 채운다.
- 한 쿼리나 한 상품 카테고리에 결과가 과도하게 몰리면 다양성 상한을 적용한다.
- 정확한 상한은 골드셋의 relevance와 다양성 지표를 함께 보고 확정한다.
self와 gift는 같은 후보 풀을 공유하지만 다른 쿼리 집합으로 점수를 계산한다. 두 리스트 사이의 상품 중복은 허용한다. 본인에게 필요하면서 선물로도 받고 싶은 상품이 존재하기 때문이다.
결과 부족은 앞의 검색 부족 폴백으로 처리한다. 폴백에서도 안전 필터, 최소 유사도, 원본 취향의 의미를 완화하지 않는다. 온디맨드 상품 수집은 이번 초안의 범위에서 제외하며, 폴백 뒤에도 후보가 없으면 기존 계약대로 items: []와 emptyReason을 반환한다.
추천 이유 모델은 상품을 선택하지 않는다. 최종 상품과 검색 근거를 자연어로 렌더링한다.
{
"mode": "gift",
"query": {
"queryId": "q_12",
"viewType": "usage",
"tasteNodeLabels": ["캠핑커피"]
},
"product": {
"productId": "p_1234",
"title": "티타늄 더블월 머그컵 350ml",
"category": "캠핑식기",
"brand": "Example",
"features": ["티타늄", "경량", "보온"],
"usageContexts": ["캠핑장에서 커피나 차를 마실 때"]
},
"matchedSignals": [
{
"tasteNodeLabel": "캠핑커피",
"similarity": 0.87,
"viewType": "usage"
}
]
}| 입력 | self |
gift |
|---|---|---|
사용자 취향 원문 value
|
가능 | 전달하지 않음 |
사용자 발화 근거 evidence
|
가능 | 전달하지 않음 |
| 취향 택소노미 라벨 | 전달 | 전달 |
| 상품 사실·특징 | 전달 | 전달 |
| 검증된 사용 맥락 | 전달 | 전달 |
| 검색 유사도·view | 전달 | 전달 |
모델 출력의 상품명·특징·취향 표현은 입력 값 안에 존재해야 한다. 후검증에 실패하면 기본 문구로 대체한다. 상품 검색 문서 전체를 프롬프트에 넣지 않는다. 검색 문서에는 모델이 읽을 필요가 없는 중복 표현이 있고, 최종 근거는 구조화된 상품 사실과 matchedSignals로 충분하다.
골드셋의 한 건은 취향 단어 하나가 아니라 프로필 + 추천 모드 + 하드필터 + 관련 상품 집합이다.
{
"caseId": "gold_001",
"profile": {
"hobbies": ["캠핑장에서 핸드드립"],
"preferences": ["가벼운 장비"],
"dislikes": ["강한 향"],
"owned": ["보온 텀블러"]
},
"mode": "gift",
"expectedTasteNodes": ["outdoor.camping.camping_coffee"],
"relevance": {
"p_10": 3,
"p_11": 3,
"p_21": 2,
"p_22": 1
},
"forbidden": {
"p_40": "strong_scent",
"p_41": "already_owned_non_consumable"
}
}| 라벨 | 의미 |
|---|---|
| 3 | 취향과 모드에 매우 적합 |
| 2 | 적합 |
| 1 | 약하게 관련됨 |
| 0 | 무관 |
forbidden |
관련도와 무관하게 반환하면 안 됨 |
추천은 정답 하나가 없으므로 단일 expectedProductId를 두지 않는다. 금지 상품은 관련도 0과도 다르다. 유사도가 아무리 높아도 하드필터가 막아야 한다.
초기 골드셋은 확정된 L1 취향 영역을 모두 포함하고, 각 영역에서 다음 유형을 나눈다.
| 유형 | 예 | 확인하는 것 |
|---|---|---|
| 직접 취향 | 캠핑을 좋아함 |
content 검색 기준선 |
| 구체적 취향 | 캠핑장에서 핸드드립 |
usage 문서의 효과 |
| 암묵적 맥락 | 주말마다 야외에서 시간을 보냄 | 쿼리 재작성의 필요성 |
| 복합 취향 | 캠핑과 커피를 함께 즐김 | 여러 신호의 후보 병합 |
| 원하는 상품 | 휴대용 핸드밀을 사고 싶음 | 상품 유형 직접 연결 |
| 구매 보류 | 좋은 그라인더는 비싸서 미룸 |
gift 모드의 차이 |
| 기피 | 강한 향을 싫어함 | 정확 필터 |
| 안전 제약 | 견과류 알레르기 | 금지 위반 0건 |
| 생활 맥락 | 원룸에서 생활함 |
weight 신호의 한계 |
| 보유 | 텀블러는 이미 많음 | 비소모품 제외 |
self와 gift, 쉬운 표현과 긴 꼬리 표현, 단일 취향과 복합 취향을 나눠 결과를 본다. 초기 케이스 수는 택소노미와 상품 범위가 정해진 뒤 산정한다. 임의로 전체 개수부터 고정하지 않는다.
현재 검색기가 찾은 결과만 라벨링하지 않는다. 다음 후보를 합친다.
- direct query의 상위 결과
- taxonomy 확장 쿼리의 상위 결과
- LLM rewrite 쿼리의 상위 결과
-
content·usage·gift각 view의 상위 결과 - 개발자가 직접 고른 명확한 관련 상품
- 의미는 비슷하지만 모드가 맞지 않는 hard negative
- 알레르기·향·보유 조건을 위반하는 금지 상품
이 후보 풀을 개발자가 관련도 0~3과 forbidden으로 라벨링한다. description·query 튜닝에 쓰는 개발 세트와 최종 비교에 쓰는 평가 세트를 분리한다.
| 실험 | 상품 검색 문서 | 쿼리 | 답하는 질문 |
|---|---|---|---|
| B0 | content |
direct |
최소 구조의 기준선은 어디인가 |
| E1 | content |
llm_rewrite |
쿼리 재작성만으로 recall이 오르는가 |
| E2 | content + usage |
direct |
사용 맥락 문서가 구체적 취향에 필요한가 |
| E3 | content + gift |
view별 direct | 선물 맥락이 gift 결과를 개선하는가 |
| E4 | content + usage + gift |
view별 rewrite | 추가 복잡성이 전체 품질을 높이는가 |
| E5 | 채택된 문서 | exact vs HNSW | 검색 지연 이득과 recall 손실은 얼마인가 |
한 실험에서는 description 또는 query 중 한쪽만 바꾼다. E4는 앞 실험에서 각각 채택된 변경을 합쳤을 때 상호작용을 보는 마지막 실험이다.
| 지표 | 의미 | 채택 조건 |
|---|---|---|
| Recall@K | 관련 상품을 후보 풀에 데려왔는가 | B0 이상 |
| nDCG@20 | 관련도가 높은 상품이 위에 있는가 | B0 이상 |
| 금지 위반 | 알레르기·기피·보유 조건을 어겼는가 | 0건 |
| 없는 상품 반환 | 카탈로그에 없는 ID가 나왔는가 | 0건 |
| 모드 적합도 | self와 gift 의도에 맞는가 | B0 이상 |
| 쿼리 중복률 | 같은 의미의 파생 쿼리가 반복되는가 | 증가하지 않음 |
| 후보 고유 비율 | 합집합 뒤 서로 다른 상품이 충분한가 | 측정 후 기준 확정 |
| 검색 p95 | 사용자와 무관한 잡의 처리량을 방해하는가 | 2단계 측정과 함께 판정 |
| 이유 근거 위반 | 입력에 없는 상품·취향 사실을 썼는가 | 0건 |
구조적 금지 위반은 품질 평균으로 상쇄하지 않는다. Recall이 올라도 알레르기 위반이 한 건 생기면 그 구성을 채택하지 않는다.
추천 결과가 나빠졌을 때 원인을 구분할 수 있도록 잡 내부에 다음을 남긴다. 대화 원문 전체는 남기지 않는다.
catalogSnapshotVersion
queryStrategyVersion
queryId · sourceSignalKey · viewType
embeddingModelVersion · documentSchemaVersion
topK · similarity
matchedProductIds · filteredCounts
rankerVersion
reasonPromptVersion
latencyMs · retryCount
async def ingest_product(raw: CrawledProduct, space: EmbeddingSpace) -> None:
product = catalog.normalize(raw)
semantics = await extractor.extract_product(raw, schema=PRODUCT_SCHEMA)
filters = validator.verify_filters(raw, semantics.filters)
documents = document_builder.render(
product=product,
semantics=semantics,
version=space.document_schema_version,
)
vectors = await embedder.embed_batch(
[doc.text for doc in documents],
model=space.model_name,
)
with database.transaction():
catalog.upsert(product)
catalog.save_semantics(product.id, semantics)
catalog.save_filters(product.id, filters)
catalog.replace_documents(product.id, space.id, documents, vectors)
catalog.activate_if_searchable(product.id)async def build_search_queries(profile: TasteProfile) -> list[SearchQuery]:
seeds = policy.select_query_items(profile, limit=6)
queries = await rewriter.derive(seeds, schema=SEARCH_QUERY_SCHEMA)
return [
policy.restore_server_owned_fields(query, seed)
for query, seed in zip(queries, seeds)
]restoreServerOwnedFields는 모델 출력의 sourceValue, confidence, hardFilters를 버리고 프로필 원본에서 다시 채운다. 모델이 바꿀 수 있는 것은 검색 문장과 view 후보뿐이다.
async def retrieve_lists(profile: TasteProfile) -> dict[str, RecoList]:
queries = await build_search_queries(profile)
vectors = await embedder.embed_batch([q.query_text for q in queries])
matches = []
for query, vector in zip(queries, vectors):
matches.extend(
catalog.search(
vector=vector,
view_type=query.view_type,
filters=query.hard_filters,
top_k=80,
)
)
candidates = merge_candidates(matches)
lists = {}
for mode in ("self", "gift"):
ranked = ranking.by_similarity(candidates, mode)
picked = ranking.diversify(ranked, limit=20)
reasons = await reasoner.per_query(picked, queries, mode)
lists[mode] = RecoList(items=picked, reasons=reasons)
return lists실제 임베딩 호출은 4단계 결정대로 한 번의 배치 요청이다. 의사코드의 검색 반복은 외부 API 호출이 아니라 PostgreSQL 읽기다. 상품 1만 개 규모에서 쿼리 병렬화가 필요한지는 실행 계획과 처리량을 보고 정한다.
| 구분 | 선택 | 역할 | 선택 이유와 기대 효과 |
|---|---|---|---|
| 상품 속성 추출 | 구조화 출력이 가능한 추출 모델 | 크롤링 원본 → 의미·필터 JSON | 자유문장보다 검증·재생성이 쉽다 |
| 검색 문장 재작성 | 4단계 응답 모델 | ProfileItem → SearchQuery | 암묵적 사용 맥락을 상품 검색 표현으로 바꾼다. 채택은 E1로 결정한다 |
| 임베딩 | Upstage Embed 2 solar-embedding-2, 1024차원 |
상품 문서와 쿼리를 같은 공간에 투영 | 자체 GPU를 점유하지 않고 배치 호출할 수 있다 |
| 관계형·벡터 저장 | PostgreSQL + pgvector | 상품 사실, 필터, 택소노미, 벡터 | 하드필터와 벡터 정렬을 한 SQL 계약 안에 둔다 |
| 세션·임베딩 캐시 | Redis | 반복 쿼리 벡터 캐시 | 사용자 사이에서 반복되는 검색 표현의 API 호출을 줄인다 |
| 검색 조립 | 직접 작성한 SQL과 Python 함수 | 검색·병합·선별 | 서비스 고유 필터와 근거 누적을 명시적으로 통제한다 |
| 측면 | 도입 전 | 도입 후 |
|---|---|---|
| 상품 최신성 | 모델 또는 외부 검색 결과에 의존 | 카탈로그 배치와 crawledAt으로 관리 |
| 추천 범위 | 모델이 아는 일반 상품 | 실제 저장된 구매 가능 상품 |
| 취향 연결 | 상품명 키워드 중심 | content·usage·gift 의미로 연결 |
| 안전 조건 | 프롬프트가 지키기를 기대 | 구조화 상품 속성과 SQL 필터로 강제 |
| 추천 근거 | 모델이 사후에 설명 |
queryId와 matchedSignals에서 실제 근거 추적 |
| 모델 환각 | 카탈로그 밖 상품 가능 | 선택권을 서버에 두어 없는 상품 반환 차단 |
| 교체 | 표현과 벡터가 한 덩어리 | 원본·문서·임베딩 버전을 분리해 재생성 가능 |
| 품질 개선 | 결과가 좋아 보이는지 주관적으로 판단 | 골드셋과 Recall@K·nDCG@20으로 비교 |
검색 증강을 도입하는 이유는 LLM 입력을 길게 만드는 것이 아니다. 모델이 추천할 수 있는 상품을 실제 카탈로그로 제한하고, 취향과 상품 사이의 근거를 검색 단계에서 만든 뒤, 그 근거만 자연어로 변환하기 위해서다.
report.md 5단계 제출 항목 |
이 문서의 대응 |
|---|---|
| 전체 데이터 흐름도 | 설계 결론 프리뷰의 오프라인·온라인 다이어그램 |
| 데이터·지식 소스와 선택 이유 | 데이터와 지식 소스 · 두 개의 카탈로그 체계 |
| 검색·임베딩·인덱싱 구현과 모델 통합 | 상품 저장 스키마 · 검색 쿼리 · 온라인 검색 SQL · 모델 입력 보강 · 의사코드 |
| 도입 전후 효과와 검증 계획 | 골드셋 · 검증 계획 · 도입 전후 예상 효과 |
니쥬는 텍스트 기반 추천 서비스이므로 report.md가 예시로 든 Visual RAG·Speech RAG·RA-IS는 적용 대상이 아니다. 대신 상품 텍스트와 구조화 속성을 검색해 추천 후보와 생성 근거를 보강하는 구조로 같은 과제 목적을 충족한다.
| 항목 | 영향 |
|---|---|
POST /v1/recommendations/jobs |
요청·응답 계약 변경 없음. 잡 내부 검색 구현만 구체화 |
POST /v1/recommendations |
요청·응답 계약 변경 없음 |
items[].matchedSignals |
queryId, viewType, similarity의 출처가 명확해짐 |
funnel |
쿼리별 검색 수와 중복 제거 후 후보 수를 내부 추적값에 추가 |
GET /v1/catalog/coverage |
취향 노드별 활성 상품과 가격대 분포를 집계할 저장 근거가 생김 |
| 카탈로그 배치 | 크롤링 뒤 속성 추출·검증·문서 생성·임베딩 단계가 확정됨 |
4단계의 "검색문 재작성 실패 시 잡 실패" 정책은 direct query를 운영 폴백으로 채택할지에 따라 달라질 수 있다. 이 초안에서는 direct를 품질 기준선으로만 확정하고, 운영 폴백 여부는 재작성 실험과 장애 테스트 후 결정한다. 결정되기 전까지 기존 실패 계약을 유지한다.
| 넘기는 것 | 왜 여기서 정하지 않는가 | 어디서 |
|---|---|---|
| 상품 수집처 API·인증·호출 규약 | 데이터 스키마와 별개인 외부 도구 통합 문제다 | 6단계 |
| 모델이 외부 상품 도구를 직접 고르는 경로 | 도입하면 모델 주도 도구 루프가 생긴다 | 6단계 |
| 카탈로그·검색·임베딩 지표의 대시보드와 경보 | 무엇을 기록할지는 정했지만 수집 인프라는 별도다 | 7단계 |
| PostgreSQL·Redis 용량과 백업·복구 | 배포 스펙과 운영 정책 문제다 | 7단계 |
| HNSW 도입과 튜닝 | exact 검색 실측에서 병목이 확인된 뒤 결정한다 | 7단계 성능 검증 |
| 항목 | 확정 방법 |
|---|---|
| 상품 수집처와 이용 가능한 필드 | 수집 API·사이트 정책 확인 |
| 초기 상품군과 상품 수 | 서비스 우선 취향 영역과 카탈로그 커버리지로 산정 |
상품 중복 판정 canonicalKey
|
수집처의 브랜드·모델·옵션 식별자 확인 |
| 상품 알레르기 정보의 신뢰 가능한 출처 | 판매처 표기와 검수 가능 범위 확인 |
| 리뷰 원문 사용·보관 여부 | 수집 권한과 개인정보·저작권 검토 |
| TasteNode L1·L2 시드 | 초기 상품군과 대화 샘플을 함께 검토 |
usage·gift 벡터 채택 여부 |
E2·E3 결과 |
| 검색문 재작성 기본 전략 | E1 결과 |
topK = 80과 maxQueries = 6
|
후보 고유 비율과 Recall@K 측정 |
| 최소 유사도 | 골드셋의 관련·무관 점수 분포로 설정 |
| 다양성 상한 | 관련도 손실과 카테고리·신호 분포를 함께 비교 |
| direct query의 운영 폴백 사용 여부 | 재작성 장애 테스트와 품질 비교 |
| 임베딩 공간 전환·롤백 절차 | 초기 전체 재색인 리허설 후 확정 |