DEV Community

Cover image for How we're using Gemini Embeddings to build a smarter, community-driven feed on DEV
Ben Halpern Subscriber for The DEV Team

Posted on

How we're using Gemini Embeddings to build a smarter, community-driven feed on DEV

Ruby-based auditing for AI calls and latency

Big improvements incoming 👋

Finding the right balance for a feed algorithm is historically really hard. If you optimize purely for clicks and comments, you end up with a clickbait echo chamber. But if you just sort by recency, it's a firehose where great discussions disappear in hours.

We've wrestled with this tension at DEV for a long time. We want a feed that feels alive, but actually surfaces high-quality, intellectually stimulating stuff.

So, we're trying something new. We are combining standard community signals—like who you follow and what you react to—with Gemini Embeddings 2 and pgvector.

Here is a look under the hood at how we are putting this together.


1. Keeping things flexible and auditable

Instead of duct-taping API calls all over the codebase, we built a flexible foundation using wrapper classes, mostly centered around Ai::Base and Ai::Embedding.

When a service needs the API, it just passes wrapper: self to the client. This lets Ai::Base look at the calling object, grab its class name, and check its VERSION.

Ai::Base.new(wrapper: self)
Enter fullscreen mode Exit fullscreen mode

This pattern gives us a really clean audit trail via our AiAudit model. Every single time we generate a vector or analyze a trend, we automatically log the model used, the caller's class, payloads, latency, and token counts.

It makes debugging and tracking costs so much easier, without muddying up our core business logic.


2. A more personalized feed

Our main feed is powered by FeedConfig. It compiles custom SQL to score and rank articles for you.

Historically, this was all hardcoded math based on things like tags and whether you follow the author. Now, we've introduced a semantic feedback loop.

As you interact with the platform, we compile a dynamic interest_embedding that represents what you actually care about. We use the pgvector extension in PostgreSQL to inject your interests directly into the SQL query:

(
  CASE
    WHEN articles.semantic_embedding IS NOT NULL
      AND articles.published_at >= :published_since
    THEN (1 - (articles.semantic_embedding <=> :interest_embedding)) * :semantic_similarity_weight
    ELSE 0
  END
)
Enter fullscreen mode Exit fullscreen mode

By using 1 - (embedding <=> user_interest), we get a cosine similarity score. We scale that up and mix it in with standard social signals (like who you follow), post quality, and time decay.

This means a highly relevant post can rise to the top of your feed, but so can a globally trending post from a community member you love. It’s all about balance.


3. What the heck is an embedding anyway? (And why v2 matters)

If you're new to the concept, an embedding is basically taking a piece of content—like an article text—and turning it into a long string of numbers (a vector). These numbers map the content into a "semantic space." If two posts are talking about the exact same conceptual ideas, their numbers will look very similar mathematically, even if they use completely different wording.

We've upgraded this pipeline to use Google's newly released Gemini Embeddings 2 model.

A standard text embedding model only looks at words. But Gemini Embeddings 2 compiles into massive 3,072-dimensional vectors and maps everything into a single, unified semantic space.

Future-proofing for a multi-modal DEV

The coolest part about moving to Embeddings 2 is that it isn't just restricted to text. It natively accepts multimodal inputs—meaning text, code, images, audio, and video.

Right now, we're using it to analyze written DEV posts. But because the underlying math maps everything into the exact same vector space, we are completely future-proofing our infrastructure. As the DEV platform evolves, we can easily feed images, podcast audio, or video posts into the exact same database architecture[.

A user's interest_embedding will be able to effortlessly surface an open-source video tutorial or a technical podcast episode based entirely on conceptual relevance, without us needing to rewrite our feed logic from scratch.


4. Catching nuanced trends 📈

Tags are great for high-level sorting, but they miss the highly specific, timely conversations. If Ruby 3.4 drops, a #ruby tag search won't distinguish between a "Hello World" tutorial and a deep debate about the new parser.

To fix this, we are in the process of building a clustering service powered by TrendDetector.

Every 6 hours, a background job runs a Leader Clustering algorithm in pure Ruby:

  • Quality first: We only look at recent articles scoring at least 15 points above our homepage minimum.
  • Clustering: We measure the cosine distance between articles. If a post is close enough (0.15 or less) to an existing cluster, it joins it. If not, it starts a new one.
  • Labeling: Once a cluster hits 10 or more articles, we ask the Gemini API to label the trend and summarize the core debate.

We store all of this in TrendMembership, which lets us sort articles in the UI based on how close they are to the core topic.

