데이터 컨텍스트 보강 설계 - 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")]
Loading

설계 이유

  • 상품 원본은 검색 쿼리나 임베딩 모델이 바뀌어도 유지되는 사실이다.
  • 검색 문서에는 생성 규칙과 버전을 남긴다. 표현을 바꿔도 상품을 다시 크롤링하지 않고 문서와 벡터만 재생성한다.
  • LLM이 상품별 자유문장을 바로 쓰면 표현 편차와 추측이 섞인다. 먼저 구조화 속성을 만들고 고정 템플릿으로 검색 문서를 조립한다.
  • 하드필터 속성은 벡터에 숨기지 않는다. 알레르기처럼 반드시 지켜야 하는 값은 PostgreSQL 컬럼으로 검사한다.
2. 상품과 쿼리를 같은 의미 축으로 투영한다 — 다이어그램과 설계 이유

description과 query 중 하나에 다른 하나를 맞추지 않는다

먼저 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"]
Loading

설계 이유

  • 현재 쿼리 문장에 맞춰 상품 설명을 쓰면 쿼리 프롬프트를 바꿀 때 카탈로그가 함께 무효가 된다.
  • 상품 설명을 먼저 고정하고 쿼리만 반복해서 고도화하면 설명에 없는 사용 맥락과 선물 의미를 검색할 수 없다.
  • 두 표현이 공유할 의미 축을 먼저 정하면 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"]
Loading

설계 이유

  • LLM은 카탈로그에 없는 상품을 만들 수 있으므로 상품 ID 선택권을 주지 않는다.
  • 벡터 검색은 후보 생성이다. 최종 20개는 여러 쿼리의 후보를 합치고 중복을 제거한 뒤 선별한다.
  • 취향 항목마다 20 / N개를 미리 배정하지 않는다. 약한 취향이 강한 취향과 같은 슬롯을 차지하고, 여러 취향에 걸린 상품을 처리하기 어렵기 때문이다.
  • 추천 이유 모델에는 최종 상품과 실제 매칭 근거만 준다. 검색 결과 전체나 대화 원문을 넘기지 않는다.
4. 검색 표현은 골드셋으로 고른다 — 검증 흐름과 설계 이유

description과 query를 동시에 바꾸지 않는다

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["개선된 구성만 채택"]
Loading

설계 이유

  • 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차원 벡터 모델·문서 버전 변경 불필요. 검색 문서에서 재계산

catalog_product

크롤링한 상품의 정체성과 화면 표시 값을 보관한다.

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는 같은 상품이 여러 판매처에서 수집되어도 피드백과 추천 이력이 하나의 내부 상품을 가리키게 하기 위한 키다. 동일 상품 판정 규칙은 수집처가 정해진 뒤 확정한다.

product_semantics

검색 문서를 만들기 위한 구조화 의미다. 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는 상품 원본 또는 허용된 리뷰에서 근거를 찾을 수 있는 값만 저장한다.

product_filter_feature

벡터 유사도가 아니라 정확 조건으로 처리할 값이다.

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로 올린다.

taste_node와 product_taste_node

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을 구분한다. 모델이 만든 매핑과 개발자가 확인한 매핑을 같은 신뢰도로 다루지 않는다.

embedding_space

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까지 하나의 임베딩 공간으로 묶는다.

product_search_document

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 · 하드필터
검색 부족 폴백 최소 유사도를 통과한 후보가 목표 개수보다 적을 때 의미와 안전 조건을 유지하면서 다른 표현으로 재검색한다 알레르기 · 기피 · 보유 · 가격 조건과 최소 유사도

프로필 정정은 검색 품질 문제가 아니다. 사용자가 분석 결과에서 항목을 삭제·수정·추가하면 프로필 추출 단계가 새 버전을 만들고 다시 확인받는다. 이 문서의 검색 파이프라인은 사용자가 확정한 최신 프로필만 입력으로 받으며, 거부된 값을 동의어나 유사 취향으로 바꾸어 되살리지 않는다. 개인화된 취향 분석 자체를 원하지 않는 경우에는 프로필을 만들지 않으며, 비개인화 목록을 제공할지는 별도 제품 정책으로 정한다.

검색 쿼리 생성·확장은 확정된 취향의 의미를 바꾸지 않고 상품 문서와 비교하기 좋은 표현으로 투영하는 전처리다. 예를 들어 "주말에 밖에서 커피를 마신다"를 "야외에서 커피를 만들고 마실 때 사용하는 제품"으로 바꿀 수 있지만, 사용자에게 없던 등산 취향이나 선물 의도를 추가할 수는 없다.

