Casa 가격 검색에서 이미지와 텍스트 유사도 결합하기
Casa 가격 검색에서 이미지와 텍스트 유사도 결합하기
텍스트가 비슷해도 다른 크기나 모델일 수 있고, 이미지가 비슷해도 색상·상태·구성품처럼 가격에 중요한 속성이 다를 수 있다. 서로 분포가 다른 cosine 점수를 바로 가중 합하지 않고, 구조화 신호로 후보를 제한한 뒤 이미지와 텍스트 점수를 보정하고, 존재하는 modality만 정규화해 결합해야 한다. 최종 가중치는 실제 정답 쌍과 hard negative에서 평가한다.
Casa Search Price의 텍스트·이미지 후보 검색 문제를 참고했지만 아래 상품, feature, 가중치와 코드는 설명용으로 재구성했다. 실제 모델 설정이나 가격 추정 규칙을 사용하지 않았다.
목차
- #한 가지 유사도만으로 상품을 찾기 어려운 이유
- #후보 생성과 최종 순위를 분리하기
- #구조화 속성은 점수보다 필터가 나을 때가 있다
- #서로 다른 점수 분포를 바로 더하지 않기
- #결측 modality의 가중치 다시 정규화하기
- #hard rule과 soft score의 경계
- #재구성한 멀티모달 점수 함수
- #이미지 한 장 안의 배경과 상품 분리
- #검색 결과를 가격 evidence로 사용할 때
- #평가셋과 hard negative 만들기
- #가중치 실험과 모델별 품질 보기
- #설명 가능한 결과 메타데이터
- #운영에서 관측할 변화
- #결론
- #관련 노트
한 가지 유사도만으로 상품을 찾기 어려운 이유
중고 상품명은 정규화되지 않은 정보가 섞인다.
브랜드 + 모델명 + 크기 + 색상 + 소재 + 상태 + 판매 문구
텍스트 검색은 모델 코드와 크기처럼 명시된 신호에 강하지만 판매자가 모델명을 생략하거나 오타를 낼 수 있다. 이미지 검색은 형태에 강하지만 같은 디자인의 크기, 소재와 색상 변형을 구분하지 못할 수 있다.
| 신호 | 잘 찾는 것 | 놓치기 쉬운 것 |
|---|---|---|
| 키워드·TF-IDF | 모델 코드, 브랜드, 숫자 | 동의어, 오타, 미기재 속성 |
| 텍스트 embedding | 의미가 비슷한 설명 | 한 글자 모델 코드 차이 |
| 이미지 embedding | 형태, 실루엣, 시각 패턴 | 크기, 연식, 구성품 |
| 구조화 속성 | 브랜드, 모델, 크기, 등급 | 파싱 실패와 누락 |
| 최신 거래 시각 | 현재 시장에 가까운 evidence | 상품 동일성 |
따라서 하나의 벡터가 “가격이 비교 가능한 같은 상품”을 완전히 표현한다고 가정하지 않는다.
시각적으로 비슷한 이미지를 찾는 것과 가격 evidence로 사용해도 되는 같은 모델을 찾는 것은 다른 목표다.
후보 생성과 최종 순위를 분리하기
전체 상품에 비싼 멀티모달 점수를 계산하기보다 두 단계로 나눈다.
flowchart LR
Q[Query] --> T[Text Top-K]
Q --> I[Image Top-K]
Q --> S[Structured Candidates]
T --> U[Candidate Union]
I --> U
S --> U
U --> F[Hard Constraint Filter]
F --> R[Multimodal Rerank]
R --> P[Price Evidence Gate]후보 생성 단계는 recall을 우선한다. 텍스트와 이미지 중 하나가 놓친 후보를 다른 경로가 보완하도록 union을 만든다. rerank는 더 비싼 feature와 규칙으로 precision을 높인다.
candidate_ids = (
text_index.top_k(query.text, k=100)
| image_index.top_k(query.image_feature, k=100)
| attribute_index.lookup(query.structured_attributes)
)
어떤 경로에서 후보가 들어왔는지도 feature로 남긴다. 양쪽 모두에서 상위에 나온 후보는 한쪽에서만 낮게 나온 후보보다 강한 신호일 수 있다.
구조화 속성은 점수보다 필터가 나을 때가 있다
브랜드가 명확한데 다른 브랜드 상품이 이미지가 비슷하다는 이유로 상위에 오르면 가격 비교에 부적합하다. 신뢰도가 높은 구조화 속성은 hard filter로 쓸 수 있다.
def build_candidate_pool(query, items):
pool = items
if query.brand and query.brand_confidence >= 0.95:
pool = [item for item in pool if item.brand == query.brand]
if query.product_code and query.product_code_confidence >= 0.98:
exact = [
item for item in pool
if item.product_code == query.product_code
]
if len(exact) >= 5:
return exact
return pool
항상 hard filter를 쓰면 파싱 오류 하나로 정답을 모두 제거할 수 있다. 신호 신뢰도와 후보 수를 함께 본다.
| 신호 상태 | 처리 |
|---|---|
| 검증된 product code, 충분한 후보 | hard filter |
| 높은 신뢰도의 brand | hard filter 후보 |
| 추정한 model line | score boost |
| 누락된 size | 필터하지 않음 |
| 서로 충돌하는 OCR과 catalog | 양쪽 후보 유지 후 검토 |
서로 다른 점수 분포를 바로 더하지 않기
이미지 cosine 0.72와 텍스트 cosine 0.72가 같은 강도를 뜻하지 않는다. 모델과 데이터셋마다 양성·음성 점수 분포가 다르다.
image positives: 0.62 ~ 0.84
image negatives: 0.48 ~ 0.75
text positives: 0.25 ~ 0.80
text negatives: 0.00 ~ 0.20
원시 점수에 0.6, 0.4를 곱하면 이미지 점수가 대부분의 순위를 지배할 수 있다. 라벨 데이터에서 점수를 확률이나 percentile로 보정한다.
class ScoreCalibrator(Protocol):
def transform(self, raw_score: float) -> float:
"""Return a calibrated value in [0, 1]."""
image_score = image_calibrator.transform(raw_image_cosine)
text_score = text_calibrator.transform(raw_text_cosine)
Platt scaling, isotonic regression이나 구간별 empirical precision을 사용할 수 있다. 중요한 것은 보정 모델의 학습 데이터와 버전을 결과에 남기는 것이다.
라벨이 적을 때는 query 안에서 z-score나 rank normalization을 쓸 수 있지만 query별 후보 분포에 따라 의미가 달라지는 한계가 있다.
결측 modality의 가중치 다시 정규화하기
이미지가 없는 query에서 image_similarity = 0으로 넣으면 좋은 텍스트 후보가 불필요하게 감점된다.
def weighted_available_score(signals: dict[str, float | None]) -> float:
weights = {
"text": 0.40,
"image": 0.40,
"attributes": 0.20,
}
available = {
name: value
for name, value in signals.items()
if value is not None
}
denominator = sum(weights[name] for name in available)
if denominator == 0:
return 0.0
return sum(
weights[name] * value
for name, value in available.items()
) / denominator
다만 이미지가 없을 때와 이미지 처리 실패를 구분한다.
type FeatureState =
| { status: "available"; score: number }
| { status: "not_provided" }
| { status: "unreadable"; reason: string }
| { status: "model_unavailable"; reason: string };
모델 장애로 이미지 신호가 빠졌다면 응답을 degraded로 표시하고, 정상적인 텍스트 전용 query와 지표를 분리한다.
hard rule과 soft score의 경계
구조화 속성을 모두 가중치로만 처리하면 치명적인 불일치가 높은 이미지 점수에 묻힐 수 있다.
def compatibility_gate(query, candidate) -> tuple[bool, list[str]]:
reasons = []
if (
query.brand_verified
and candidate.brand
and query.brand != candidate.brand
):
reasons.append("brand_mismatch")
if (
query.product_code_verified
and candidate.product_code
and query.product_code != candidate.product_code
):
reasons.append("product_code_mismatch")
return len(reasons) == 0, reasons
반대로 색상 차이는 검색 결과에서는 soft penalty일 수 있지만 특정 색상이 가격에 크게 영향을 주는 카테고리에서는 evidence gate가 될 수 있다. 카테고리별 정책을 버전 관리한다.
@dataclass(frozen=True)
class RetrievalPolicy:
category: str
hard_attributes: tuple[str, ...]
soft_weights: dict[str, float]
minimum_evidence_score: float
version: str
재구성한 멀티모달 점수 함수
@dataclass(frozen=True)
class CandidateSignals:
text: float | None
image: float | None
attributes: float | None
recency: float
condition: float | None
@dataclass(frozen=True)
class RankedCandidate:
item_id: int
final_score: float
signals: CandidateSignals
rejected_reasons: tuple[str, ...]
def rank_candidate(query, candidate, policy) -> RankedCandidate:
compatible, rejected = compatibility_gate(query, candidate)
if not compatible:
return RankedCandidate(
item_id=candidate.id,
final_score=0.0,
signals=empty_signals(),
rejected_reasons=tuple(rejected),
)
signals = CandidateSignals(
text=calibrated_text_similarity(query, candidate),
image=calibrated_image_similarity(query, candidate),
attributes=attribute_similarity(query, candidate),
recency=recency_score(candidate.observed_at),
condition=condition_similarity(query, candidate),
)
identity_score = weighted_available_score({
"text": signals.text,
"image": signals.image,
"attributes": signals.attributes,
})
final = identity_score * signals.recency
if signals.condition is not None:
final *= 0.8 + 0.2 * signals.condition
return RankedCandidate(
item_id=candidate.id,
final_score=round(final, 6),
signals=signals,
rejected_reasons=(),
)
recency와 condition을 identity score에 단순 가산할지 곱할지는 목적에 따라 평가한다. 오래됐다는 이유로 상품 동일성이 낮아지는 것은 아니지만 가격 evidence의 유효성은 낮아질 수 있다. 검색 순위와 가격 가중치를 별도 단계로 두는 편이 의미가 선명하다.
이미지 한 장 안의 배경과 상품 분리
중고 상품 사진에는 손, 테이블, 상자와 여러 물건이 함께 있을 수 있다. 전체 이미지를 embedding하면 배경이 유사도를 지배할 수 있다.
처리 단계:
- 이미지 디코딩과 orientation 정규화
- 상품 영역 탐지 또는 segmentation
- 너무 작은 mask와 실패 상태 판정
- 원본 전체와 crop feature를 각각 계산
- 품질 점수에 따라 결합
def image_feature_bundle(image) -> ImageFeatureBundle:
raw = encoder.encode(image)
mask = segmenter.select_primary_object(image)
if mask is None or mask.coverage < 0.10:
return ImageFeatureBundle(
raw=raw,
object_feature=None,
segmentation_status="fallback_raw",
)
cropped = apply_mask_and_crop(image, mask)
return ImageFeatureBundle(
raw=raw,
object_feature=encoder.encode(cropped),
segmentation_status="object_selected",
)
segmentation 실패를 빈 feature로 숨기지 않고 결과 메타데이터에 남긴다. 배경 제거가 모든 카테고리에 좋아진다고 가정하지 말고 라벨별 recall을 비교한다.
검색 결과를 가격 evidence로 사용할 때
상위 20개가 시각적으로 비슷하다는 이유만으로 모두 가격 계산에 넣으면 안 된다. 판매 상태, 통화, 거래 시각, condition과 동일 모델 신뢰도를 추가로 확인한다.
def select_price_evidence(
candidates: list[RankedCandidate],
minimum_score: float,
) -> list[RankedCandidate]:
return [
item for item in candidates
if item.final_score >= minimum_score
and item.sale_price_krw is not None
and item.sale_price_krw > 0
and item.listing_status in {"sold", "verified_available"}
]
검색 recall을 위해 넓게 잡은 후보 집합과 가격 precision을 위해 좁게 고른 evidence 집합을 분리한다.
retrieval top 100
-> reranked top 20
-> price-compatible evidence 8
-> 가격 범위와 신뢰도
가격 하나보다 범위와 근거 수를 표시하는 방법은 Casa 가격 추정값을 범위와 신뢰도로 표현하기에서 이어진다.
평가셋과 hard negative 만들기
무작위 negative는 너무 쉬워 높은 점수를 만들 수 있다. 실제로 헷갈리는 hard negative가 필요하다.
- 같은 브랜드와 형태지만 다른 모델
- 같은 모델의 다른 크기
- 같은 상품이지만 다른 소재나 구성
- 제목은 같지만 이미지가 다른 상품
- 이미지는 같아 보이지만 product code가 다른 항목
- 배경과 촬영 각도가 비슷한 다른 상품
- 같은 판매글에서 파생된 중복 이미지
query_id: query-example-17
positive_ids:
- item-same-model-1
hard_negative_ids:
- item-different-size-1
- item-similar-shape-2
required_attributes:
brand: Example
model_line: Model-A
evaluation_group: rare-model
학습과 평가에 같은 판매글의 다른 이미지가 나뉘면 데이터 누수가 생긴다. listing 또는 상품 그룹 단위로 split한다.
가중치 실험과 모델별 품질 보기
전체 recall만 보면 데이터가 많은 인기 모델이 결과를 지배한다.
| 지표 | 의미 |
|---|---|
| Recall@K | 정답이 상위 K에 있는 비율 |
| MRR | 첫 정답 순위 |
| macro Recall@K | 모델별 recall의 평균 |
| hard-negative rejection | 혼동 후보를 밀어낸 비율 |
| evidence precision | 가격 계산에 들어간 후보의 동일성 |
| no-result rate | gate가 너무 엄격해 결과가 없는 비율 |
for image_weight in [0.0, 0.25, 0.5, 0.75, 1.0]:
metrics = evaluate(
dataset=validation_set,
policy=policy.with_image_weight(image_weight),
)
report(image_weight, metrics)
가중치는 전체 최고값 하나보다 query 유형이나 카테고리별로 달라질 수 있다. 하지만 세분화가 너무 많으면 표본이 부족하고 운영이 복잡해진다. 충분한 데이터가 있는 큰 구간부터 나눈다.
설명 가능한 결과 메타데이터
최종 점수 하나만 저장하면 왜 후보가 위에 왔는지 알 수 없다.
{
"itemId": 731,
"finalScore": 0.812,
"signals": {
"textCalibrated": 0.76,
"imageCalibrated": 0.88,
"attribute": 0.82,
"recency": 0.95
},
"matched": {
"brand": true,
"modelLine": true,
"size": false
},
"versions": {
"textEncoder": "text-example-3",
"imageEncoder": "image-example-5",
"calibrator": "calibration-7",
"policy": "retrieval-12"
}
}
이 정보는 사용자에게 전부 보여 줄 필요는 없지만 오답 조사와 모델 비교에 필요하다. confidence라고 부를 값이 실제 확률로 보정되지 않았다면 score나 relativeConfidence처럼 제한된 이름을 쓴다.
운영에서 관측할 변화
- modality별 feature 생성 성공률
- text/image 후보 집합의 overlap
- 결측 신호별 검색 성공률
- hard filter로 제거된 후보 수
- 모델·카테고리별 Recall@K
- 가격 evidence로 승격된 비율
- 사용자의 결과 선택·거절·수정률
- encoder와 calibrator 버전별 score 분포
- 이미지 처리 지연과 메모리 사용량
encoder를 바꾸면 기존 gallery feature와 query feature의 벡터 공간이 같아야 한다. 일부만 새 버전으로 재색인하면 점수를 비교할 수 없다. index generation과 feature version을 함께 검증한다.
결론
텍스트와 이미지 유사도를 결합하는 일은 두 cosine 값에 고정 가중치를 곱하는 것보다 복잡하다. 각 modality가 잘 찾는 속성과 놓치는 속성이 다르고 점수 분포도 같지 않기 때문이다.
핵심은 텍스트·이미지·구조화 속성으로 recall 높은 후보 집합을 만들고, 신뢰도 높은 속성은 gate로 사용하며, 각 점수를 라벨 데이터로 보정한 뒤 존재하는 신호만 다시 정규화해 순위를 계산하는 것이다.
검색 후보와 가격 evidence의 기준도 분리해야 한다. hard negative와 모델별 macro 지표로 평가하고, 모든 결과에 신호와 feature·정책 버전을 남겨야 가중치 변경이 실제 품질을 높였는지 설명할 수 있다.