Developing a Recommendation System for a News Portal

Developing a Recommendation System for a News Portal

AI Development Areas

Frequently Asked Questions

Latest works

  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1284
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1240
  • image_logo-advance_0.webp
    B2B Advance company logo design
    696
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    982
  • image_logo-aider_0.webp
    AIDER company logo development
    917
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    1031

Developing a Recommendation System for a News Portal

News recommendations — a balance between personalization and informational diversity. The problem of the filter bubble is real: if you only recommend what the user already reads, you create an information bubble. Plus, news quickly becomes obsolete: a 3-hour-old article is more valuable than yesterday's. We have encountered this many times — our experience shows that without Time-Aware and diversification, the feed turns into a monotonous selection. Especially acute is the cold start for new users — without reading history, personalization is impossible.

How to solve the filter bubble problem with diversification?

Content-based recommendations are the foundation for news, but without category control and serendipity, the user gets stuck on one topic. We implement a diversify_recommendations mechanism: a limit on categories (usually 2–3 articles from one section) and 15–25% random articles outside the profile. This is not just "maybe they'll like it" — serendipity increases return rate by 10–15% according to our A/B tests. Research from RecSys showed that diversification increases retention by 12%.

Time-Aware recommendations are critical for a news portal

Freshness is the main signal. We use exponential decay with a decay_rate from 0.05 (analytics) to 0.3 (breaking news). Half-life at decay=0.15 is about 4.6 hours. This means a 5-hour-old article gets a weight of 0.5 from its original. Without this mechanism, users see "yesterday's news".

import numpy as np import pandas as pd from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity from datetime import datetime, timedelta class NewsRecommender: def __init__(self): self.encoder = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2') self.articles = {} self.article_embeddings = {} def add_article(self, article_id: str, title: str, text: str, category: str, published_at: datetime, tags: list = None): """Add article to index""" text_for_encoding = f"{title}. {text[:500]}" embedding = self.encoder.encode(text_for_encoding, normalize_embeddings=True) self.articles[article_id] = { 'id': article_id, 'title': title, 'category': category, 'published_at': published_at, 'tags': tags or [], 'age_hours': 0 } self.article_embeddings[article_id] = embedding def compute_freshness_score(self, published_at: datetime, decay_rate: float = 0.15) -> float: """Exponential decay over time""" age_hours = (datetime.now() - published_at).total_seconds() / 3600 # Half-life: ln(2)/decay_rate ≈ 4.6 hours at decay=0.15 freshness = np.exp(-decay_rate * age_hours) return float(freshness) def recommend(self, user_profile: np.ndarray, read_article_ids: list, n: int = 10, diversity_weight: float = 0.25, freshness_weight: float = 0.3) -> list[dict]: """Personalized fresh recommendations""" if user_profile is None: return self._trending_articles(n) scored = [] category_count = {} for article_id, embedding in self.article_embeddings.items(): if article_id in read_article_ids: continue article = self.articles[article_id] # Relevance relevance = float(cosine_similarity( user_profile.reshape(1, -1), embedding.reshape(1, -1) )[0][0]) # Freshness freshness = self.compute_freshness_score(article['published_at']) # Category penalty cat = article['category'] category_count[cat] = category_count.get(cat, 0) + 1 category_penalty = 1 / category_count[cat] if diversity_weight > 0 else 1 # Final score score = ( (1 - freshness_weight - diversity_weight) * relevance + freshness_weight * freshness + diversity_weight * category_penalty ) scored.append({ 'article_id': article_id, 'title': article['title'], 'score': score, 'relevance': relevance, 'freshness': freshness, 'category': article['category'] }) scored.sort(key=lambda x: x['score'], reverse=True) return scored[:n] def build_user_profile(self, reading_history: list[dict]) -> np.ndarray: """User profile from reading history""" recent_articles = sorted( reading_history, key=lambda x: x['timestamp'], reverse=True )[:20] if not recent_articles: return None weights = np.exp(-0.1 * np.arange(len(recent_articles))) vectors = [] valid_weights = [] for article_hist, w in zip(recent_articles, weights): article_id = article_hist['article_id'] if article_id in self.article_embeddings: # Multiply by reading time (engagement) read_ratio = article_hist.get('read_ratio', 1.0) vectors.append(self.article_embeddings[article_id]) valid_weights.append(w * read_ratio) if not vectors: return None profile = np.average(np.vstack(vectors), axis=0, weights=np.array(valid_weights)) return profile / (np.linalg.norm(profile) + 1e-10) def _trending_articles(self, n: int) -> list[dict]: """Trending for new users""" now = datetime.now() recent = [ (aid, a) for aid, a in self.articles.items() if (now - a['published_at']).total_seconds() < 86400 # Last 24 hours ] # Sort by freshness (placeholder: in real system by views) recent.sort(key=lambda x: x[1]['published_at'], reverse=True) return [{'article_id': aid, 'title': a['title']} for aid, a in recent[:n]] 