All of this can be tracked via our open source codebase Forem:

GitHub logo forem / forem

For empowering community 🌱


Forem 🌱

For Empowering Community



Build Status Build Status GitHub commit activity GitHub issues ready for dev GitPod badge

Welcome to the Forem codebase, the platform that powers dev.to. We are so excited to have you. With your help, we can build out Forem’s usability, scalability, and stability to better serve our communities.

What is Forem?

Forem is open source software for building communities. Communities for your peers, customers, fanbases, families, friends, and any other time and space where people need to come together to be part of a collective See our announcement post for a high-level overview of what Forem is.

dev.to (or just DEV) is hosted by Forem. It is a community of software developers who write articles, take part in discussions, and build their professional profiles. We value supportive and constructive dialogue in the pursuit of great code and career growth for all members. The ecosystem spans from beginner to advanced developers, and all are welcome to find their place…


5. Putting the community first ❤️

Human curation, both from the broader community and our editorial perspective, is still the backbone of the system.

We are using Gemini Embeddings to amplify what the community is already doing. It’s about mixing the raw utility of vector search with the human spirit of developer-voted scores and relationships.

We want DEV to be the best place on the internet to share code and talk about software. We think this is a big step in that direction.

What do you think? Let me know in the comments.

Happy coding!

Top comments (28)

Collapse
 
fm profile image
Fayaz

Very much interested to see how much it improves the personalized feed!

Although, I think significant improvement will take time.

I've used embeddings in an n8n RAG challenge last year.

While sometimes the similarity match works pretty well, at times it becomes very difficult to get the desired results.

In this case, for example, a person may engage in posts s/he disagrees with and/or is familiar with, a lot more than in posts s/he likes and wants to learn from.

Disagreement and familiarity are almost instant and hence result in more engagement, whereas unfamiliar learnable posts may take a long time to engage in.

Hence, these sort of algorithm may increase engagement while decreasing learning!

Hence, I'd be cautiously optimistic ❤️

Collapse
 
ben profile image
Ben Halpern The DEV Team

We do have many other factors also being weighted completely separate, such as the "clickbait score" which punishes typical clickbait articles etc.

It is always veeeery much a balance of finding the right outcomes for learning. We'll try and come up with success criteria that really do reward learning to weigh as feedback. Will report back :)

Collapse
 
fm profile image
Fayaz

Just a quick feedback: I usually bookmark a post if I think I have something to learn from that post later, but not yet sure what to say in comments, or whether or not I'd like it! Sometimes I engage with it weeks or months later.

Can this metric be used in any useful way? Just thinking out loud! 🤔

Collapse
 
dannwaneri profile image
Daniel Nwaneri

The interest_embedding injected directly into the ranking SQL is the right call. Static tag weights miss the person — someone who reacts to advanced Rust posts and beginner Python posts looks like "Rust + Python developer" when they're really two different people depending on the week.

One thing I'm curious about: how are you handling embedding drift? A user's interest_embedding from six months ago may actively work against what they care about now. Rolling window, decay weighting, or full recompute on new interactions?

I built a similar pipeline — hybrid BM25 + vector search on 100k personal documents. The thing that surprised me most was how much reranking changed the result set after retrieval. The cosine score alone didn't match what actually mattered. Curious whether the weighted SQL blend is your final signal or if there's a reranking layer on top.

Collapse
 
fm profile image
Fayaz

Reranking is expensive, what did you use for reranking?
I've used Pinecone before. Any better suggestion?

Collapse
 
ben profile image
Ben Halpern The DEV Team

This is the logic we're using, you can see blend_factor (which is a different weight depending on the trigger).

It's designed to reward recency and cycle through your interests over time, which is different than indicators such as what tags you follow which are more permanent in nature.

class UpdateUserInterestEmbeddingWorker
  include Sidekiq::Job
  sidekiq_options queue: :low_priority, lock: :until_executing, on_conflict: :replace

  def perform(user_id, article_id, blend_factor = 0.2)
    blend_factor = blend_factor.to_f.clamp(0.0, 1.0)
    article = Article.find_by(id: article_id)
    return unless article && article.respond_to?(:semantic_embedding) && article.semantic_embedding.present?

    article_vector = article.semantic_embedding.to_a
    return unless article_vector.length == 768

    UserActivity.transaction do
      user_activity = UserActivity.lock.find_or_create_by!(user_id: user_id)

      if user_activity.respond_to?(:interest_embedding)
        current_vector = user_activity.interest_embedding&.to_a

        new_vector = if current_vector.blank? || current_vector.length != 768
                       article_vector
                     else
                       # Blend vectors using Exponential Moving Average
                       current_vector.zip(article_vector).map do |c, a|
                         (c * (1 - blend_factor)) + (a * blend_factor)
                       end
                     end

        user_activity.update_column(:interest_embedding, new_vector)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