검색 부족 폴백은 다음 순서로 수행한다.

  1. direct로 검색한다.
  2. 결과가 부족하면 같은 TasteNode의 활성 별칭을 사용한 taxonomy_expand로 다시 검색한다.
  3. 그래도 부족하면 원본 신호와 서버 소유 필드를 고정한 llm_rewrite로 다시 검색한다. 이 단계는 E1과 금지 위반 평가를 통과한 경우에만 운영에서 사용한다.
  4. 각 시도의 후보를 productId로 합치고 중복을 제거한 뒤 동일한 최소 유사도와 하드필터를 적용한다.
  5. 끝까지 부족하면 관련 없는 상품으로 20개를 채우지 않고 통과한 상품만 반환한다. 후보가 없으면 items: []와 emptyReason을 반환한다.

폴백 쿼리는 원래 쿼리를 대체하는 동일 신호의 다른 표현이며 쿼리 수를 무한히 늘리는 수단이 아니다. 부족이 발생한 tasteNodeId, 필터 통과 수, 최소 유사도 통과 수를 카탈로그 커버리지 로그에 남겨 다음 상품군 수집 근거로 사용한다.

세 전략을 순서대로 비교한다

전략 만드는 방법 예 역할
direct 사용자 표현을 최소 정리 캠핑장에서 핸드드립 기준선 · 모델 실패 시 비교 대상
taxonomy_expand 활성 TasteNode 별칭을 추가 캠핑커피, 야외 커피 정규화 표현 보강
llm_rewrite 상품 검색 목적의 문장으로 변환 캠핑에서 커피를 만들고 마실 때 쓰는 제품 암묵적 사용 맥락 보강

최초 구현은 direct로 기준선을 만든다. 4단계가 배치한 검색문 재작성 모델은 llm_rewrite 실험에 사용한다. 골드셋에서 direct보다 검색 품질이 높고 금지 위반이 늘지 않을 때만 기본 전략으로 전환한다.

쿼리 수를 무한히 늘리지 않는다

프로필의 query 역할 항목은 최대 12개지만 파생 쿼리는 초기 maxQueries = 6으로 제한한다. 확신도만으로 자르면 한 모드의 신호가 사라질 수 있으므로 다음 순서로 고른다.

  1. self에만 유효한 신호 중 가장 높은 항목
  2. gift에만 유효한 신호 중 가장 높은 항목
  3. 두 모드에 유효한 신호
  4. 남은 슬롯을 확신도 순으로 채움

해당 신호가 없는 단계는 건너뛴다. 같은 sourceSignalKey와 viewType 조합은 하나만 만든다. 문장만 조금 다른 쿼리를 여러 개 만들어 후보 수를 부풀리지 않는다.

mode는 모델이 아니라 서버가 intentType과 필드에서 파생한다.

조건 SearchQuery.mode
intentType == need 또는 consumables self
intentType == want, wants, unaffordable, deferralSignal == true gift
intentType == both both

view 선택

프로필 신호 우선 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["활성 공간 전환 중단"]
Loading
단계 입력 출력 실패하면
① 크롤링 수집 대상 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가 되지 않는다.

온라인 검색과 증강

벡터 검색 SQL

하드필터를 적용한 상품 중에서 쿼리와 가까운 검색 문서를 찾는다.

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))

최종 20개 선별

취향 항목이 N개라고 20 / N개를 먼저 배정하지 않는다. 다음 순서로 선별한다.

  1. 모드별 점수가 있는 후보를 점수 내림차순으로 정렬한다.
  2. 각 유효 쿼리의 최고 점수 상품이 최소 한 번 검토되도록 1차 순회한다.
  3. 같은 상품을 productId로 중복 제거한다.
  4. 남은 슬롯은 전체 점수 순으로 채운다.
  5. 한 쿼리나 한 상품 카테고리에 결과가 과도하게 몰리면 다양성 상한을 적용한다.
  6. 정확한 상한은 골드셋의 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, 쉬운 표현과 긴 꼬리 표현, 단일 취향과 복합 취향을 나눠 결과를 본다. 초기 케이스 수는 택소노미와 상품 범위가 정해진 뒤 산정한다. 임의로 전체 개수부터 고정하지 않는다.

평가 후보를 만드는 방법

현재 검색기가 찾은 결과만 라벨링하지 않는다. 다음 후보를 합친다.

  1. direct query의 상위 결과
  2. taxonomy 확장 쿼리의 상위 결과
  3. LLM rewrite 쿼리의 상위 결과
  4. content·usage·gift 각 view의 상위 결과
  5. 개발자가 직접 고른 명확한 관련 상품
  6. 의미는 비슷하지만 모드가 맞지 않는 hard negative
  7. 알레르기·향·보유 조건을 위반하는 금지 상품

이 후보 풀을 개발자가 관련도 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의 운영 폴백 사용 여부 재작성 장애 테스트와 품질 비교
임베딩 공간 전환·롤백 절차 초기 전체 재색인 리허설 후 확정
⚠️ **GitHub.com Fallback** ⚠️