Lansman haftasında aynı kişi üç kanaldan geliyor: sabah Meta anlık formunu dolduruyor, öğleden sonra proje sitesindeki formu gönderiyor, akşam da ilan portalından "bilgi al" butonuna basıyor. CRM'de üç kayıt açılıyor, üç danışman aynı numarayı arıyor, aday kızıyor, pazarlama ekibi de "reklam çalışıyor ama satış kaçırıyoruz" diyor. Ben Koray Yalçın olarak lead yönetimi ve CRM kurulumlarında bu tabloyu defalarca gördüm: mükerrer kayıt, bir temizlik işi gibi düşünülüyor; oysa sorun kayıt geldikten haftalar sonra tabloda satır birleştirmek değil, kaydın sisteme girdiği anda kimliğin çözülmemesidir.

Lead deduplication (mükerrer lead ayıklama), yeni bir talebin mevcut bir kişiye ait olup olmadığına karar veren ve bu kararı tekrarlanabilir kurallarla veren katmandır. Kavramın kısa tanımını lead deduplication nedir sözlük maddesinde tutuyorum; bu yazıda o tanımı bir geliştiricinin kuracağı yapıya çeviriyorum: normalizasyon, blocking key, kural tablosu, üç sonuçlu karar (bağla / incelemeye al / yeni kişi aç) ve geri alınabilir bir kişi grafı.

Önemli bir ayrım var. Her gelen formu silip tek satırda toplamak istemiyoruz. Her başvuru bir niyet sinyalidir: hangi kanal, hangi proje, hangi saat, hangi reklam. Birleştirilmesi gereken şey kişi kimliğidir, başvuru olayı değil.

Telefon tek başına yetmez

Birçok ekip "aynı telefon varsa aynı kişidir" kuralıyla yola çıkar. Gayrimenkul tarafında bu kural üç şekilde bozulur.

Birincisi biçim. 0532 111 22 33, +90 532 111 22 33, 00905321112233 ve 5321112233 aynı numaradır ama metin olarak dört farklı değerdir. İkincisi paylaşım. Bir ofis sabit hattı veya bir ev numarası iki farklı kişi tarafından kullanılabilir; özellikle ailece bakılan bir dairede bu sık görülür. Üçüncüsü değişim. Aynı kişi yeni bir hatla ikinci kez başvurur; telefon eşleşmez, e-posta eşleşir.

Bu yüzden eşleştirmeyi tek bir alana bağlamak yerine, normalize edilmiş birkaç kimlik anahtarı ve bu anahtarların güven düzeyini tutan kurallar kullanıyorum.

Önce normalize et, sonra karşılaştır

Karşılaştırma asla ham metin üzerinde yapılmamalı. Telefon için hedef biçim E.164'tür; Türkiye'de mobil numarada baştaki sıfır ve 0090 / +90 varyantları tek bir +90XXXXXXXXXX değerine indirgenir. E-posta için küçük harf, uç boşluk ve Gmail'deki nokta ile + takıları ayrı ele alınır: ali.veli+proje@gmail.com ile aliveli@gmail.com aynı teslim kutusudur.

import phonenumbers def normalize_phone(raw: str, default_region: str = "TR") -> str | None: if not raw: return None try: num = phonenumbers.parse(raw, default_region) if not phonenumbers.is_valid_number(num): return None return phonenumbers.format_number( num, phonenumbers.PhoneNumberFormat.E164 ) except phonenumbers.NumberParseException: return None def normalize_email(raw: str) -> str | None: if not raw: return None email = raw.strip().lower() if "@" not in email: return None local, domain = email.split("@", 1) if domain in {"gmail.com", "googlemail.com"}: local = local.split("+", 1)[0].replace(".", "") domain = "gmail.com" return f"{local}@{domain}" 

İsim için daha temkinliyim. Türkçe karakterleri NFC ile tutmak, fazla boşlukları sıkıştırmak ve unvanları (sn., mr.) atmak yeterlidir. İsmi tek başına eşleşme anahtarı yapmam; "Mehmet Yılmaz" aynı projede onlarca kez geçer. İsim, telefon veya e-posta gibi güçlü bir anahtarla birlikte destekleyici kanıt olur.

Kişi grafı: başvuruyu silme, kişiye bağla