github.com/forem/forem/tree/main/a...

Since we're open source we more than welcome PRs suggesting more effective approaches here :)

Thread Thread
 
dannwaneri profile image
Daniel Nwaneri

The EMA blend is the right mechanism — it handles recency without requiring a separate decay job, and the variable blend_factor per trigger is smart. A reaction carries different signal than a page view; same vector update logic, different weight.

The drift question answers itself in the code: low blend_factor means old preferences dominate for a long time even after behavior shifts. Worth watching whether 0.2 is sticky enough at the edges
— a user who spent six months in one niche and then pivots hard may feel like the feed "doesn't know them yet" for longer than it should.

On the PR invitation . I'll take a look. Curious whether you've experimented with asymmetric blend rates: higher factor when the new article is far from the current vector (big shift), lower when
it's close (reinforcement).

Collapse
 
dannwaneri profile image
Daniel Nwaneri

Pinecone's reranking is managed but you're paying for the latency and the lock-in. What moved the needle for me was a cross-encoder running locally — slower than cosine similarity alone but the result quality difference on ambiguous queries was significant enough to justify it.

The retrieval gets you candidates; the reranker picks the one that actually matches intent. I built this out on Cloudflare Workers if you want to see it in production:github.com/dannwaneri/vectorize-mc...

Collapse
 
workout097collab profile image
Vasyl

This is actually a really smart direction for recommendation systems.

I like that you're not replacing community signals with AI, but blending semantic relevance with human-driven interactions. That balance is what most platforms miss.

The audit layer around AI calls is also underrated — logging latency, token usage, wrappers, versions, etc. becomes essential once AI touches production ranking systems.

Collapse
 
annavi11arrea1 profile image
Anna Villarreal

How are you considering wild factors? Like occasionally its nice to see a post outside of your interest scope, for freshness. Is there a loose allowance after preferences are consideted, im guessing? I hope that makes sense.

Collapse
 
jonmarkgo profile image
Jon Gottfried The DEV Team

Honestly really excited about this. I love how thoughtful the implementation is while also leveraging some bleeding edge tech from our partners :) and hopefully continuing to zig zag us towards the ultimate goal of always surfacing the right content to the right people when they're spending time on DEV

Collapse
 
itskondrat profile image
Mykola Kondratiuk

the part I'd worry about is model pinning - gemini-embedding-exp-03-07 today, different dimensions next cycle, full recalibration project. what's your migration path when the embedding model version changes?

Collapse
 
francistrdev profile image
FrancisTRᴅᴇᴠ (っ◔◡◔)っ

Big improvements!! Thanks Ben for updating us and keeping it transparent! Good work :D

Collapse
 
ofri-peretz profile image
Ofri Peretz

The AiAudit-by-default pattern is the part more teams should steal — most bolt AI in and add logging only after the first incident. Two things it quietly buys you beyond debugging: (1) it's your governance boundary — when someone asks "what exactly did we send Gemini, and when," you have an answer instead of a guess; (2) it's where you'd catch payload drift (PII or secrets sneaking into an embedding call) before it's a compliance problem. Curious whether you redact payloads before they hit the AiAudit log or store them raw for fidelity — that trade-off (forensic completeness vs. not warehousing sensitive text) is the one I keep seeing teams get wrong. Really like that the embedding layer is auditable rather than magic.

Collapse
 
tahosin profile image
S M Tahosin

Balancing a feed algorithm between clickbait popularity and chronological noise is incredibly difficult. Using semantic embeddings to cluster topics rather than relying purely on tag-matching is a huge step forward for content discovery. I’m really looking forward to seeing how this impacts the visibility of deeper, technical deep-dives over listicles!

Collapse
 
casperday11 profile image
Somay

This is really interesting to me because I tried applying a tiny version of this idea in my college blog website.

I’d love to see how this goes at DEV’s scale, especially around how different actions affect the interest_embedding. A like, bookmark, comment, or long read can all mean different things. Sometimes bookmarking means “I want to learn this later,” while commenting can even mean disagreement or familiarity.

So I’m looking forward to learning from how this works in practice, and seeing which parts I could have done better in my own small implementation.

Also love that you’re blending embeddings with community signals instead of making vector similarity the whole feed. That feels like the right balance.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.