veröffentlicht am:
Lesedauer:
Lesedauer: 5 min

Semantische Dublettensuche

Autoren
  • avatar
    Thomas Schefter

Überblick

Echte KI-Hilfe abseits von Hype und Agenten: Dublettensuche in 200.000 Texten – lokal, kostenlos, in 5 Minuten.

Bisher hatte ich keine gute Lösung, um Dubletten in meiner Sammlung von kurzen Texten zu finden. Typische Datenbanken finden nur exakte Übereinstimmungen, aber keine inhaltlichen.
D. h.
„Wer so spricht, dass er verstanden wird, spricht immer gut." und
„Wer so redet, dass er verstanden wird, redet immer gut."
sind für die meisten Maschinen keine Dublette.

KI-Systeme arbeiten mit Vektor-Indizes. Diese funktionieren anders als herkömmliche Datenbanken: Texte werden in mathematische Vektoren umgewandelt, die Bedeutung abbilden. Das Ergebnis: Mit 50 Zeilen Python konnte ich lokal über 200.000 kurze Texte binnen 5 Minuten in einen Vektor-Index (FAISS) speichern und über 400 Dublettenpaare finden, inklusive Export als CSV.

Sogar anderssprachige Dubletten wurden erkannt:
→ „Unser Körper ist die Harfe unserer Seele."
→ „Notre corps est la harpe de notre âme."

Dieses Dokument beschreibt den technischen Workflow zur Erkennung semantischer Dubletten in einer Sammlung von 200.000 Texten mittels Vektor-Embeddings und FAISS-Index.

Workflow

CSV-Datei (200k Texte)
    ↓
Sentence Transformer (Embeddings erzeugen)
    ↓
FAISS Index (Vektoren speichern & normalisieren)
    ↓
Similarity Search (Top-k Nachbarn finden)
    ↓
Filterung (Score > 0.95)
    ↓
CSV-Export (Dublettenkandidaten)

Input-Daten

Die CSV-Datei hat eine einfache Struktur:

id,text
1,"Der Geist, der stets verneint, ist jener Geist, der Böses will und Gutes schafft."
2,"Ein Geist, der alles verneint und doch Gutes bewirkt."
3,"In der Ehe schweigt man, um sich zu verstehen."
  • id: Primärschlüssel aus der MySQL-Datenbank zur eindeutigen Zuordnung
  • text: Der zu analysierende Text

Technischer Ansatz

1. Embeddings statt Textvergleich

Traditionelle Datenbanken arbeiten mit exakten String-Matches. Semantische Ähnlichkeit erfordert einen anderen Ansatz:

  • Embeddings: Texte werden in hochdimensionale Vektoren (512 Dimensionen) umgewandelt
  • Bedeutung: Ähnliche Bedeutungen liegen im Vektorraum nahe beieinander
  • Mehrsprachig: Das Modell erkennt auch Übersetzungen als ähnlich

2. FAISS für schnelle Suche

FAISS (Facebook AI Similarity Search) ist eine optimierte Bibliothek für:

  • Effiziente Nearest-Neighbor-Suche in großen Vektormengen
  • Speicher-optimierte Indizes
  • Parallelisierung

Gewählte Index-Strategie: IndexFlatIP (Inner Product)

  • Exakte Suche ohne Approximation
  • Bei 200k Vektoren noch performant genug
  • Inner Product entspricht nach L2-Normalisierung der Cosine Similarity

3. Similarity Threshold

  • Score-Bereich: 0.0 (komplett unähnlich) bis 1.0 (identisch)
  • Gewählter Threshold: 0.95
  • Begründung: Hohe Präzision, um False Positives zu vermeiden

Das Python-Script

import pandas as pd
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np

# CRITICAL: Fix for macOS ARM + FAISS + OpenMP bug
# Without this, you'll get "resource_tracker: leaked semaphore" warnings
faiss.omp_set_num_threads(1)

# Step 1: Load CSV
df = pd.read_csv("zitate.csv")

# Step 2: Load pre-trained multilingual model
# distiluse-base-multilingual-cased supports 50+ languages
model = SentenceTransformer("distiluse-base-multilingual-cased")

# Step 3: Generate embeddings
# convert_to_numpy=True for FAISS compatibility
embeddings = model.encode(df["text"].tolist(), convert_to_numpy=True)

# Step 4: Build FAISS index
dimension = embeddings.shape[1]  # 512 for this model
index = faiss.IndexFlatIP(dimension)  # Inner Product index

# Normalize embeddings for cosine similarity
faiss.normalize_L2(embeddings)
index.add(embeddings)

