DEV Community

Cover image for How I built a semantic scoring algorithm for internal links across >200 pages
Serhii Kalyna
Serhii Kalyna

Posted on

How I built a semantic scoring algorithm for internal links across >200 pages

Last week I wrote about why I added internal linking to my image converter. This is the technical follow-up: how I replaced alphabetical ordering with a semantic scoring algorithm.

The problem with alphabetical ordering

My RelatedConversions component was showing links in alphabetical order. For /heic-to-jpg, it would show avif-to-heic, avif-to-jpg, bmp-to-heic — technically related, but not semantically prioritized.

What I wanted: show conversions that share the most format overlap first, then factor in keyword similarity.

The scoring model: 70/30

A simple weighted formula:

  • format overlap — how many formats the two conversions share (weight: 0.7)
  • keyword similarity — do the slugs share meaningful terms (weight: 0.3)
function scoreRelation(current, candidate) {
  const [fromA, toA] = current.split('-to-');
  const [fromB, toB] = candidate.split('-to-');

  const formatsA = new Set([fromA, toA]);
  const formatsB = new Set([fromB, toB]);
  const shared = [...formatsA].filter(f => formatsB.has(f)).length;
  const formatScore = shared / Math.max(formatsA.size, formatsB.size);

  const wordsA = new Set(current.split('-'));
  const wordsB = new Set(candidate.split('-'));
  const sharedWords = [...wordsA].filter(w => wordsB.has(w) && w !== 'to').length;
  const keywordScore = sharedWords / Math.max(wordsA.size, wordsB.size);

  return (formatScore * 0.7) + (keywordScore * 0.3);
}
Enter fullscreen mode Exit fullscreen mode

How it works in practice

For /heic-to-jpg:

  • jpg-to-heic → score 1.0 (both formats shared)
  • heic-to-png → score 0.7 (one shared format: heic)
  • webp-to-jpg → score 0.7 (one shared format: jpg)
  • avif-to-png → score 0.0 (no shared formats)

The component

export default function RelatedConversions({ from, to }) {
  const current = `${from}-to-${to}`;

  const ranked = SLUGS
    .filter(slug => slug !== current)
    .map(slug => ({ slug, score: scoreRelation(current, slug) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 8);

  return (
    <section>
      <h2>Related Conversions</h2>
      {ranked.map(({ slug }) => (
        <a key={slug} href={`/${slug}`}>
          {slug.replace('-to-', ' to ').toUpperCase()}
        </a>
      ))}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

What's next

Planning to weight by GSC impression data — pages that already rank should get higher weight in related links. That closes the feedback loop: organic traffic signals inform which related conversions to surface.

Building in public at convertifyapp.net.

Top comments (0)