Şemada üç tablo tutuyorum. person kanonik kimliktir. lead_event her başvurudur ve asla güncellenerek yok edilmez. person_identifier o kişiye bağlı, normalize edilmiş anahtarlardır.

CREATE TABLE person ( id bigserial PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now(), display_name text ); CREATE TABLE lead_event ( id bigserial PRIMARY KEY, person_id bigint REFERENCES person(id), source text NOT NULL, source_record_id text NOT NULL, project_key text, payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now(), match_status text NOT NULL, match_rule text, match_score numeric, UNIQUE (source, source_record_id) ); CREATE TABLE person_identifier ( id bigserial PRIMARY KEY, person_id bigint NOT NULL REFERENCES person(id), kind text NOT NULL, value text NOT NULL, first_seen timestamptz NOT NULL DEFAULT now(), UNIQUE (kind, value) ); CREATE TABLE match_review ( id bigserial PRIMARY KEY, lead_event_id bigint NOT NULL REFERENCES lead_event(id), candidate_person_id bigint NOT NULL REFERENCES person(id), score numeric NOT NULL, reasons jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), resolved_at timestamptz, resolution text ); 

UNIQUE (kind, value) satırı kritiktir: aynı E.164 numarası iki kişiye bağlanamaz. İki kişi aynı numarayı gerçekten paylaşıyorsa bu bir inceleme kaydıdır, sessizce ikinci kişiye yazılmaz. UNIQUE (source, source_record_id) ise aynı Meta form gönderiminin iki kez işlenmesini engeller; bu, deduplication'ın kardeşi olan idempotent ingest'tir.

Blocking key: tüm tabloyu taramayın

Her yeni lead geldiğinde bütün person tablosunu gezmek hem yavaş hem tehlikelidir; yanlış eşleşme riski hacimle birlikte artar. Blocking, olası adayları ucuz bir anahtarla daraltır, pahalı karşılaştırmayı yalnız o kümeye uygular.

CREATE TABLE person_block ( person_id bigint NOT NULL REFERENCES person(id), block_key text NOT NULL, PRIMARY KEY (block_key, person_id) ); -- örnek anahtarlar: -- phone:+905321112233 -- email:aliveli@gmail.com -- phone_last7:5321112233 

Yeni kayıt geldiğinde önce kendi blocking key'lerini üretiyorum, person_block üzerinden aday kişi kimliklerini çekiyorum, sonra yalnız bu adaylar üzerinde kural tablosunu çalıştırıyorum. Hacim binlerle ifade edildiğinde bu fark, tam taramayla kıyaslanamayacak kadar belirgindir.

Kuralları koda gömmeyin, tabloya yazın

Eşleşme kurallarının iş birimiyle konuşulabilir olması gerekir. "Aynı normalize telefon kesin eşleşmedir" kuralını bir if dalının içine gömersem, pazarlama ekibi kuralı değiştirmemi istediğinde deploy beklemek zorunda kalır. Ben kuralları veri olarak tutuyorum:

CREATE TABLE match_rule ( id text PRIMARY KEY, priority int NOT NULL, conditions jsonb NOT NULL, decision text NOT NULL, min_score numeric NOT NULL ); -- phone_exact: {"phone": "exact"} -> auto_link -- email_exact: {"email": "exact"} -> auto_link -- phone_name: {"phone": "exact", "name": "similar"} -> review -- email_project:{"email_local": "exact", "project": "exact"} -> review 

Kesin telefon veya kesin e-posta eşleşmesi otomatik bağlanır. İsim benzerliği, aynı proje, aynı cihaz veya aynı oturum gibi zayıf sinyaller tek başlarına otomatik birleştirme yapmaz; inceleme kuyruğuna düşer. Yapay zekâyı bu katmanda skor üretmek veya kuyruğu sıralamak için kullanabilirsiniz, birleştirme kararını ona bırakmam. Yanlış birleştirmenin maliyeti, yanlış ayrı tutmanın maliyetinden daha yüksektir: iki gerçek kişinin sohbet geçmişi, izni ve satış aşaması birbirine karışır ve geri almak zordur.

İsim benzerliği için Levenshtein ya da Jaro-Winkler yeterli olur; eşik değerini kendi verinize bakarak seçin, ezbere bir sayı yazmayın. Türkçe'de ı/i ve ğ/g dönüşümlerini karşılaştırmadan önce uygulamak, sahte farkları azaltır. Yine de isim skorunu otomatik birleştirme eşiğinin üstüne koymuyorum.

