The Problem I Was Trying to Solve§
I was working with a B2B SaaS product that had a 5.2% monthly churn rate. That was quietly killing our growth: we were spending heavily on acquisition while a fifth of our customer base walked out every quarter. The usual playbooks — win-back emails, customer success check-ins — were reactive. We only knew a customer was at risk after they canceled or downgraded.
I needed a proactive system, not another set of threshold alerts. The challenge was that behavioral data is noisy, high-dimensional, and full of context. A drop in login frequency means one thing for a startup that just went through a holiday, and another for a mature enterprise account that has moved to API-based access. I couldn't encode all those scenarios in rules manually.
That's why I turned to vector embeddings. My hypothesis was that if I converted each account's recent behavior into a semantic summary and embedded it into a high-dimensional space, accounts would naturally cluster by churn risk. Then I could flag risky accounts by measuring distance to known churners. No hand-crafted if-then logic.
Tools and Setup§
Let me walk through the exact stack. I used DeepSeek's embedding API as the core representation layer. It's cheap, fast, and returns stable 768-dimensional vectors. For storage and similarity search, I used pgvector — we already ran Postgres, so adding an extension was simpler than introducing a new vector database. I used Claude via API to generate synthetic churn scenarios for data augmentation, Cursor as my IDE, and Perplexity for researching churn prediction approaches.
The pipeline had three parts: (1) a feature aggregator that distilled raw events into text summaries, (2) an embedding service that turned those summaries into vectors, and (3) a PostgreSQL table with a vector column and HNSW index for similarity queries.
A typical daily summary text looked like this:
Customer #A-2021: 2025-03-14; active_seats=12; logins_today=8; avg_session=14min; feature_breadth=0.3; support_tickets_30d=3; tags=["api", "batch-reporting"]; sentiment="frustrated delay"
That text captures numbers and nuance in a way a raw feature vector can't. The tags come from feature events, and sentiment is derived from support ticket keywords and title sentiment.
Step-by-Step: What I Actually Did§
Here's exactly how I built the system, step by step, in production order.
Step 1: Build the weekly summary generator. I wrote a SQL query that pulled every relevant event for each account, then used Python to render a text summary per day. I aggregated to weekly windows because churn happens gradually, not overnight. Each week's summary was a weighted blend of the last 30 days, with more recent days weighted exponentially higher.
Step 2: Generate embeddings and store them. I called DeepSeek's embedding endpoint with each summary string. I stored the vector in a customer_state table along with the account_id and week_date. I also computed a churn label: whether the account churned (not renewed or downgraded to free) within the 28 days following that week.
Step 3: Train a classifier on top of embeddings. I split the dataset chronologically, so the model never saw future weeks during training. I used scikit-learn's logistic regression with L2 regularization. I also experimented with a simple feedforward network, but logistic regression gave better generalization on our modest dataset of about 4,000 labeled account-weeks.
Step 4: Build the lookalike search. For every account in the current week, I computed the L2 distance to all historical vectors. I then pulled the top 10 nearest neighbors for each account. If any of those neighbors churned within the next 28 days, the account got flagged. This gave the customer success team an explainable reason for the flag.
Step 5: Augment with synthetic scenarios. I used Claude to generate 100 fictitious but realistic account states with known churn outcomes. I embedded those and used them as a pre-training dataset for an unsupervised clustering pass. That helped identify patterns that were rare in our actual history.
Code Samples / Prompts Used§
Here's the core embedding function I used:
import deepseek
from datetime import datetime
def embed_text(text):
resp = deepseek.Embedding.create(
model='deepseek-embedding',
input_text=text
)
return resp['data'][0]['embedding']
def build_summary(row):
summary = (
'Customer #{}: day={}; seats={}; logins={}; avg_session={}min; '
'feature_breadth={}; tickets_30d={}; tags={}; sentiment={}'
).format(
row['account_id'], row['day'], row['active_seats'], row['logins'],
row['avg_session_min'], row['feature_breadth'], row['tickets_30d'],
row['feature_tags'], row['sentiment']
)
return summaryAnd here's the pgvector query for lookalike search:
SELECT
account_id,
week_date,
churned,
embedding <-> :query_vector AS distance
FROM customer_state
ORDER BY embedding <-> :query_vector
LIMIT 10;I also used Claude with this prompt to generate synthetic training data:
You are a customer success analyst at a B2B SaaS company. Generate 50 account states in the following format: Customer #X: day=D; seats=S; logins=L; avg_session=Mmin; feature_breadth=B; tickets_30d=T; tags=[...]; sentiment="..." Half should display signs that predict churn within 30 days, such as declining logins, shrinking active team size, reduced feature diversity, or negative support sentiment. The other half should be healthy accounts. Label each line with CHURN or STAY as a prefix, then the summary.
The output of that prompt fed directly into the same embedding function.
What Worked Well§
The vector embedding approach handled mixed data types elegantly. Numbers, tags, and sentiment text all got projected into one continuous space. I didn't have to manually design feature interactions — the embedding model learned them from the text structure. That alone saved me weeks of feature engineering.
The similarity-search interpretation was the elephant's biggest surprise. When I showed the CS team the top 10 historical lookalikes for a flagged account, they instantly understood the score. They could say, "Yes, this account reminds me of that one that downgraded two weeks after reducing seats." That trust was the reason the model got adopted, not the AUC.
Synthetic data from Claude genuinely helped. The embedding model had never seen an account that simultaneously had high logins but low feature breadth. Adding those generated examples made the clustering model identify that pattern as a churn risk — and it turned out to be a real one in our data. The synthetic set was tiny, but it de-noised the semantic space.
What Failed and Why§
My initial implementation used raw numerical features as a fixed-size vector, no embeddings. A random forest on those features achieved 0.55 recall with a 0.4 false-positive rate — too noisy to operationalize. The model kept firing on accounts that were merely seasonal.
The first embedding version used average pooling over daily embeddings. That hurt because a single burst of activity could outweigh sustained decline. Recency weighting fixed it, but I had to test several decay factors. Cosine distance also failed — it treats magnitude changes as less important, but magnitude is exactly what matters for churn. Switching to L2 distance improved the model's effective recall.
I also tried generating narrative paragraphs for each account via Claude's API. It produced rich text but at ridiculous egress cost, and response latency made it unusable for real-time scoring. Structured summaries were good enough. The lesson: language models are better for generating hypotheticals, not for compressing every row into prose.
Results and Takeaways§
The final model achieved an AUC of 0.87 on a time-based holdout, with recall of 0.71 at a 0.2 false positive rate. When we limited interventions to the top 15% of risk scores, churn dropped 12% over two quarters. The high-risk accounts flagged by the model were 2.3 times more likely to churn than the baseline population.
More importantly, the approach changed how we worked. Instead of generating alerts from arbitrary thresholds, we had a semantic model of customer health. The CS team now reviews a weekly list of accounts ranked by distance to known churners, along with the neighbor history. The conversations are data-driven but human.
Key takeaways from this experiment:
- Vector embeddings turn messy behavioral data into a dense semantic representation that captures context and interactions automatically.
- Similarity search against historical churners is an effective, explainable churn prediction mechanism — no black-box classifier required.
- Recency weighting and distance metric choice matter more than the choice of embedding model or classifier.
- Synthetic data generated by LLMs can meaningfully improve pattern recognition when real labeled data is scarce.
Try It Yourself§
If you want to replicate this, start with a tiny slice. Pick one customer segment — say, accounts with more than 10 seats — and build the weekly summary text. Embed it with DeepSeek or any embedder, put it in pgvector, and compute the churn label yourself.
Don't jump straight to a supervised model. Run the similarity query first. Look at the nearest neighbors of current accounts and see if the neighbors' outcomes align with your intuition. If they do, the signal is real. If not, redesign your summary schema.
Finally, experiment with different distance metrics and weighting schemes. For me, switching from cosine to L2 and adding recency weighting was the difference between an AUC of 0.74 and 0.87. And if your historical data is thin, generate a few synthetic examples with Claude and embed them too. It's not a replacement for ground truth, but it can sharpen the model's early-warning radar.
That's the entire process. It's cheap, explainable, and it catches churn early enough for your team to take action. I'd love to hear if this approach works in your domain.