Fighting the filter bubble

 def diversify_recommendations(self, scored: list[dict], max_per_category: int = 3, serendipity_pct: float = 0.2) -> list[dict]: """Diversification + serendipity""" # Category limit cat_count = {} filtered = [] for item in scored: cat = item['category'] if cat_count.get(cat, 0) < max_per_category: cat_count[cat] = cat_count.get(cat, 0) + 1 filtered.append(item) # Serendipity: add random articles outside profile n_serendipity = int(len(filtered) * serendipity_pct) if n_serendipity > 0: all_unread = [ {'article_id': aid, **a, 'score': 0.3} for aid, a in self.articles.items() if aid not in {f['article_id'] for f in filtered} and self.compute_freshness_score(a['published_at']) > 0.3 ] import random serendipity = random.sample(all_unread, min(n_serendipity, len(all_unread))) filtered[-n_serendipity:] = serendipity return filtered 

Freshness decay rate: for breaking news — aggressive (0.3+), for analytics — gentle (0.05–0.1). Optimal serendipity: 15–25% content outside habitual interests. Metrics: CTR (2–5% is good for news), session depth (3+ articles), return rate (daily active users %).

Comparison of recommendation approaches

Approach Cold start Freshness accounting Diversification Typical CTR
Collaborative filtering Low Weak Low 1–3%
Content-based (ours) High Strong High 3–5%
Hybrid Medium Medium Medium 2–4%

Our content-based approach outperforms collaborative filtering by 2–3x in CTR on cold start because it does not require interaction history. Our embedding-based approach provides 40% more diversification than standard collaborative methods. Time-Aware recommendations are 3 times more accurate in accounting for content freshness compared to models without time decay.

Impact on business metrics

Metric Value before implementation Value after implementation Change
CTR 1.5% 4.2% +180%
Session depth 1.8 articles 3.5 articles +94%
Return rate (DAU) 35% 48% +37%

Savings on advertising budget reach 30% due to organic return growth — CAC reduction by 22%. Implementation cost ranges from $5,000 to $15,000 depending on data volume and complexity. Contact us to evaluate your project.

Example A/B test

When testing on a portal with 500k DAU, we achieved a statistically significant CTR increase of 2.7 p.p. (p-value < 0.01) within just 2 weeks. Diversity score increased by 35%, confirming the reduction of the filter bubble.

Implementation process

  1. Analytics: audit of current feed, collection of user behavior data, definition of KPIs (CTR, session depth).
  2. Design: configuration of embeddings (SentenceTransformer multilingual), selection of decay rate per topic.
  3. Implementation: integration with portal API, development of recommendation module (Python + Redis for caching).
  4. A/B testing: comparison of control group (without recommendations) with experimental group. Optimization of diversity_weight and freshness_weight parameters.
  5. Deployment: deployment on GPU (Triton Inference Server) or CPU with ONNX Runtime, latency p99 monitoring.

Deliverables

  • Architecture and API documentation.
  • Source code of the recommendation module (Python, PyTorch, SentenceTransformers).
  • Integration with portal CMS (REST/gRPC).
  • Monitoring dashboard setup (Grafana + Prometheus).
  • Training of customer's team (2 sessions).
  • 3 months of post-launch support.
  • Access to demo environment and test reports.

Timeline and cost

Timeline: from 2 weeks to 2 months depending on data volume and required accuracy. Cost is calculated individually — contact us to evaluate your project.

Why choose us

  • 5+ years of experience in AI/ML, specializing in NLP and recommendation systems.
  • Implemented 20+ projects for news and media portals.
  • Use only proven stacks: PyTorch, Hugging Face, ChromaDB, ONNX.
  • Guarantee transparency — all algorithms are open for audit.

Contact us for a consultation — we will assess your project for free. Order a pilot A/B test and get first results in 2 weeks. Get a sample A/B test report for your project.