Karar anı ve yarış durumu

İki form aynı saniye içinde gelebilir. İkisi de henüz person tablosunda olmayan aynı telefonu taşırsa, kontrol-sonra-yaz yaklaşımı iki kişi üretir. Bunu önlemenin yolu, identifier üzerine konan benzersiz kısıt ve kısa bir kilit:

def ingest_lead(conn, event): phone = normalize_phone(event.get("phone")) email = normalize_email(event.get("email")) keys = [] if phone: keys.append(("phone_e164", phone)) if email: keys.append(("email_norm", email)) with conn.cursor() as cur: lock = phone or email or event["source_record_id"] cur.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (lock,)) candidates = find_candidates(cur, keys) decision = apply_rules(candidates, event, keys) if decision.kind == "auto_link": person_id = decision.person_id elif decision.kind == "review": person_id = None else: cur.execute( "INSERT INTO person (display_name) VALUES (%s) RETURNING id", (event.get("name"),), ) person_id = cur.fetchone()[0] cur.execute( """INSERT INTO lead_event ( person_id, source, source_record_id, project_key, payload, match_status, match_rule, match_score ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (source, source_record_id) DO NOTHING""", ( person_id, event["source"], event["source_record_id"], event.get("project"), event["payload"], decision.kind, decision.rule_id, decision.score, ), ) if person_id and decision.kind == "auto_link": for kind, value in keys: cur.execute( """INSERT INTO person_identifier (person_id, kind, value) VALUES (%s, %s, %s) ON CONFLICT (kind, value) DO NOTHING""", (person_id, kind, value), ) if decision.kind == "review": queue_review(cur, event, decision) return decision 

pg_advisory_xact_lock aynı telefon veya e-posta üzerinde eşzamanlı iki işlemi sıraya koyar. ON CONFLICT DO NOTHING aynı kaynak kaydının yeniden oynatılmasını yutar. Identifier çakışması olursa (başka bir kişi o telefonu zaten taşıyorsa) bunu sessizce geçmek yerine inceleme kuyruğuna yazın; aksi halde veri kaybını fark etmezsiniz.

İnceleme kuyruğundaki kayıt için person_id boş bırakılabilir ya da geçici bir kişi açılıp sonra bağlanabilir. Ben boş bırakmayı tercih ediyorum: satır görünür, danışmana henüz ikinci bir kişi olarak atanmaz, karar verilince bağlanır. Atama ve ilk temas bu kuyruğu bekleyemezse, geçici kişiyi açıp "olası mükerrer" bayrağı koymak daha güvenlidir; aksi halde inceleme bekleyen lead sessizce soğur. İkisinden birini bilinçli seçin.

Otomatik bağlama, kaydın sahibini değiştirmez. Var olan kişinin bir danışmanı varsa yeni başvuru o kişiye eklenir; sahiplik devredilmez, SLA saati sıfırlanmaz, "yeni lead" kuyruğuna ikinci kez düşülmez. Aksi halde aynı aday her formda başka bir danışmana gider ve mükerreri teknik olarak birleştirsek bile operasyonda yine üç kişi arar. İsterseniz bağlama olayına "mevcut sahibe haber ver" gibi bir yan etki ekleyin; haber vermek ile yeniden atamak farklı işlerdir.

Birleştirmeyi geri alınabilir tutun

Otomatik bağlama bile yanlış olabilir. Evli bir çift aynı e-posta kutusunu paylaşıyor olabilir; bir asistan kendi telefonundan işvereni adına form doldurmuş olabilir. Bu yüzden bağlama bir olaydır:

CREATE TABLE person_link_event ( id bigserial PRIMARY KEY, lead_event_id bigint NOT NULL REFERENCES lead_event(id), from_person_id bigint, to_person_id bigint NOT NULL REFERENCES person(id), rule_id text, actor text NOT NULL, action text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); 

Geri alma (unlink) lead olayını eski haline döndürür, identifier'ı gerekirse koparır ve sonraki otomatik kuralların aynı hatayı hemen tekrarlamaması için bir istisna kaydı bırakır. Denetimde "bu iki kayıt neden birleşti?" sorusunun cevabı bu tablodadır.