# Step 5: Search for similar vectors
k = 4  # Top 4 neighbors (including self)
D, I = index.search(embeddings, k)
# D = distances/scores, I = indices

# Step 6: Print potential duplicates
print("Potenzielle Dubletten:")
for i, neighbors in enumerate(I):
    for j, neighbor_idx in enumerate(neighbors[1:]):  # Skip first (self)
        score = D[i][j+1]
        if score > 0.95:
            print(f"- Ähnlich ({score:.2f}):")
            print(f"  [{df.iloc[i]['id']}] {df.iloc[i]['text']}")
            print(f"  [{df.iloc[neighbor_idx]['id']}] {df.iloc[neighbor_idx]['text']}\n")

# Step 7: Build CSV output (deduplicated pairs)
seen = set()
rows = []

for i, neighbors in enumerate(I):
    for j, neighbor_idx in enumerate(neighbors[1:]):
        # Create sorted tuple to avoid duplicate pairs (A,B) and (B,A)
        pair = tuple(sorted((df.iloc[i]["id"], df.iloc[neighbor_idx]["id"])))
        score = D[i][j+1]
        
        if score >= 0.95 and pair not in seen:
            seen.add(pair)
            rows.append({
                "id_1": df.iloc[i]["id"],
                "text_1": df.iloc[i]["text"],
                "id_2": df.iloc[neighbor_idx]["id"],
                "text_2": df.iloc[neighbor_idx]["text"],
                "score": round(float(score), 4)
            })

# Step 8: Write CSV
df_out = pd.DataFrame(rows)
df_out.to_csv("dublettenkandidaten.csv", index=False)
print(f"\n✅ {len(df_out)} Dublettenkandidaten gespeichert in 'dublettenkandidaten.csv'")

Technische Details

Warum distiluse-base-multilingual-cased?

  • Mehrsprachig: Unterstützt 50+ Sprachen ohne Spracherkennung
  • Kompakt: DistilBERT-Architektur, schneller als BERT
  • Bewährt: Standard-Modell für semantische Textsuche

Performance-Optimierungen

  1. Batch-Encoding: Alle Texte werden in einem Durchgang encodiert
  2. NumPy-Arrays: Direkte FAISS-Kompatibilität
  3. L2-Normalisierung: Ermöglicht Inner Product statt teurerem Cosine Distance
  4. Deduplizierung: seen-Set verhindert doppelte Paare (A→B und B→A)

Platform-spezifische Anpassungen

macOS ARM (M1/M2/M3) + FAISS Bug

faiss.omp_set_num_threads(1)

Problem: Bekannter Kombinationsfehler zwischen macOS ARM, FAISS und OpenMP führt zu:

resource_tracker: There appear to be 1 leaked semaphore objects to clean up at shutdown
warnings.warn('resource_tracker: There appear to be %d '

Ursache: Inkompatibilität zwischen Apple Silicon, OpenMP-Threading und Python's multiprocessing-Modul

Lösung: Single-Threading für FAISS erzwingt

  • Kostet minimale Performance (bei 200k Texten kaum messbar)
  • Verhindert Semaphore-Leaks und Race Conditions
  • Nur auf macOS ARM nötig, auf Linux/Windows optional

Skalierung

Bei 200.000 Texten:

  • Embedding-Zeit: ~3-4 Minuten (CPU-abhängig)
  • Index-Aufbau: <1 Sekunde
  • Search: <1 Sekunde (auch mit Single-Threading)
  • RAM-Bedarf: ~1-2 GB

Output

Die erzeugte dublettenkandidaten.csv hat folgendes Format:

id_1,text_1,id_2,text_2,score
42,"Wer so spricht, dass er verstanden wird...",1337,"Wer so redet, dass er verstanden wird...",0.9823
105,"Unser Körper ist die Harfe unserer Seele.",106,"Notre corps est la harpe de notre âme.",0.9645

Erkenntnisse

Stärken

✅ Erkennt semantische Ähnlichkeit (nicht nur exakte Matches)
✅ Mehrsprachige Dubletten werden gefunden
✅ Schnell und lokal (keine Cloud/API-Kosten)
✅ Reproduzierbar und transparent

Grenzen

❌ Homophone werden nicht erkannt ("ist" vs. "isst")
❌ Threshold muss manuell justiert werden
❌ Keine Berücksichtigung von Kontext/Autor

Weiterführende Ideen

  • Clustering: Nicht nur Paare, sondern ganze Gruppen ähnlicher Texte
  • Deduplication-Pipeline: Automatisches Zusammenführen in Datenbank
  • Fine-Tuning: Custom Model für spezifische Textdomäne trainieren
  • Monitoring: Score-Verteilung analysieren für besseren Threshold