본문 바로가기
C.W.K.
Stream
Lesson 04 of 04 · published

같은 사건, 일곱 번 — 신원 대 같음

~12 min · identity, deduplication, projections, measurement

Level 0미분류
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

맞는 신원, 중복된 선반

정본 주소 신원은 맞아. 정리된 주소가 같은 두 행은 같은 기사고, 유일성 제약이 그걸 강제하고, 아무것도 두 번 저장 안 돼. 그런데도 선반은 한 사건을 일곱 번 보여줄 수 있어.

측정값. 하루치 행의 대략 팔 퍼센트가 이미 있는 사건의 반복이었고, 최악은 서로 다른 포장지 주소 일곱 개로 일곱 번 도착한 사건 하나였어. 그 주소 하나하나가 진짜로 구별됐고. 모든 행이 진짜 출처를 통한 진짜 배달이었고. 신원은 안 깨졌어. 그냥 독자가 묻는 질문에 답을 안 할 뿐이지. 독자가 묻는 건 "같은 주소야?"가 아니라 "같은 소식이야?"거든.

서로 다른 관계 둘, 헷갈리는 단어 하나

따로 이름 붙이는 게 도움돼. 신원은 정확하고, 싸고, 레코드의 속성이야. 같은 정본 주소, 같은 행. 같음은 흐릿하고, 판단이 들어가고, 세상의 속성이고. 한 사건을 서술하는 레코드 둘. 신원은 수집에 속해. 같은 배달을 두 번 저장하는 걸 막는 자리니까. 같음은 렌더링에 속하고. 사람이 뭘 봐야 할지 정하는 자리니까.

수집 시점에 같음을 접는 게 솔깃한 지름길이고 못 되찾을 걸 파괴해. 출처 추적이 사라져. 어느 출처가 그 사건을 실었는지 더는 알 수 없고, 그건 그날 소식이 어떻게 퍼졌는지에 대한 진짜 정보야. 결정이 되돌릴 수 없게 되고. 그리고 파괴적으로 적용된 흐릿한 규칙은 결국 제목이 비슷한 진짜로 다른 기사 둘을 합쳐버릴 거야. 복구할 게 아무것도 안 남은 채로.

제일 쓸모 있는 사본을, 첫 사본 자리에

렌더링 시점에 접을 땐 어느 사본이 살아남을지 정해야 하고, 답은 "첫 번째"가 아냐. 사본들은 품질이 달라. 하나는 이미 전처리됐을 수 있고, 하나는 그림을 달고 있을 수 있고, 하나는 포장지 말고 발행사를 직접 가리킬 수 있어. 순위를 매겨서 제일 나은 걸 남겨.

근데 첫 번째가 있던 자리에 남겨. 시간순 선반에선 위치가 의미를 지고, 사본을 자기 나중 자리로 올리면 중복 제거의 부작용으로 선반 순서가 바뀌어. 장치 하나가 조용히 두 일을 하는 거고, 그게 선반이 설명 불가능해지는 경로야.

묶는 키는 영리하지 말고 구두점만 지워

같음을 정하는 키는 되는 것 중 제일 심심한 거여야 해. 구두점이랑 대소문자를 평평하게 민 정규화된 제목. 유사도 점수가 솔깃한데 여기선 나쁜 거래야. 튜닝할 문턱이 생기고, 무관한 글을 합칠 수 있고, 렌더링마다 진짜 시간을 잡아먹어. 뭉툭한 키는 덜 합치는데, 그게 안전한 방향이야. 새어나간 중복은 좀 안 깔끔한 선반이고, 잘못된 합침은 독자가 존재조차 모를 기사를 숨기니까.

신원으로 저장하고, 같음으로 보여줘. 제약으로 강제할 만큼 정확한 건 저장소에 속하고, 판단이 필요한 건 다시 지을 수 있는 투영에 속해. 그 판단은 가끔 틀릴 거고, 그때 그게 데이터 손실이 아니라 표시 버그이길 바랄 테니까.

Code

렌더링 시점 접기 — 제일 나은 사본, 첫 사본 자리, 뭉툭한 키·python
def dedupe_stories(rows: list[dict]) -> list[dict]:
    """Collapse the same story delivered by several sources.

    Measured: 8% of a day's rows were duplicates -- one story arrived
    seven times under seven different wrapper URLs, so canonical-URL
    identity (correct at ingest) cannot see it.

    RENDER-time only: every row stays in the store, and the copy that
    survives is the most useful one, holding the position of the FIRST
    copy so the shelf's ordering is untouched.
    """
    best: dict[str, dict] = {}
    order: list[str] = []
    for row in rows:
        key = title_key(str(row.get("title") or ""))   # punctuation-blind
        if not key:
            key = f"__id{row.get('id')}"               # never merge on empty
        if key not in best:
            best[key] = row
            order.append(key)                          # position of copy #1
        elif _story_rank(row) > _story_rank(best[key]):
            best[key] = row                            # ...but the best copy
    return [best[key] for key in order]


def _story_rank(row: dict) -> tuple:
    """Which copy is most useful to a reader, in priority order."""
    return (
        bool(row.get("cleaned_at")),        # already pre-processed
        bool(row.get("image_url")),         # has a picture
        not is_shell(row.get("url", "")),   # points at the publisher
        bool(row.get("summary")),           # has a dek
    )

External links

Exercise

네 시스템에서 중복을 제거하는 자리를 찾아 분류해봐. 규칙이 정확한지 흐릿한지, 그리고 쓰기 시점에 파괴적으로 적용되는지 읽기 시점에 되돌릴 수 있게 적용되는지. 흐릿하고 파괴적이면, 규칙이 잘못 합칠 레코드 둘을 만들어서 뭐가 사라지는지 따져봐. 마지막으로 그 규칙이 읽기 시점으로 옮겨갈 수 있는지 정해.
Hint
흐릿하고 파괴적인 건 보통 성능 때문에 그렇게 됐고, 그 성능 논변은 보통 낡았어. 접기가 결과 한 페이지가 아니라 표 전체에 대고 일어나던 시절에 나온 거거든. 쓰기 시점 버전이 필요하다고 받아들이기 전에 실제로 그리는 크기에서 재봐.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.