İzin birleştirilirken de dikkat gerekir. Bir kayıttaki geri çekme, diğer kayıttaki daha eski bir onayın üzerine yazılmalıdır; aksi halde mükerreri birleştirirken birine tekrar mesaj göndermiş olursunuz. Bu, izni ayrı bir olay defteri olarak tutmanın bir başka nedenidir.

Ne ölçülür?

Panoda dört sayıya bakıyorum. Otomatik bağlanan başvuruların oranı, inceleme kuyruğunun medyan bekleme süresi, incelemede "yeni kişidir" denilen kayıtların oranı ve geri alınan bağlama sayısı. İlki yüzde yüze yaklaşırsa kurallar çok agresiftir. İkincisi saatleri aşarsa lansman gününde kuyruk satışın önüne geçer. Üçüncüsü çok yüksekse blocking key fazla geniş tutulmuş demektir. Dördüncüsü sıfırdan büyükse, hangi kuralın yanlış pozitif ürettiğini person_link_event üzerinden geriye doğru okuyabilirsiniz.

Sahte bir doğruluk yüzdesi uydurmanın alemi yok; bu sayılar sizin verinizde, sizin kurallarınızla anlam kazanır.

Gayrimenkulde sık gördüğüm tuzaklar

İlan portallarından gelen bildirim e-postalarında telefon bazen imzada, bazen konu satırında, bazen de farklı bir biçimde durur. Ayrıştırıcının ürettiği ham metni doğrudan eşleştirmek yerine önce normalizasyondan geçirin; aksi halde aynı kişi her portal için yeni kayıt olur.

İkinci tuzak, "mükerrer = değersiz" varsayımıdır. Aynı kişinin üç gün içinde ikinci kez form doldurması genelde çöp değil, ısınma sinyalidir. Lead olayını silmek bu sinyali yok eder. Kişi aynı kalır, olay ayrıca durur, skorlama ve danışman notu bu ikinci olayı görür.

Üçüncü tuzak, test ve sahte kayıtlardır. 555 ile başlayan numaralar, test@ e-postaları ve kendi çalışanlarınızın tekrarlayan başvuruları eşleştirme istatistiklerini bozar. Bunları deduplication'dan önce ayrı bir doğrulama katmanında ayıklamak, hem kuyruğu hem de kuralların kalibrasyonunu temiz tutar.

Sık sorulan sorular

Yalnızca telefon eşleştirsem olmaz mı?
Küçük bir ofiste ve tek kanallı bir akışta bir süre idare eder. Kanal sayısı artınca biçim farkları, paylaşılan hatlar ve ikinci numaralar boşluk bırakır. Telefonu en güçlü anahtar olarak tutun, ama tek anahtar yapmayın.

Olası eşleşmeyi otomatik birleştirmesem satış gecikir mi?
Gecikme inceleme kuyruğunun tasarımına bağlıdır. Kuyruğu birinin hayır demesini bekleyen bir beklemeye dönüştürmeyin: süre dolunca geçici kişi üzerinden ilk temas kurulsun, bayrak kalsın, sonra insan karar versin. İlk temasın kaçmaması ile yanlış birleştirmenin olmaması aynı anda kurgulanabilir.

İki kişi birleştikten sonra sohbet geçmişi ne olur?
Kişi grafı doğru kurulmuşsa sohbet person_id üzerinden okunur, lead olayları yerinde kalır. Yanlış birleştirmede unlink sohbeti de ayırmalıdır; bu yüzden mesajları kişiye değil, mümkünse lead olayı veya konuşma oturumuna bağlamak daha güvenlidir.

Hacim küçükseniz blocking key gerekir mi?
İlk yüz kayıtta fark etmez. İlk lansmanda binlerce form aynı güne yığılınca fark eder. Blocking key'i baştan koymak ucuzdur, sonradan tam taramayı temizlemek pahalıdır.

Yapay zekâ eşleştirmeyi devralabilir mi?
Adayları sıralayabilir, zayıf sinyalleri gerekçesiyle birlikte incelemeye bırakabilir. Otomatik auto_link kararını kural tablosuna bırakın. Model bir gün değişir, kural tablosu ve olay defteri kalır; denetimde ikincisini göstermek daha kolaydır.

Koray Yalçın — lead yönetimi, CRM mimarisi, dijital pazarlama ve PropTech üzerine çalışıyor.