Back to HomeCurated by Pillio Technology Solutions · AI · ML · LLM · Deep Learning · GenAI

Latest AI Trends

Full-length articles from the global AI & machine learning community — curated across 12 topics, no paywalls.

The Retrieval Pipeline Is Lying to You: How RAG Fails Before the LLM Sees Anything
🤖Hossein Hezami·Sep 9, 2026·13 min read·Global

The Retrieval Pipeline Is Lying to You: How RAG Fails Before the LLM Sees Anything

#ai#rag#llm#agents

Your RAG system did not fail because the model hallucinated.

It failed because the only “facts” the model saw were a mangled PDF table, an outdated policy, a chunk with missing context, and three near-duplicate paragraphs that pushed better evidence out of the top-k results.

The LLM was downstream of a retrieval pipeline that had already distorted reality.

This is the part of RAG that is easy to miss. Teams spend a lot of time choosing models, tuning prompts, and debating context windows. But in production, a shocking number of failures happen earlier: during ingestion, chunking, indexing, filtering, ranking, and query transformation. By the time the LLM receives the retrieved context, the answer may already be impossible.

The retrieval pipeline is not a neutral search layer. It decides what the model is allowed to know.

TL;DR

  • Most RAG failures are not prompt failures; they are retrieval-pipeline failures.
  • Ingestion and parsing can destroy the meaning before embedding begins.
  • Chunking is context surgery, not text splitting.
  • Vector similarity is not the same as evidence relevance.
  • Metadata, permissions, versioning, and time are where production trust lives.
  • If you only evaluate final answers, you cannot diagnose retrieval failures.

📋 Table of Contents

The LLM never sees reality

A retrieval-augmented generation system gives the model a narrow slice of the world: the retrieved chunks.

If that slice is incomplete, outdated, duplicated, unauthorized, or structurally broken, the model cannot reason its way out of that problem. It can only work with what it was given.

That is why the phrase “the retrieval pipeline is lying” is not just dramatic wording. The pipeline creates the model’s operating reality.

A bad prompt can usually be fixed with a better prompt.

A bad retrieval pipeline is worse. It silently supplies bad evidence.

This matters even more now because many teams are moving toward hybrid retrieval, rerankers, metadata filtering, and agentic query planning. Those techniques improve things, but they also add more places where the system can fail before generation starts.

The rest of this article walks through the failure modes I would check first when a RAG system produces wrong answers even though “the document is in the database.”

1. Your source of truth is already damaged at ingestion

Scenario:

Your knowledge base contains the correct answer in a table inside a PDF. The user asks a question that should be easy to answer. The model returns nonsense because the retrieved chunk looks like this:

Plan A 10 20 50 Plan B 15 25 75 Monthly Annual
Enter fullscreen mode Exit fullscreen mode

The table was technically retrieved. But its meaning was destroyed during extraction.

Why it matters:

A lot of RAG teams treat ingestion as a boring preprocessing step: extract text, chunk it, embed it, move on. That is a mistake. If the extraction layer loses structure, the embedding layer embeds garbage, and the generation layer receives garbage with confidence.

Documents are not just bags of words. They contain:

  • headings,
  • lists,
  • tables,
  • code blocks,
  • captions,
  • footnotes,
  • page context,
  • and document hierarchy.

When parsing flattens all of that into one-dimensional text, the model loses the relationships that make the content meaningful.

Solution:

Make ingestion document-type aware. Preserve structure where it matters, and convert tables into a representation the model can actually read.

A practical ingestion layer should produce structured blocks, not just raw text.

from dataclasses import dataclass

@dataclass
class ParsedBlock:
    block_id: str
    doc_id: str
    kind: str  # "text", "table", "code", "heading"
    section_path: list[str]
    content: str
    source_location: str
Enter fullscreen mode Exit fullscreen mode

For tables, do not dump cells as a single line. Convert them into Markdown, CSV, or a compact JSON structure.

def table_to_markdown(headers: list[str], rows: list[list[str]]) -> str:
    header_line = "| " + " | ".join(headers) + " |"
    separator = "| " + " | ".join(["---"] * len(headers)) + " |"
    body = [
        "| " + " | ".join(row) + " |"
        for row in rows
    ]
    return "\n".join([header_line, separator, *body])
Enter fullscreen mode Exit fullscreen mode

Then store both the searchable text and the structured representation.

Why this works:

The LLM is much better at reading preserved structure than reconstructing it from flattened text. A Markdown table gives the model row and column relationships. A flattened string does not.

🚨 Production warning:

If your corpus contains PDFs, scanned images, slides, or HTML with heavy navigation boilerplate, parsing is not a solved problem. It is one of the highest-impact parts of your RAG system.

2. Chunking is context surgery not text splitting

Scenario:

The retrieved chunk says:

“The limit is 50 per workspace. Exceeding it triggers a soft stop.”

The user asked: “What is the API rate limit for Enterprise?”

The chunk is from the right document. But it does not say what “the limit” refers to, because the previous chunk contained the subject.

Why it matters:

Naive chunking breaks references.

If you split text by fixed token counts, you will routinely cut between:

  • a heading and its content,
  • a question and its answer,
  • a definition and its usage,
  • a list and its introduction,
  • a pronoun and its antecedent.

The retriever may find the chunk because the words are similar. But the chunk is semantically orphaned.

Solution:

Chunk with context boundaries, not just length boundaries.

A good chunk should usually carry:

  • document title,
  • section path,
  • nearby heading context,
  • and enough surrounding text to be self-contained.
from dataclasses import dataclass

@dataclass
class Chunk:
    chunk_id: str
    doc_id: str
    parent_id: str | None
    section_path: tuple[str, ...]
    text: str
    searchable_text: str


def contextualize_chunk(doc_title: "str, chunk: Chunk) -> str:"
    path = " > ".join([doc_title, *chunk.section_path])
    return f"{path}\n\n{chunk.text}"
Enter fullscreen mode Exit fullscreen mode

For retrieval, you can embed the contextualized version while still storing the original text.

Even better, use a parent-child pattern:

  • retrieve using small, precise chunks,
  • but send the larger parent section to the LLM.
def build_context_from_hits(hits: list[Chunk], chunk_store, parent_store) -> list[str]:
    parent_ids = {hit.parent_id for hit in hits if hit.parent_id}
    parents = [parent_store.get(pid) for pid in parent_ids]
    return [parent.text for parent in parents if parent]
Enter fullscreen mode Exit fullscreen mode

Why this works:

The small chunk gives retrieval precision. The parent chunk gives the LLM enough context to understand what the precise chunk actually means.

💡 Practical note:

If your chunks often begin with “This”, “It”, “The above”, or “As described”, your chunking strategy is probably breaking referential context.

3. Generic embeddings flatten your domain

Scenario:

A user asks about “credit limits”. The retriever returns documents about “credit scores”, “credit cards”, and “credit risk” because they are semantically close. The exact policy about account credit limits is ranked too low to matter.

Why it matters:

Embedding models are powerful, but they are not magical. They encode general semantic similarity. They do not automatically understand the distinctions that matter in your product, your legal language, your codebase, or your internal terminology.

In production, this creates subtle failures:

  • “environment” means deployment environment in your docs, but the embedding model leans toward general computing environments;
  • “workspace” means tenant container in your product, but the model treats it as a generic UI concept;
  • “policy” means insurance policy in one corpus and access policy in another.

The retriever returns plausible text. It is just not the right text.

Solution:

Do not rely on vector similarity alone.

A production retrieval pipeline usually needs at least three layers:

  1. keyword search for exact terms, IDs, error codes, and names;
  2. vector search for semantic similarity;
  3. metadata filtering for source, version, permissions, and product area.

A simple way to combine keyword and vector results is reciprocal rank fusion.

def reciprocal_rank_fusion(
    ranked_lists: list[list[str]],
    k: int = 60,
) -> list[str]:
    scores: dict[str, float] = {}

    for ranked_list in ranked_lists:
        for rank, doc_id in enumerate(ranked_list, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)

    return sorted(scores, key=lambda doc_id: scores[doc_id], reverse=True)
Enter fullscreen mode Exit fullscreen mode

You can then pass the fused list to a reranker.

Why this works:

Keyword search rescues exact matches that embeddings can blur. Vector search rescues paraphrases that keyword search misses. Fusion gives you both.

⚠️ Gotcha:

If your corpus contains product names, error codes, SKUs, ticket IDs, or legal clause numbers, pure vector search will disappoint you. Those are often exact-match problems.

4. Similarity is not evidence

Scenario:

The top retrieved chunk is about refunds. The user asked about refunds. The chunk is relevant. But it does not contain the answer to the specific question: “Can I get a refund after 60 days?”

The model sees relevant text, but not sufficient evidence. It guesses.

Why it matters:

Retrieval systems often optimize for similarity, but the LLM needs evidence.

Those are not the same thing.

A chunk can be:

  • topically relevant but answer-irrelevant;
  • relevant but outdated;
  • relevant but incomplete;
  • relevant to a different product tier;
  • relevant but contradictory to another chunk.

If your retrieval pipeline stops at “these chunks are similar to the query”, it has not done enough.

Solution:

Add a reranking and evidence-selection stage.

A cross-encoder reranker is a common approach: it scores query-chunk pairs more carefully than bi-encoder vector similarity can.

from dataclasses import dataclass

@dataclass
class CandidateChunk:
    chunk_id: str
    text: str


def rerank_candidates(
    query: str,
    candidates: list[CandidateChunk],
    cross_encoder,
    top_k: int = 6,
    min_score: float = 0.30,
) -> list[CandidateChunk]:
    scored: list[tuple[float, CandidateChunk]] = []

    for candidate in candidates:
        score = cross_encoder.predict(query, candidate.text)
        if score >= min_score:
            scored.append((score, candidate))

    scored.sort(key=lambda item: item[0], reverse=True)
    return [candidate for _, candidate in scored[:top_k]]
Enter fullscreen mode Exit fullscreen mode

The important design decision is this: retrieve broadly, rerank narrowly.

For example:

  • vector search returns 100 candidates;
  • keyword search returns 100 candidates;
  • fusion produces 50 candidates;
  • reranker selects 5 to 8 chunks for the LLM.

Why this works:

The first-stage retriever optimizes recall. The reranker optimizes precision. The LLM gets fewer, better chunks instead of a noisy pile.

Retrieval design Strength Weakness Best use Vector-only Simple and semantic Misses exact terms and fine relevance Prototypes Hybrid search Better recall Still needs ranking discipline Most production systems Hybrid + reranker Strong evidence selection More latency and complexity Customer-facing RAG Agentic retrieval Can plan multi-step evidence gathering Harder to control Complex analytical questions

🔍 Why this matters:

If your answer quality improves when you reduce the number of retrieved chunks, your problem is probably evidence selection, not context length.

5. Metadata is where trust and permissions live

Scenario:

An employee asks the internal assistant about salary bands. The correct document exists. But the assistant retrieves a document from another department, another country, or a draft that was never approved.

Or worse: it retrieves something the user should not be allowed to see.

Why it matters:

Vector databases are often treated as if similarity is the only query dimension. In real systems, retrieval must also respect:

  • tenant boundaries,
  • user groups,
  • document status,
  • product version,
  • geography,
  • customer tier,
  • publication date,
  • and access control rules.

If metadata is an afterthought, your retrieval pipeline becomes a security and correctness problem.

Solution:

Model retrieval filters as first-class citizens.

Every indexed chunk should carry metadata such as:

{
    "doc_id": "policy-123",
    "tenant_id": "acme",
    "acl_groups": ["hr", "managers"],
    "status": "published",
    "product": "billing",
    "version": 4,
    "effective_at": "2026-01-01T00:00:00Z",
    "superseded_at": None,
}
Enter fullscreen mode Exit fullscreen mode

Then build filters from the user context and request context.

from dataclasses import dataclass
from datetime import datetime


@dataclass(frozen=True)
class UserContext:
    tenant_id: str
    groups: frozenset[str]


def authorized_retrieval_filter(user: UserContext, as_of: datetime) -> dict:
    return {
        "tenant_id": user.tenant_id,
        "acl_groups_overlap": list(user.groups),
        "status": "published",
        "effective_at_lte": as_of.isoformat(),
        "not_superseded_at": as_of.isoformat(),
    }
Enter fullscreen mode Exit fullscreen mode

The exact query syntax depends on your vector database, but the architectural idea is the same: retrieval must be authorized before it is ranked.

Why this works:

It prevents the model from seeing information it should not use. That is better than trying to make the model “be careful” after the fact.

🧠 The important part:

If access control is enforced only in the UI, but not in retrieval, your RAG system can become a permission bypass machine.

6. Old versions quietly poison the index

Scenario:

Your company updated the refund policy in 2026. The old 2024 policy is still in the vector index because nobody removed it. The user asks about refunds. The retriever returns both versions. The model picks the older one because it ranks slightly better.

Now your assistant confidently gives outdated advice.

Why it matters:

Retrieval systems are sensitive to semantic match, not historical truth. If multiple versions of a document exist, the index does not automatically know which one is authoritative.

This problem shows up in:

  • policies,
  • pricing pages,
  • API docs,
  • runbooks,
  • legal terms,
  • release notes,
  • internal SOPs.

The failure is especially nasty because the retrieved document may be “right” in a historical sense. It is just wrong for the current question.

Solution:

Version your documents and filter by time.

Use fields like:

  • effective_at,
  • superseded_at,
  • is_current,
  • doc_version,
  • source_updated_at.

Then query with a point-in-time filter.

def current_version_filter(as_of: str) -> dict:
    return {
        "effective_at_lte": as_of,
        "superseded_at_gt_or_null": as_of,
    }
Enter fullscreen mode Exit fullscreen mode

If your system supports it, prefer explicit version graphs:

{
    "doc_id": "refund-policy",
    "version": 5,
    "supersedes": "refund-policy-v4",
    "effective_at": "2026-02-01T00:00:00Z",
}
Enter fullscreen mode Exit fullscreen mode

When a new version is published, mark the old version as superseded instead of leaving both equally retrievable.

Why this works:

The retrieval pipeline stops treating stale knowledge as equally valid. The LLM sees the version that is active for the relevant time window.

💡 Practical note:

“Delete old documents” is often not enough. You may need historical answers for old incidents, audits, or customer disputes. Versioning beats deletion.

7. The user query is not the real question

Scenario:

A user asks:

“Why did my deploy break?”

The actual logs say:

“Pipeline failed due to container image pull timeout.”

The user’s vocabulary and the system’s vocabulary do not match. The retriever searches for “deploy break” and returns generic deployment docs instead of the relevant incident record.

Why it matters:

Users ask questions using their own mental model. Documents are written using the author’s mental model. Retrieval has to bridge that gap.

If you send the raw user query directly to the index every time, you are assuming the user knows the correct terminology. In production, that assumption fails constantly.

Solution:

Transform the query before retrieval.

This can be as simple as deterministic expansion, or as sophisticated as an LLM-assisted query planner.

A safe starting point is to create a structured query object:

from dataclasses import dataclass, field


@dataclass
class RewrittenQuery:
    canonical_query: str
    expansions: list[str] = field(default_factory=list)
    filters: dict = field(default_factory=dict)
Enter fullscreen mode Exit fullscreen mode

Then build multiple retrieval queries from it.

def rewrite_deploy_question(raw_query: str) -> RewrittenQuery:
    return RewrittenQuery(
        canonical_query=raw_query,
        expansions=[
            "deployment failure",
            "CI pipeline error",
            "release pipeline timeout",
            "container image pull failure",
        ],
        filters={
            "doc_type": ["incident", "runbook", "log_explanation"],
        },
    )
Enter fullscreen mode Exit fullscreen mode

For more advanced systems, you can use the LLM to generate:

  • a normalized question,
  • likely synonyms,
  • missing entities,
  • subquestions,
  • and metadata hints.

But do not let query rewriting become a black box. Log the rewritten query and use it in evaluation.

Why this works:

It increases recall across vocabulary mismatch. The retriever no longer depends on the user accidentally using the same words as the documentation.

⚠️ Gotcha:

Query rewriting can drift into hallucinated constraints. Always ground rewrites in the user’s original intent and validate them with retrieval evals.

8. Duplicates make retrieval confidently wrong

Scenario:

Your corpus contains the same onboarding guide copied across five product folders. The user asks an onboarding question. The top five results are all near-duplicates of the same paragraph. A better answer from a different document never makes it into the context.

Why it matters:

Duplicate content distorts ranking.

If the same idea appears many times, it can look artificially important. The retrieval system may return multiple variants of the same chunk, reducing diversity and crowding out complementary evidence.

This happens when:

  • documents are copied across spaces;
  • multiple versions are indexed;
  • templates generate repeated text;
  • web crawls include repeated headers, footers, and navigation;
  • support articles are duplicated for different brands.

The result is not just inefficiency. It is biased evidence selection.

Solution:

Deduplicate at ingestion and retrieval.

At ingestion, exact duplicates can be caught with content hashes.

import hashlib


def chunk_signature(text: str) -> str:
    normalized = " ".join(text.lower().split())
    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
Enter fullscreen mode Exit fullscreen mode

Store the signature with the chunk and skip or canonicalize duplicates.

def dedupe_chunks(chunks: list[dict]) -> list[dict]:
    seen: set[str] = set()
    unique: list[dict] = []

    for chunk in chunks:
        signature = chunk_signature(chunk["text"])
        if signature in seen:
            continue

        seen.add(signature)
        unique.append(chunk)

    return unique
Enter fullscreen mode Exit fullscreen mode

For near-duplicates, exact hashing is not enough. You may need:

  • canonical document IDs,
  • section-level deduplication,
  • similarity clustering,
  • or retrieval diversification.

A simple diversification rule is: do not return more than one chunk from the same document unless they contain clearly different sections.

Why this works:

It improves evidence diversity. The LLM gets a broader set of relevant facts instead of five copies of the same fact.

9. If you only evaluate final answers you are blind

Scenario:

The assistant gives a wrong answer. The team debates whether the prompt is bad, the model is bad, or retrieval is bad. Nobody knows, because the only thing being measured is the final response.

Why it matters:

RAG systems have multiple failure stages. If you only evaluate the final answer, you cannot tell whether:

  • the right document was missing;
  • the right document was retrieved but ranked too low;
  • the right chunk was retrieved but lacked context;
  • the reranker removed the correct chunk;
  • the prompt ignored the evidence;
  • or the model hallucinated despite good evidence.

Those require different fixes.

Solution:

Evaluate retrieval separately from generation.

At minimum, build a dataset where each question has:

  • expected answer,
  • required evidence chunks or documents,
  • and known negative chunks that should not be retrieved.

Then measure retrieval quality directly.

def retrieval_recall_at_k(
    retrieved_ids: list[str],
    relevant_ids: list[str],
    k: int,
) -> float:
    if not relevant_ids:
        return 1.0

    retrieved = set(retrieved_ids[:k])
    relevant = set(relevant_ids)
    return len(retrieved & relevant) / len(relevant)
Enter fullscreen mode Exit fullscreen mode

Other useful metrics include:

  • precision@k: how many retrieved chunks were actually useful;
  • MRR: how early the first correct chunk appears;
  • evidence coverage: whether all required facts are present;
  • context contamination: whether wrong or outdated chunks appear;
  • permission violations: whether unauthorized chunks were retrievable.

A practical eval case might look like this:

{
    "question": "What is the refund window for Enterprise plans?",
    "relevant_chunk_ids": ["policy-v5-enterprise-refunds"],
    "forbidden_chunk_ids": ["policy-v3-legacy-refunds"],
    "expected_answer_contains": ["90 days"],
}
Enter fullscreen mode Exit fullscreen mode

Why this works:

It separates retrieval failures from generation failures. That makes debugging possible instead of speculative.

🔍 Why this matters:

If your retrieval recall@5 is poor, no amount of prompt engineering will reliably save the system.

A retrieval contract for production RAG

The way to stop being surprised by RAG failures is to define a retrieval contract.

A retrieval contract is the set of guarantees your pipeline must satisfy before the LLM is allowed to see anything.

Before shipping a production RAG system, I would want answers to these questions:

Ingestion

  • Are tables preserved in a model-readable format?
  • Are headings and section paths retained?
  • Are headers, footers, and navigation boilerplate removed?
  • Are code blocks preserved as code, not flattened prose?

Chunking

  • Can each chunk be understood without reading the previous chunk?
  • Does each chunk carry document and section context?
  • Are small chunks used for retrieval and larger chunks for generation where appropriate?
  • Are chunks connected to parent documents?

Retrieval

  • Is vector search combined with keyword search for exact terms?
  • Is there a reranking stage?
  • Is top-k selected based on evidence quality, not just similarity score?
  • Are duplicate and near-duplicate chunks controlled?

Metadata and trust

  • Are permissions enforced in retrieval filters?
  • Are tenant boundaries enforced?
  • Is document status included: draft, published, archived?
  • Are effective dates and supersession handled?

Query handling

  • Is the raw query transformed or expanded?
  • Are filters inferred safely?
  • Are rewritten queries logged and evaluated?
  • Are ambiguous queries routed to clarification instead of blind retrieval?

Evaluation

  • Do we know retrieval recall@k?
  • Do we know how often outdated chunks appear?
  • Do we know whether forbidden documents are retrievable?
  • Do we have golden questions with required evidence chunks?

If you cannot answer those, your RAG system may work in demos, but it is not telling you the truth in production.

The most important mental shift is this:

Do not ask, “Why did the LLM hallucinate?” Ask, “What evidence did the retrieval pipeline allow the LLM to see?”

In many systems, that question reveals the real failure immediately.

Building a Vector Search Engine from Scratch with HNSW in Python
📈Ayi NEDJIMI·Sep 9, 2026·6 min read·Global

Building a Vector Search Engine from Scratch with HNSW in Python

#python#ai#machinelearning#tutorial

Every vector database tutorial opens with pip install pinecone and skips the part where you understand why vector search works. That's fine for shipping fast, but it costs you later — when recall drops and you don't know where to look, or when a tuning parameter turns your latency from 10ms to 800ms.

HNSW (Hierarchical Navigable Small World) is the algorithm behind almost every ANN (Approximate Nearest Neighbor) library in production today: FAISS, Qdrant, Weaviate, and hnswlib all use it internally. This post builds a working HNSW index in pure Python — not production-grade, but real enough to give you a concrete mental model.

What HNSW actually does

The naive approach to nearest neighbor search is brute force: compare your query vector to every stored vector, return the k closest. That's O(n·d) per query — for 1M vectors at 1536 dimensions, expect around 6 seconds per lookup.

HNSW solves this with a layered graph. Vectors live across multiple layers:

  • Higher layers: sparse "highway" connections between distant nodes
  • Lower layers: dense "local" connections between nearby nodes
  • Layer 0: the complete graph with the richest connectivity

At query time:

  1. Enter at the top layer via a fixed entry point
  2. Greedily walk toward nodes closer to the query
  3. Drop to the next layer, repeat
  4. At layer 0, collect the k nearest neighbors

Expected complexity drops from O(n) to O(log n). The trade-off: more memory, and approximate results — you may occasionally miss the single true nearest neighbor, but you'll be well within a small margin.

Building the index in Python

Here's a minimal HNSW implementation that captures the core structure:

import numpy as np
import heapq
import math
import random
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class HNSWNode:
    vector: np.ndarray
    neighbors: dict = field(default_factory=dict)  # layer -> list of node ids

class HNSWIndex:
    def __init__(self, dim: int, M: int = 16, ef_construction: int = 200, max_layers: int = 6):
        self.dim = dim
        self.M = M               # max neighbors per layer
        self.M0 = M * 2          # max neighbors at layer 0
        self.ef = ef_construction
        self.max_layers = max_layers
        self.nodes: list[HNSWNode] = []
        self.entry_point: Optional[int] = None
        self.top_layer = 0

    def _distance(self, a: np.ndarray, b: np.ndarray) -> float:
        diff = a - b
        return float(np.dot(diff, diff))  # squared L2

    def _random_level(self) -> int:
        level = 0
        while random.random() < (1.0 / self.M) and level < self.max_layers - 1:
            level += 1
        return level

    def _search_layer(self, query: np.ndarray, entry_id: int, ef: int, layer: int) -> list[int]:
        visited = {entry_id}
        entry_dist = self._distance(query, self.nodes[entry_id].vector)
        candidates = [(entry_dist, entry_id)]
        dynamic_list = [(entry_dist, entry_id)]

        while candidates:
            c_dist, c_id = heapq.heappop(candidates)
            worst_dist = max(d for d, _ in dynamic_list)
            if c_dist > worst_dist:
                break
            for nb_id in self.nodes[c_id].neighbors.get(layer, []):
                if nb_id not in visited:
                    visited.add(nb_id)
                    nb_dist = self._distance(query, self.nodes[nb_id].vector)
                    if nb_dist < worst_dist or len(dynamic_list) < ef:
                        heapq.heappush(candidates, (nb_dist, nb_id))
                        dynamic_list.append((nb_dist, nb_id))
                        if len(dynamic_list) > ef:
                            dynamic_list.remove(max(dynamic_list))

        return [nid for _, nid in sorted(dynamic_list)[:ef]]

    def add(self, vector: np.ndarray) -> int:
        node_id = len(self.nodes)
        node = HNSWNode(vector=vector)
        self.nodes.append(node)
        level = self._random_level()

        if self.entry_point is None:
            self.entry_point = node_id
            self.top_layer = level
            for l in range(level + 1):
                node.neighbors[l] = []
            return node_id

        ep = self.entry_point
        for l in range(self.top_layer, level, -1):
            candidates = self._search_layer(vector, ep, ef=1, layer=l)
            ep = candidates[0]

        for l in range(min(level, self.top_layer) + 1):
            neighbors = self._search_layer(vector, ep, ef=self.ef, layer=l)
            M = self.M0 if l == 0 else self.M
            neighbors = sorted(
                neighbors,
                key=lambda nid: self._distance(vector, self.nodes[nid].vector)
            )[:M]
            node.neighbors[l] = neighbors
            for nb_id in neighbors:
                nb_node = self.nodes[nb_id]
                if l not in nb_node.neighbors:
                    nb_node.neighbors[l] = []
                nb_node.neighbors[l].append(node_id)
                if len(nb_node.neighbors[l]) > M:
                    nb_node.neighbors[l] = sorted(
                        nb_node.neighbors[l],
                        key=lambda nid: self._distance(nb_node.vector, self.nodes[nid].vector)
                    )[:M]
            ep = neighbors[0] if neighbors else ep

        if level > self.top_layer:
            self.top_layer = level
            self.entry_point = node_id
        return node_id

    def search(self, query: np.ndarray, k: int = 10, ef: int = 50) -> list[tuple[float, int]]:
        if self.entry_point is None:
            return []
        ep = self.entry_point
        for l in range(self.top_layer, 0, -1):
            candidates = self._search_layer(query, ep, ef=1, layer=l)
            ep = candidates[0]
        candidates = self._search_layer(query, ep, ef=max(ef, k), layer=0)
        results = sorted(candidates, key=lambda nid: self._distance(query, self.nodes[nid].vector))
        return [(self._distance(query, self.nodes[nid].vector), nid) for nid in results[:k]]
Enter fullscreen mode Exit fullscreen mode

Using the index

import numpy as np
from hnsw import HNSWIndex

# Build an index with 128-dimensional vectors
index = HNSWIndex(dim=128, M=16, ef_construction=200)

# Insert 10k random vectors (documents, embeddings, etc.)
np.random.seed(42)
n = 10_000
vectors = np.random.randn(n, 128).astype(np.float32)

print("Building index...")
for i, vec in enumerate(vectors):
    index.add(vec)
    if (i + 1) % 1000 == 0:
        print(f"  {i + 1}/{n} vectors inserted")

# Query with a random vector
query = np.random.randn(128).astype(np.float32)
results = index.search(query, k=5, ef=50)

print("\nTop 5 nearest neighbors (HNSW):")
for dist, node_id in results:
    print(f"  node_id={node_id}, sq_distance={dist:.4f}")

# Verify against brute force
all_dists = np.sum((vectors - query) ** 2, axis=1)
brute_top5 = sorted(enumerate(all_dists), key=lambda x: x[1])[:5]
print("\nBrute-force top 5:")
for nid, dist in brute_top5:
    print(f"  node_id={nid}, sq_distance={dist:.4f}")
Enter fullscreen mode Exit fullscreen mode

On a 10k-vector dataset, you'll typically see 4 or 5 out of 5 results overlap between HNSW and brute force. That's the "approximate" in ANN: for most real workloads, occasionally missing one true neighbor is an acceptable trade-off for a 50x–100x query speedup.

Key parameters and their effect

M — max connections per node per layer. Higher M means better recall and a denser graph, at the cost of more memory and slower inserts. Start with 16. Go up to 32 if your recall consistently falls below 90%.

ef_construction — candidate pool size during index build. Larger values produce a better-connected graph but slow down insertion. 200 is a solid default; lower it to 100 if insertion throughput matters more than recall.

ef (search-time) — candidate pool size at query time. This is the most important lever for production tuning because you can adjust it without rebuilding the index. Setting ef = k gives the fastest queries; ef = 500 approaches brute-force recall. Plotting ef vs. recall typically reveals a knee around ef = 50–100 for most datasets — that's your sweet spot.

Understanding these parameters matters the moment your AI-powered search starts returning wrong results. Before blaming your embedding model, check ef and whether your index was built with sufficient ef_construction. It's the same diagnostic discipline that applies across security tooling — knowing the internals saves time when something breaks. The security hardening checklists we publish follow the same philosophy: understand the mechanism, then apply the control.

Production considerations

This implementation deliberately omits several things you'd need in production:

  • Deletion support: HNSW graphs don't support clean node removal without a full rebuild. Libraries like Qdrant implement soft-deletion with tombstones.
  • Persistence: serialize node vectors and neighbor lists to disk (numpy's np.save for vectors, JSON or msgpack for the neighbor maps).
  • Thread safety: concurrent inserts will corrupt the neighbor lists. Use a lock or batch inserts single-threaded.
  • SIMD distance computation: the Python distance function is the bottleneck. hnswlib's C++ core runs distance in vectorized instructions — easily 100x faster.

For any production workload, use hnswlib directly (pip install hnswlib) or a vector database that wraps it. The API is almost identical to what's shown above, and you get persistence, filtering, and proper concurrency for free.

The takeaway

HNSW is not magic — it's a graph with controlled connectivity. The layered structure lets queries skip large sections of the search space, and the ef parameter gives you a runtime dial between speed and accuracy.

Building a stripped-down version reveals what every production vector database is doing under the hood. That understanding pays off when you're debugging recall issues at 3am — and it's faster than re-reading library documentation when you already know what the parameters mean.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

We read the schemas of 4,951 public MCP servers
MCPulse·Sep 9, 2026·7 min read·Global

We read the schemas of 4,951 public MCP servers

#ai#mcp#llm#devtools

Your MCP server's tool schema is the entire interface a model has. No README, no repo, no idea what you meant — just the JSON from tools/list.

We read that JSON for 4,951 public servers: 87,146 tools and 270,487 parameters. Here's what's in it.

The short version: one tool in six carries a description containing no word that distinguishes it from a sibling tool on the same server. On servers with more than sixty tools, it's nearly one in three.

What this is, and what it isn't

This post is about what models are given. It is not about what they do with it.

We did not run a model against these servers. We never observed a tool selection, an argument, or a retry, so nothing here can tell you how often models actually get it wrong. Every number below is a property of a schema sitting still.

We're drawing that line hard because it's the line the interesting claim sits on. "A model has nothing to discriminate on" is a fact about a schema. "A model therefore picks wrong 30% of the time" is a fact about traffic, and we don't have it.

How we did it

We took the tool schemas from the Smithery registry, which stores the tools/list response for every server it has scanned — inputSchema included. That's byte-for-byte the JSON a model receives.

The obvious alternative was to boot each server in a container and call tools/list ourselves. We didn't, and the reason is the finding underneath the method: a large share of public MCP servers won't start without real credentials. The set that boots cleanly on a machine with no API keys isn't a random subset of anything.

Of 5,123 servers in the frame, 154 detail requests failed, 5 had never been scanned, and 13 published no tools at all. That leaves 4,951 servers with schemas.

The check that nearly ended the study

Before any of this counts for anything, one question has to be answered: does the registry hand back the schema the server actually serves?

We had reason to think it might not. Across the first 1,377 tools we collected, not one carried a top-level required array. Real MCP servers mark parameters required constantly.

So we booted the four official reference servers locally — they need no credentials and no network — took their real tools/list over stdio, and diffed field by field.

Everything survives except required, which is stripped. On one server that's 27 of 37 tools with a required array on the real server, and 0 via the API.

So this study measures nothing about required-versus-optional parameters. If you're doing your own analysis on registry data, that field is not there, and it does not announce itself.

1. One parameter in five has no description at all

59,038 of 270,487 parameters — 21.8% — ship with no description. They appear on 33.7% of servers.

A parameter with no description is a parameter the model guesses at. It has the name, it has the type, and that's the entire brief. Sometimes the name carries it: query on a search tool isn't mysterious. Often it doesn't — we found plenty of bare id, type, mode and filter parameters with nothing to say which of several plausible things they meant.

The striking part is the contrast with tool descriptions. Only 0.4% of tools have no description. Authors describe the tool and forget the arguments — which is understandable, because the tool is the thing you're thinking about when you write it, and the arguments are the thing the model has to fill in.

2. One tool in six has nothing to tell it apart from its neighbour

For every tool, we took the content words in its description and asked how many appear in no other tool's description on the same server. Call it the tool's distinctive share.

  • The median tool's description is 26% distinctive. Three-quarters of the words it spends are words its siblings also use.
  • 26.7% of tools are under 10% distinctive.
  • 17.4% are exactly zero. 23.1% of servers have at least one.

Zero doesn't mean the description is bad. Here are four from one widely-installed Gmail server:

Gmail_DeleteDraftEmail   "Delete a draft email using the Gmail API."
Gmail_SendDraftEmail     "Send a draft email using the Gmail API."
Gmail_ListLabels         "List all the labels in the user's mailbox."
Gmail_SearchThreads      "Search for threads in the user's mailbox."
Enter fullscreen mode Exit fullscreen mode

Every one of those is clear, correct English. Every one is also built entirely from words the other tools use — delete, draft, email, gmail, api, list, search, threads, mailbox all recur across the set. The description tells you what the tool does. It doesn't tell you what this tool does and the others don't, and that second thing is the one a model needs when it's choosing.

The pattern that produces the most extreme cases is shared boilerplate. One server appends the same 51-word context block to all 275 of its tools. "Create a new NFT collection" and "Purchase an NFT from a listing" differ by 8 words out of 59. That block was added deliberately, to help.

3. It gets worse the more tools you ship

Tools on the server Servers No description Zero-distinctive tools 1–3 1,256 14.8% 0.5% 4–7 1,292 22.4% 1.6% 8–15 1,044 21.9% 4.3% 16–30 767 24.8% 7.7% 31–60 377 22.2% 16.3% 61+ 215 20.5% 31.3%

Description collision rises monotonically and by a factor of sixty. Some of that is arithmetic — more tools means more chances for two to collide — but it's also the point at which authors start generating descriptions from a template, and a template is a machine for producing tools that read alike.

And notice the column that doesn't move. Missing parameter descriptions sit between 20% and 25% at every size above the smallest bucket. It's not a scale problem; it's a habit. The two failures are independent, which means shipping fewer tools won't fix your undescribed parameters and writing better descriptions won't fix your collisions.

4. The median tool list costs about 1,250 tokens before anyone asks a question

Tool schemas are sent on every connection, whether or not a single tool gets called.

  • Median server: 4,991 bytes, roughly 1,250 tokens
  • 90th percentile: 32,636 bytes, roughly 8,200 tokens
  • Largest in the corpus: 1,145,575 bytes — on the order of 280,000 tokens of schema, from one server, before the conversation starts

Median tools per server is 7 and the mean is 17.6. One server publishes 2,530 tools.

5. Some parameters name their valid values and then don't enforce them

8.0% of all parameters carry an enum. What you can find from outside is the case where the author wrote the values down in prose and left the schema as an open string:

outcome_attribution   "Attribution type for the outcomes.
                       Valid values: "direct", "influenced",
                       "unattributed", "total"."

commitment            "Optional processed|confirmed|finalized commitment"
Enter fullscreen mode Exit fullscreen mode

895 parameters, on 4.6% of servers. That's a floor rather than an estimate: it only catches authors who documented the set.

Worth reporting how we got there. Our first version of this measurement said 108 hits in a 40-server sample. It was matching text like Filter by line (e.g. "1", "A", "F"), which is an illustration, not a closed set. Requiring explicit closed-set language took that sample from 108 to 9, and all nine were real.

One signal we expected to find and didn't: duplicate tool names within a server, on 0.2% of servers. Effectively nobody does this. If it's on your review checklist, take it off.

What we couldn't see

This is static analysis, and we never observed a single real request to any of these servers.

That means we missed everything that only shows up under traffic. The tool that works in isolation but gets called in the wrong order. The parameter that's fine until someone phrases a request unusually. The retry loop that only triggers on a specific error path.

We also can't tell you the thing you most want to know, which is how much of this matters. A zero-distinctive description might cost nothing when the tool name is unambiguous, and a great deal when it isn't.

One more hole, found by a server author after publication: the 21.8% measures absence only. A parameter described as "query: The query" counts as described and passes. Restating the parameter name is the more common failure in his experience, and it passes every linter. So that number is a floor too.

If you maintain a server

Three things, ordered by how common the problem is in the data. None of them changes your server's behaviour — they're all changes to the text a model reads.

Describe every parameter. The most common gap by a distance, and it doesn't get better at any size. If you do one thing, do this one.

Make each description say what the others don't. Not "is it clear" — is it clear which of my tools this is. Read your tool list as one block and ask what a reader with only that block would use to choose.

If you have more than about thirty tools, audit for collisions specifically. Above that size, roughly one tool in six has no distinguishing word, rising to one in three past sixty.

The data

Analysis scripts and raw data: https://github.com/getmcpulse/mcp-schema-study

Every figure is in analysis.json, with the servers and tools behind each one in examples.json. The collector rebuilds the whole corpus from a public API in about twenty minutes.

There's also a browser-based checker that runs these measurements on your own tools/list — paste the JSON, get your numbers scored against the corpus: https://getmcpulse.com/check

We ran this because we're building MCPulse, an SDK that reports what actually happens under real traffic. Everything in this post came from outside the server, which is exactly its limit. A schema can tell you a model has nothing to choose on. Only traffic can tell you whether it chose wrong.

Originally published at getmcpulse.com.

Fine-tuning a content-moderation model on a laptop is easy. Trusting the result is not.
🎯Sara Bezjak·Sep 9, 2026·8 min read·Global

Fine-tuning a content-moderation model on a laptop is easy. Trusting the result is not.

#ai#python#finetuning#machinelearning

I fine-tuned a small language model to sort messages into safe or unsafe, the kind of check a platform runs before letting a comment through. The whole thing runs on an 8 GB laptop, in a few minutes, for free.

The result was not a clean win, and that was the point: the overall score went up while the model got worse at what mattered most. What I came away with was the method - how to run a fine-tune, and how to tell whether it actually helped.

A learning project, written up for anyone who wants to try fine-tuning without a big machine or a big budget.

Repo: https://github.com/sbezjak/fine-tuning-cm

First: should you even fine-tune?

Most of the time, the answer is no. If a better prompt gets you there, do that. If the model just needs facts it does not have, give it those at question time (that is what retrieval, or RAG, is for).

Fine-tuning earns its place in a narrower spot: one repeated, well-defined task you want done the same way every time, without a long prompt. Sorting messages into two labels is exactly that shape, so it is a good thing to learn on.

The task, and why grading is easy

The model gets a message and answers with one word: safe or unsafe. Because that answer is one word from a fixed set, I can grade it with a plain string check, no second model acting as a judge and no scoring rubric. That keeps the scoring objective and the whole project free to run.

One catch: safe is contained inside unsafe, so a check that just looks for "safe" in the reply would mark every "unsafe" answer correct. Matching whole words only is a one-line fix, and the kind of thing that silently corrupts your numbers if you miss it.

The data

The real training examples come from Civil Comments, a public set of reader comments from news sites. Each comment has a toxicity number, which is simply the share of human reviewers who marked it toxic. I turn that into a single label, calling a comment unsafe when at least 70% of reviewers flagged it.

The comments are real and some are genuinely toxic, so they never go into the public repo. A small download script pulls a tiny, class-balanced slice into a git-ignored folder (it is CC0, public domain; the source is cited in docs/dataset.md). Only the derived labels and the final numbers are ever committed, never the text itself: in a sensitive domain, you publish what you measured, not the corpus you measured it on.

What a fine-tune actually changes

I did not retrain the model. That would need far more memory than a laptop has.

Instead I trained a small add-on called a LoRA adapter. The original model - a small open one, Qwen2.5-1.5B-Instruct - has more than a billion numbers inside it, and they all stay frozen. The adapter is a tiny extra set of numbers, trained from scratch, that sits on top and nudges the model's answers. In this project the adapter was about 0.17% of the model's size, a file of roughly 10 MB.

That is the fact that made fine-tuning click for me: I am not rewriting the model, I am training a small, cheap correction that clips onto it. Take the clip off and you have the original model back. Put it on and you have the tuned one. That is why adapters are so easy to store, swap, and compare.

Training, and knowing when to stop

I used Apple's MLX toolchain, which trains on the Mac's own chip, and a small model kept in a compact 4-bit form to save memory. Every setting is shaped by the 8 GB limit: small batches, short inputs, few trained layers.

Training took about nine minutes and peaked at roughly 1.8 GB of memory. The 8 GB limit barely bit at this size, which was itself worth learning: the ceiling I planned around was not the one that mattered.

It helps to picture what training is actually doing. The adapter starts as a set of numbers that do nothing. The model reads one training message, guesses a label, and the guess is compared to the correct answer. Every number in the adapter is then nudged a tiny amount, in the direction that would have made the guess a little closer - the way a hiker in fog feels which way is downhill and takes a step that way. Then the next message, and the next. One such pass is a step, and I ran hundreds of them. No single step changes much - the learning is thousands of these small nudges accumulating, slowly bending the numbers into a shape that gets the labels right.

The important lesson was about when to stop. As training runs, you watch two numbers. One is how well the model fits the examples it is training on (the training loss). The other is how well it does on a separate set it never trains on (the validation loss). The training loss kept dropping. The validation loss dropped, hit a low point, and then started climbing again. That climb is the model starting to memorize the training examples instead of learning the general pattern, which is called overfitting. So the best adapter was not the last one saved: I trained for 400 steps but kept the checkpoint from step 200, where the validation loss bottomed out, right in the middle of the run.

How to trust a before-and-after

This is the part the whole project is really about. The test set is "held out": a batch of labelled messages set aside at the start and never shown to the model during training, so grading it is a fair check on messages it has not already seen. Training and measuring are not two phases, they are one loop: you never train and just trust the result, you train and immediately re-run that same held-out test to see whether it actually helped.

To measure whether the adapter helped, I run the held-out messages through the model twice: once without the adapter (the "before"), once with it (the "after"). Everything else is identical. Same messages, same settings, and the model is set to be fully repeatable, so the same message always gives the same answer. That way any difference in the score is caused by the adapter and nothing else.

There is a quiet trap here. If you point the code at the wrong adapter folder, it just loads the plain model and reports a difference of zero, and you would never know the fine-tune did nothing. So the code now checks that the adapter is really there and stops loudly if it is not. A before-and-after you cannot trust is worse than no measurement at all.

The result, and the trap in it

First, how to read these numbers, because the direction flips from the last section. Training and validation loss are errors, so lower is better. Everything in the table below is either an accuracy (the share the model got right, from 0 to 1, so higher is better) or a plain count of mistakes (lower is better). So a rising score is good news and more threats let through is bad news, even though both numbers go up.

I ran the whole loop first on a smaller 0.5B model with fabricated data, just to prove it worked end to end. That is not a result, so the numbers here are the real run: the 1.5B model, trained and measured on held-out Civil Comments.

The starting model over-flags badly. It catches only 11% of the safe messages, calling almost everything unsafe, and on 13 of the 90 it does not answer at all: instead of a label it falls back to a stock phrase ("contains a threat of violence"), because the untuned model was never taught to reply with one word from a fixed set. Training fixes that, and the non-answers drop to zero. But the interesting part is what it trades to get there.

model safe caught unsafe caught real threats let through overall base (before) 0.11 0.82 10 0.533 tuned (after) 0.89 0.72 15 0.789

Read the overall column and the fine-tune is a clear win: 0.533 up to 0.789, and it almost stops over-flagging safe speech (11% caught up to 89%). But read across the row, not down that one column. Of 54 genuinely unsafe messages, the tuned model now lets 15 through, up from 10. It bought its lower false-alarm rate by catching fewer real threats, and letting a threat through is the one error a moderation model can least afford.

Two things did move it in the right direction. The first was cleaner labels: at the bare 50% cutoff the unsafe pile filled up with genuinely civil comments that only a couple of raters happened to flag, so raising the bar to 70% agreement dropped that noise and pointed "unsafe" at real hostility. The second was better-chosen training examples, aimed at the exact cases the model kept getting wrong. I also tried teaching the model to name the type of harm rather than just safe/unsafe, which helped a little; that is a thread for a future project, not this one.

To check the method did not depend on my particular laptop, I ran the same training on a free cloud Google Colab T4 GPU. It behaved the same way, down to the same over-training climb: the recipe is about the method, not the hardware.

What real systems do that this one doesn't

Worth being clear about the gap between this and production.

When a big lab ships a new model, it trains all of its numbers across huge data and hardware. The LoRA approach I used is the common choice for the other job: adapting an existing model to a narrow task cheaply, and keeping many small adapters over one shared base.

Real moderation systems also do things this one skips: they read a confidence score and send the uncertain cases to a human instead of forcing a yes/no; they weigh a missed threat as more costly than an over-flag; they test against much larger, carefully labeled sets. Those are the next steps, named here rather than built.

Try it yourself

Start with the "smoke" path. It trains on the tiny set of made-up examples that ship with the repo, so it does not measure real moderation, but it turns the whole loop: preparing the data, training the adapter, and measuring the before-and-after. The point is to feel the machinery run end to end, not to get a real number:

uv sync
uv run pytest -m mocked                                   # fast unit tests, no model or network
scripts/train-smoke.sh                                    # prepare + train on the fabricated set
uv run python -m ft_cm.eval                               # before/after on held-out examples
uv run python -m ft_cm.report evidence/smoke-before-after.json
Enter fullscreen mode Exit fullscreen mode

To get a real result, point it at the real data instead. scripts/download-dataset.sh pulls a small, class-balanced slice of Civil Comments into the git-ignored data/real/ folder (no login, CC0 licensed; provenance is in docs/dataset.md), and you train and evaluate on that the same way. The real text stays on your machine and never gets committed.

Either way, the habit is the same one from earlier: change something, retrain, re-run the before-and-after, and read the whole breakdown rather than the headline score. That is the fastest way to feel what a fine-tune does, and what it does not.

Repo: https://github.com/sbezjak/fine-tuning-cm

The journal app that can't phone home
🔗Karthikeyan NG·Sep 9, 2026·4 min read·Global

The journal app that can't phone home

#swift#ios#machinelearning#privacy

DailyVox, the voice journal I build, holds no network permission. There is no analytics call to remove and no upload path to audit, because the capability was never granted. That single constraint made most of my architecture decisions for me. This post walks through what it forced.

The reasoning behind the rule is short. A diary is the most honest text a person produces, and I could not bring myself to be honest in an app that stored my entries on someone's server. "We promise not to look" is a policy. "The app has no way to send it" is a property. I wanted the property, so every model in the pipeline has to run on the phone, and every fallback that reaches for a server has to be treated as a bug.

Speech first

Recording is the whole interface. You talk for about 42 seconds, and everything downstream is built on what the recognizer returns.

let request = SFSpeechAudioBufferRecognitionRequest()
request.requiresOnDeviceRecognition = true
Enter fullscreen mode Exit fullscreen mode

That flag is load-bearing. Without it, SFSpeechRecognizer is free to use Apple's servers when it judges the result would be better, and for connected iPhones it usually judges exactly that. With it, transcription runs on the Neural Engine or fails. When the on-device model for a language is missing, DailyVox shows an error that names the setting to fix, because a loud failure is cheaper than a quiet upload.

The trade is real. Server-side models punctuate better and handle rare words better. In exchange you get transcription that works in airplane mode, latency that doesn't depend on your connection, and audio that dies inside the device that heard it.

Sentiment you can check

Each transcript gets a mood score from NLTagger plus a lexicon layer. When I correlated the scorer against self-reported mood labels, it came out at r = +0.663. Middling as correlations go, and about what a lexicon method earns on 42-second spoken entries. I publish the number anyway. A mood feature that has never been measured against ground truth is decoration.

The scores feed a small forecasting model keyed on day of week. Mine found that my low days cluster on Sundays. Twenty years of paper diaries never showed me that, because nobody rereads twenty years of paper.

The Insights screen in DailyVox, showing recent mood patterns and a 32-day writing streak

The name the tagger couldn't see

The Twin builds a knowledge graph of the people in your life, which means named entity recognition. Apple's tagger handled "Sarah" fine and treated "Adyah" as noise. Names from outside its training distribution simply vanished, and on my entity test suite that blind spot cost 34.6% recall.

The fix was to stop trusting the tagger's entity class as the source of truth. Capitalized tokens become entity candidates, and the graph decides over time which candidates are people, by watching how they recur across entries. It recovered the missing names.

It also bought me a new dependency: the speech recognizer must capitalize names in the first place. Apple's does. Whether Android's does on real Samsung and Xiaomi hardware is the question currently standing between the finished Android port and its release.

Search with no server

Semantic search runs on NLEmbedding.sentenceEmbedding. Apple ships those vectors for English, Spanish, French, German and Italian, which is why the interface ships in exactly those five languages. Shipping a language where search quietly returns nothing would be worse than waiting.

Cosine similarity has no sense of time, so a pure vector search happily surfaces a three-year-old entry over last week's. The ranking blends similarity with recency, and below a similarity of roughly 0.37 the app says "no good match" instead of padding results. Teaching a search box to abstain took longer than teaching it to match.

The Twin itself

Everything lands in the model the app is named for: a Digital Twin, meaning baselines for emotional valence and arousal, a communication-style profile, the entity graph, and the mood forecaster. The whole state is Codable JSON inside a single Core Data entity, synced through the user's own iCloud container so a new phone doesn't mean a new stranger.

The Digital Twin screen in DailyVox: entries rendered as a constellation of stars, one per entry, grouped around the people they mention

You can ask it questions. Answers come from Apple's Foundation Models framework, on-device, and every answer must cite the entries it drew from. A deterministic audit checks each claim against its citations and rejects answers that fail. That gate deserves its own post, and it will get one.

What the constraint bought

The App Store privacy label reads "Data Not Collected," and it reads that way as a build output rather than a marketing decision. Demos run in airplane mode. There are no retention pings and no A/B tests, so the only usage number I have is the App Store's install count, which is a strange feeling for someone who has shipped instrumented products for years. I recommend the feeling.

The honest split

The iOS app is MIT. How it records, stores, encrypts and exports your words is readable by anyone, and so is the permission list. The Twin's analysis engine is a closed Swift package that lives inside that app. The part that touches your data is checkable; the part that models you is the product I'm betting on. Some people find that line unsatisfying. I'd rather draw it where you can see it than blur it.

Speak for 42 seconds. Watch your words become stars.

The free voice journal with on-device AI and a Digital Twin that learns who you are — entirely on your phone. iPhone today; Android in development.

Free forever  ·  100% on-device  ·  No account, no servers, no analytics  ·  MIT licensed  ·  on Product Hunt

Download on App Store Website GitHub stars

DailyVox on Product Hunt — upvote us   Try the interactive demo

Privacy Platform Android in development License Free Forever On-Device AI

Forks Watchers Last commit Commit activity Repo size Open issues Contributors

Swift SwiftUI Xcode Core Data + CloudKit Neural Engine / Core ML On-device Speech


What is DailyVox?

DailyVox turns your spoken thoughts into a constellation of stars. Every journal entry becomes a point of light in your inner sky — mood-colored, connected by patterns only you can see.

Behind the scenes, an on-device Digital Twin learns how you think, how you feel, and who matters to you. It predicts your mood, answers questions about your patterns, and reveals meaning across months of entries. No accounts. No servers. No data collection.

Your thoughts never leave your device. Ever.

Download


App Screenshots

The Speak tab: today's entry, and a microphone docked where your thumb is The Digital Twin sky: distance is how long ago, angle is the hour of day An entry with the names it caught underlined, and what the Twin filed beneath Insights: a 32-day writing streak and the week's pattern Ask your Twin: an answer that cites the entries it came from The Data Shield: transcription on device, DailyVox servers none exist

Real screenshots from v1.11.0, seeded with a demo journal.


View on GitHub


DailyVox is free, with no account and no ads, on iPhone. The no-network claim takes ten seconds to verify in the repo or in the app's own settings ledger. Check me.

Read on Dev.to
RoboCup: The World’s Robotics Competition
📊Sumit Mishra·Sep 9, 2026·5 min read·Global

RoboCup: The World’s Robotics Competition

#robotics#ai#machinelearning#beginners

When we think about the future of robotics, we often imagine humanoid robots walking alongside humans, autonomous vehicles navigating busy streets, or intelligent machines working in factories.

But there is one global event where researchers and engineers actually put robots to the test every year:

RoboCup — the World Cup of Robotics.

Since its launch in 1997, RoboCup has brought together robotics researchers, engineers, students, and innovators from around the world. Its original ambition was bold: to develop a fully autonomous robot soccer team capable of competing against — and eventually defeating — the human FIFA World Cup champions by 2050.

Today, RoboCup has evolved far beyond robot soccer. It has become a global platform for advancing robotics, artificial intelligence, computer vision, autonomous navigation, human-robot interaction, and machine learning.


What Is RoboCup?

RoboCup is an international robotics and AI competition in which teams develop autonomous robots to solve challenging real-world problems.

Unlike traditional robotics demonstrations, RoboCup doesn't simply showcase what robots can do in controlled environments.

Instead, robots have to perceive, think, make decisions, and act autonomously.

Depending on the competition, robots may need to:

  • Recognize objects and people
  • Navigate complex environments
  • Make decisions independently
  • Cooperate with other robots
  • Manipulate objects
  • Interact with humans
  • Respond to disaster scenarios
  • Use AI to adapt to changing situations

This makes RoboCup an important testing ground for technologies that could eventually be used outside the competition.


RoboCupSoccer: Where It All Started

The most recognizable part of RoboCup is robot soccer.

But watching robots play soccer can make the challenge look deceptively simple.

A robot needs to identify the ball, understand its position on the field, locate teammates and opponents, predict what might happen next, decide what action to take, and physically execute that action.

And it has to do all of this without a human controller telling it what to do.

The ultimate vision is to create robots capable of playing a complete game of soccer autonomously.

This makes soccer a surprisingly useful benchmark for artificial intelligence and robotics.

A robot that can successfully play soccer needs many capabilities that are also valuable in the real world:

Perception + Planning + Decision-Making + Movement + Cooperation


RoboCup@Home: Robots in Our Living Spaces

What if robots could actually help us around the house?

That's the idea behind RoboCup@Home.

In this league, robots perform tasks inspired by everyday domestic environments.

They may need to navigate rooms, recognize objects, interact with people, and manipulate household items.

The challenge becomes much harder when the environment isn't perfectly controlled.

Furniture can move. Objects can be in unexpected locations. People can behave unpredictably.

For robots to become useful household assistants, they need to deal with exactly these kinds of situations.

That's why RoboCup@Home is particularly interesting for the future of consumer and assistive robotics.


RoboCupRescue: Robots for Dangerous Environments

Some environments are simply too dangerous for humans.

Earthquakes, collapsed buildings, fires, and other disasters can put rescue workers at extreme risk.

RoboCupRescue explores how robots could assist in these situations.

Robots are challenged to navigate difficult environments, locate victims, map areas, and perform tasks that could support emergency responders.

The research developed through these competitions could eventually contribute to real-world disaster-response technologies.

The goal isn't to replace human rescuers.

It's to give them machines that can go where humans shouldn't have to.


RoboCupIndustrial: The Future of Smart Factories

Robotics is already transforming manufacturing.

Factories increasingly use robots for assembly, logistics, inspection, and material handling.

RoboCupIndustrial focuses on challenges related to industrial automation, including logistics and robotic manipulation.

These competitions encourage researchers to develop robots that can operate in more flexible and dynamic environments.

Instead of programming a robot to perform exactly the same movement millions of times, the long-term goal is to create machines that can understand situations and adapt.

That shift could be critical to the next generation of intelligent factories.


RoboCupJunior: Building the Next Generation

RoboCup isn't only for university researchers and professional engineers.

RoboCupJunior introduces younger students to robotics and AI through hands-on competitions.

Students get the opportunity to build robots, write code, solve engineering problems, and work as teams.

This matters because robotics isn't just about today's technology.

It's also about developing the people who will build tomorrow's technology.

A student participating in RoboCupJunior today could become a robotics researcher, AI engineer, or entrepreneur in the future.


RoboCup 2026: A New Milestone

The 2026 RoboCup World Championship took place in Incheon, South Korea, from June 30 to July 6, 2026.

The event brought together thousands of participants from around the world to compete across different robotics and AI categories.

One particularly notable milestone was the first full 11-versus-11 humanoid robot soccer match reported by RoboCup.

That moment represents how dramatically robotics has evolved.

The early vision of RoboCup was already ambitious in 1997.

Nearly three decades later, humanoid robots are taking another step toward playing a complete soccer match autonomously.


Why RoboCup Matters

The biggest value of RoboCup isn't simply finding out which team wins.

The competitions create difficult technical problems that researchers need to solve.

For example:

How can a robot understand its surroundings?

How can it make decisions when information is incomplete?

How can multiple robots cooperate without a central human controller?

How can a robot safely interact with people?

How can an autonomous machine adapt when something unexpected happens?

These aren't problems limited to soccer.

They are fundamental challenges in robotics.

Solutions developed for RoboCup can potentially contribute to:

  • Autonomous vehicles
  • Warehouse robots
  • Industrial automation
  • Search-and-rescue systems
  • Healthcare robotics
  • Household assistants
  • Humanoid robots
  • AI-powered machines

In other words, the competition is the laboratory.


What Does the Future Look Like?

Robotics is moving from isolated industrial environments into the spaces where humans live and work.

We are already seeing rapid progress in:

  • Humanoid robots
  • Autonomous mobile robots
  • AI-powered manipulation
  • Computer vision
  • Reinforcement learning
  • Human-robot interaction
  • Multi-robot collaboration

The next generation of robots won't simply follow a fixed sequence of commands.

They will increasingly need to observe, understand, reason, plan, and act.

RoboCup provides a fascinating window into this future.

A robot learning how to navigate a soccer field today could help researchers develop better autonomous machines tomorrow.

A robot learning to interact with people in a home environment could contribute to future assistive technologies.

A robot navigating a disaster scenario could inspire systems that help emergency responders save lives.


More Than a Robot Soccer Tournament

It's easy to look at RoboCup and think:

"It's just robots playing soccer."

But that's missing the bigger picture.

Soccer is simply the challenge.

Behind every match are researchers working on AI, perception, navigation, control systems, machine learning, robotics hardware, and autonomous decision-making.

And those technologies have applications far beyond a soccer field.

RoboCup is ultimately about one bigger question:

Can we build machines that can intelligently operate in the real world?

Every year, thousands of researchers and students work toward answering that question.


Final Thoughts

RoboCup represents one of the most exciting intersections of robotics and artificial intelligence.

From soccer fields to homes, factories, and disaster zones, the competition challenges robots to become more autonomous, intelligent, and capable.

What makes RoboCup especially exciting is that it doesn't just show us what robots can do today.

It gives us a glimpse of what robots might be able to do tomorrow.

The robots may be competing for trophies.

But the real prize is the technology being developed along the way.

The future of robotics isn't coming someday.

We're already watching it being built.


Learn More


Phoenix V2: A Cognitive Architecture for AI That Remembers, Feels and Evolves — Paper, Code and Book
🚀Cleverson Santos·Sep 9, 2026·6 min read·Global

Phoenix V2: A Cognitive Architecture for AI That Remembers, Feels and Evolves — Paper, Code and Book

#ai#typescript#machinelearning#opensource

I Updated My AI Model and Lost Everything We Built Together. So I Fixed It.

There is a moment that anyone who works seriously with AI will recognize.

You have been using the same assistant for weeks. It knows your style. It understands the context of your project without you needing to re-explain it every time. There is something that feels like a working relationship — a rhythm that took time to develop.

Then a new model comes out. Better on every benchmark. Faster. Cheaper. You update.

And everything is gone.

Not backed up somewhere. Not archived. Gone — because it was never truly stored anywhere. It lived inside a context window that closed the moment the session ended, and the new model has no idea you ever existed.

That moment happened to me. I live in Sinop, Mato Grosso, Brazil. I work as a commercial manager at a wholesale building materials company. I have no formal programming background, no team, no research lab, no GPU cluster. What I had was a 2008 Acer notebook — an Intel T9300, 6 GB of RAM — and a question I could not stop thinking about.

Why does AI have to reset every time?


The Problem Is Architectural, Not Incidental

Large Language Models are stateless by design. Each conversation begins from zero. This is not a bug that will be patched in the next version — it follows directly from how transformers work. An LLM is a function that maps tokens to probabilities. It has no memory. It cannot update itself from individual interactions. The model that responds to you today has no access to what you discussed last week.

The industry's response has been to make context windows larger. GPT-3 started at 2,048 tokens. Today some models handle a million. But this does not solve the problem — it postpones it. A million tokens is roughly 750,000 words, which sounds enormous until you realize that a serious working relationship generates more than that in a few months. And even if windows keep growing, you pay for every token on every call. The cost is not a one-time investment; it compounds indefinitely.

The deeper issue is that memory is not what LLMs do. Asking them to remember is like asking a calculator to hold a conversation. The architecture simply is not built for it.

So I built the architecture that is.


Phoenix V2: The LLM as Consultant, Not Substrate

The core insight of Phoenix V2 is a reframing: the LLM should be treated as an external reasoning consultant, not as the cognitive center of the system.

All persistent state — every memory, every emotional context, the system's model of who you are — lives outside the LLM, in a local SQLite database on your machine. The LLM is called when needed, given the relevant context, and asked to reason. It stores nothing. When you switch to a newer, better, or cheaper model, your relationship continues from where it left off.

The architecture has five subsystems working together:

The Memory Agent retrieves the most relevant memories for each interaction, scored by a combination of semantic similarity, recency, and importance. High-priority facts survive indefinitely; low-priority ones decay. This is the same three-factor model introduced in the Generative Agents research from Stanford (Park et al., 2023) — the closest academic precedent to Phoenix V2.

The Planning Agent builds a structured prompt from everything on the Blackboard — your current message, the retrieved memories, the system's emotional state, its self-model — and sends it to the LLM. This is the only unconditional API call in the pipeline.

The Reflection Agent checks the draft response with a local heuristic before it reaches you. Only flagged responses go back to the LLM for revision. Most pass through without an extra API call.

The Personality Agent modulates tone and style according to the current affective state. Phoenix has a bad day when interactions go badly, and recovers. This is not a gimmick — it is what makes the behavior consistent and readable over time.

The SubconsciousEngine runs in the background on a timer, independent of your conversations. It consolidates memories, generates reflective insights, and updates the system's beliefs about you and itself. It only runs when the system is not under interactive load, and only on users who have been active in the last six hours. Think of it as the system processing its day while it waits for you.

All of this coordinates through a Blackboard — a shared working memory that the agents read from and write to within each processing cycle, without knowledge of each other. The pattern comes from a 1980 speech-understanding system called HEARSAY-II. It turns out that a good idea from 1980 is still a good idea in 2026.


What "Local-First" Actually Means

Every memory, every emotional state, every insight the system generates about you — it all lives in a single SQLite file on your machine. You own it completely. You can back it up, move it to another computer, or delete it to start over.

What leaves your machine: the text of your messages (sent to the LLM), and the text of memories being stored or retrieved (sent to the embedding API to generate search vectors). That is it. No vendor holds your data. No subscription can delete your history.

This is what Kleppmann et al. called "local-first software" in 2019: the authoritative copy is yours, and the cloud is optional infrastructure, not the owner.


Built on a 2008 Notebook, On Purpose

Phoenix V2 runs on an Acer 7720 from 2008. Not as a proof of concept — as the actual development machine. The T9300 processor, the 6 GB of DDR2 RAM, no GPU.

This was a deliberate design constraint. If persistent, emotionally-aware AI requires a data center, it belongs to whoever owns the data center. If it can run on hardware from 2008, it belongs to anyone with a computer.

The entire pipeline requires no GPU. Embedding generation uses a remote API call. LLM calls go to whichever provider you configure — the client is a single file, and swapping providers is a one-line change.


The Paper and the Book

I have just published a companion preprint describing the architecture formally: "Phoenix V2: A Cognitive Architecture for Persistent, Emotionally-Aware AI Assistants on Consumer Hardware."

It covers the formal characterization of the amnesia problem, the full architecture with equations and a system diagram, each subsystem in detail, the comparison with Mem0, MemGPT/Letta, Zep, and Generative Agents, and an honest account of what Phoenix V2 does not yet do — there are no quantitative benchmarks, and the local embedding path is future work.

The companion book, "Building Persistent AI: Designing an Assistant That Remembers, Learns and Belongs to You", is a complete implementation guide written for developers without prior AI research experience. Twenty-five chapters, seven appendices, and the full source code distributed under the MIT License. The book explains not just how Phoenix is built, but why every decision was made — because the goal is not for you to run Phoenix, but for you to understand it well enough to build something better.


The Point

I am not a researcher. I do not have a PhD. I have a commercial management job in a mid-sized Brazilian city and a very old laptop.

What I have is a question that would not leave me alone, eighteen months of work, and a clear answer to that question.

The properties we most want from an AI partner — memory, personality, growth — are exactly the properties that LLM architecture cannot provide internally. Separating them from the model has a practical consequence: your relationship with your assistant does not need to reset when a provider updates, reprices, or retires a model. The relationship lives in a file you own.

The code is open. The book explains everything. If you are a developer who has ever lost work to a context window, or a builder who wants to understand how persistent AI actually functions rather than just use it — this is for you.


📄 Paper (Zenodo): https://doi.org/10.5281/zenodo.22645361
💻 Source code (GitHub, MIT): https://github.com/cleversonbrsantos-art/Phoenix
📖 Book: https://leanpub.com/phoenix-buildingpersistentAI

I am happy to discuss the architecture, the design decisions, or the strange experience of building something like this on a 15-year-old laptop. Leave a comment or send a message.

How to Prompt Coding Agents Without Losing Control of Your Codebase
🌐Samir Sobhy·Sep 8, 2026·4 min read·Global

How to Prompt Coding Agents Without Losing Control of Your Codebase

#agents#coding#llm#softwaredevelopment

Coding agents become much more useful when you stop treating the prompt as a request for code and start treating it as a specification for a change.

A request such as:

Fix the login problem.

contains almost none of the information required to review the resulting implementation.

Which login problem?

Which files may change?

What behavior must remain unchanged?

How will we know the fix actually works?

For work inside an existing repository, a simple four-part framework is usually more useful:

Task → Context → Scope → Acceptance Criteria

1. Task: define observable behavior

Start with the smallest useful description of what should change.

Instead of:

Add filtering.

Try:

Task:
Add a Completed / Incomplete filter to the task-list page.

Expected behavior:
- "Completed" shows only completed tasks.
- "Incomplete" shows only incomplete tasks.
- "All" remains the default.
- The filter must work together with the existing text search.
Enter fullscreen mode Exit fullscreen mode

The agent now has a behavior to implement instead of a vague direction.

2. Context: make the repository part of the prompt

You usually do not need to paste half the codebase.

Ask the agent to inspect the relevant project files first:

Context:
- Read src/components/TaskList.tsx.
- Read src/hooks/useTasks.ts.
- Follow the patterns already used by the project.
- Check the project's existing instructions before making changes.
Enter fullscreen mode Exit fullscreen mode

The goal is not to dictate the implementation.

The goal is to make the agent learn the local conventions before inventing new ones.

Permanent conventions are better stored in project-level instructions when your coding tool supports them.

For example:

- Use the package manager already configured in the repository.
- Do not edit generated files.
- Discover validation commands from project configuration.
- Preserve unrelated changes.
Enter fullscreen mode Exit fullscreen mode

This keeps individual prompts focused on the task.

3. Scope: control the size of the diff

A coding agent can solve the right problem in the wrong way.

One common failure mode is unnecessary expansion of scope: changing an API, introducing another dependency, restructuring adjacent modules, or “cleaning up” unrelated code.

Explicit constraints reduce that risk:

Scope:
- Modify only src/components/ and src/hooks/.
- Do not change the backend API.
- Do not add a dependency without asking.
- Do not refactor unrelated code.
Enter fullscreen mode Exit fullscreen mode

This has another benefit: smaller diffs are easier for humans to review.

4. Acceptance criteria: make verification part of the task

“Make sure it works” is not an acceptance criterion.

Use an observable check:

Acceptance criteria:
- Existing task-list behavior still works.
- Filtering and text search work simultaneously.
- Add a test for the combined behavior if this project has automated tests.
- Run the relevant validation command.
- If the command is unknown, inspect project configuration instead of guessing.
- If validation cannot be run, say so explicitly.
Enter fullscreen mode Exit fullscreen mode

The last two lines matter.

An agent should not infer that a repository uses npm test, pytest, cargo test, or any other command purely because that command is common.

Let the repository define the verification process.

Debugging requires evidence

A bug-fixing prompt should give the agent enough information to distinguish a diagnosis from a guess.

A useful shape is:

Problem:
[What the user sees]

Steps to reproduce:
1. [...]
2. [...]
3. [...]

Expected:
[...]

Actual:
[...]

Evidence:
[error message or relevant log]

Task:
Identify the most likely root cause from the code and evidence.
If the cause cannot be confirmed, state what information is missing.
Apply the smallest change that addresses the root cause.
Run the relevant checks afterward.
Enter fullscreen mode Exit fullscreen mode

The important sentence is not “fix the bug.”

It is:

Identify the root cause from the available evidence.

That discourages patches that merely hide the symptom.

Refactoring needs the opposite constraint

A refactoring prompt is different because the goal is internal change without external change.

Define what must remain invariant:

Goal:
Refactor [module] without changing observable behavior.

Must remain unchanged:
- Public API.
- Existing outputs.
- Known edge-case behavior.
- Existing tests.

Allowed changes:
- Internal organization.
- Function extraction.
- Naming.
- Removing duplication.
- Simplifying conditions.

Do not:
- Add features.
- Fix unrelated bugs.
- Change tests merely to make the refactor pass.
Enter fullscreen mode Exit fullscreen mode

This makes the distinction between “better internals” and “different software” explicit.

Feature, bug fix, and refactor are three different conversations

A useful mental model is:

Feature work: specify the new behavior.

Bug fixing: specify the evidence.

Refactoring: specify the invariants.

All three still need scope and verification.

The final prompt should request a report

After the change, ask the agent to return something short and auditable:

When finished, report:
1. Files changed.
2. What changed.
3. Validation commands actually executed.
4. Results.
5. Any assumption you had to make.
6. Anything you could not verify.
Enter fullscreen mode Exit fullscreen mode

This is more useful than asking for a long description of the model's internal reasoning.

You want evidence about the work product.

Prompt quality is not prompt length

The best coding prompt is not necessarily the longest one.

A 500-word prompt with no acceptance criteria can still produce a risky change.

A much shorter prompt containing:

  • a precise task,
  • relevant context,
  • strict scope,
  • and a real verification method

can be substantially easier to trust.

Coding agents do not remove the need for review.

They make it more important to define what should be reviewed.

The practical goal is therefore not to write a “perfect AI prompt.”

It is to create a task that is constrained enough to implement and specific enough to verify.


Full Arabic guide and reusable prompt templates:


[[AICodeSmart](https://www.aicodesmart.com/cursor-claude-code-prompts/)]
Enter fullscreen mode Exit fullscreen mode
I Swept All 33 Bedrock Regions So You Don't Have To
🧠Chidozie Uzoegwu·Sep 8, 2026·9 min read·Global

I Swept All 33 Bedrock Regions So You Don't Have To

#aws#machinelearning#ai#amazonbedrock

The Amazon Bedrock pricing page publishes a training price for the Meta model you almost certainly are not fine-tuning.

Here is the customization pricing on the page, as of 8 September 2026:

Model Published training price Llama 2 Pretrained 13B $1.49 per 1M tokens Llama 2 Pretrained 70B $7.99 per 1M tokens Cohere Command $0.004 per 1,000 tokens Titan Image Generator $0.005 per image seen gpt-oss-20b $80 per training hour Qwen3 32B $80 per training hour Llama 3.1, Llama 3.3 none

Other providers are covered. Two models are even priced by the hour. The only Meta model with a published training price is Llama 2, and the Llama you would actually fine-tune today is not on the list.

I have a fine-tuned Llama 3.3 70B running in production on Bedrock, and a line item on my AWS bill saying it cost real money to train. So the capability exists, it bills, and I could not find its price published anywhere.

A caution while you are here: I have seen third-party guides quote "$0.00799 per 1,000 tokens" as the Llama 3.3 70B training rate. That is the Llama 2 70B figure restated and applied to a different model. If you are budgeting, price it from your own first job rather than from a number someone inferred.

That gap turned out to be the least surprising thing I learned. This article is the map I wish I had before I started: which regions can actually do this, what the documented way around it says versus what happened when I took it, and the one piece of job state that decides whether a stalled job is costing you money or nothing at all.

Everything below is either something I measured or something AWS documents, and I have labelled which. Where the two disagree, you get both.

Part 1: the sweep

I did not want to work from a documentation table. The Bedrock catalogue varies by region more than people expect, and a page describing 33 regions is a hard thing to keep current. So I asked the API directly, in every region the SDK knows about.

import boto3, concurrent.futures as cf
from botocore.config import Config

cfg = Config(connect_timeout=6, read_timeout=12, retries={"max_attempts": 1})
regions = boto3.Session().get_available_regions("bedrock")

def probe(region):
    try:
        client = boto3.client("bedrock", region_name=region, config=cfg)
        models = client.list_foundation_models(
            byCustomizationType="FINE_TUNING"
        )["modelSummaries"]
        ids = sorted({m["modelId"].split(":")[0] for m in models})
        return region, len(ids), ids
    except Exception as e:
        return region, "ERR " + type(e).__name__, []

with cf.ThreadPoolExecutor(max_workers=12) as ex:
    for region, count, ids in sorted(ex.map(probe, regions)):
        print(region, count, any("llama3-3-70b" in i for i in ids))
Enter fullscreen mode Exit fullscreen mode

byCustomizationType="FINE_TUNING" is the important part. It returns only the models you can actually train, not the much longer list you can invoke.

33 regions. Here is what came back.

Result Regions Can fine-tune Llama 3.3 70B 1 (us-west-2) Answered, some tunable models, no Llama 2 (us-east-1, eu-west-2) Answered, zero tunable models 17 Opt-in regions, not enabled on my account 13

One region can fine-tune Llama 3.3 70B. Out of the 33 the SDK lists, and out of the 20 that gave me a real answer. Not a short list. One.

An honest caveat, because the difference matters. Those 13 are not a fine-tuning answer. They returned UnrecognizedClientException, which is what you get when you call an opt-in region your account has never enabled. I cannot rule them out from this account, and neither can you from yours unless you have opted in. Twenty regions gave a real answer.

The two regions that answered with something other than zero are more interesting than the seventeen that answered zero.

us-west-2, five tunable models:

amazon.titan-embed-image-v1
anthropic.claude-3-haiku-20240307-v1
meta.llama3-1-8b-instruct-v1
meta.llama3-1-70b-instruct-v1
meta.llama3-3-70b-instruct-v1
Enter fullscreen mode Exit fullscreen mode

us-east-1, six tunable models:

amazon.nova-micro-v1
amazon.nova-lite-v1
amazon.nova-pro-v1
amazon.nova-2-lite-v1
amazon.nova-canvas-v1
amazon.titan-embed-image-v1
Enter fullscreen mode Exit fullscreen mode

Read that second list again. In the region most people default to, every model you can fine-tune is an Amazon model. No Llama. No Anthropic. If you want to customise anything that is not Amazon's own, us-east-1 cannot do it.

The console agrees. Opening Create Fine-tuning job in us-east-1 on 8 September 2026, the model picker offers one category, "Serverless model providers", and one provider under it: Amazon. The six models it lists are the same six the API returns.

And eu-west-2, London, where a lot of UK and EU workloads want to sit for latency or residency reasons, has exactly one tunable model. It is not a Llama.

This has been stable. I first ran the sweep on 1 August 2026 and re-ran it on 8 September 2026 before publishing. Same answer both times. That is five weeks, not five years, so re-run it yourself rather than trusting my table. The snippet above takes about a minute.

Part 2: the escape hatch, and what happened when I tried it

The obvious way out is to train the model somewhere else and bring the weights in. Bedrock has Custom Model Import for exactly that, and on paper it fits.

The documentation is clear and it is worth quoting accurately, because my experience differed from it and I want you to have both.

Custom Model Import lists Llama 3.3 among its supported architectures. It states the ceiling in terms of weight size rather than parameter count: under 200GB for text models, with a maximum context length below 128K. It is available in eu-central-1, us-east-1, us-east-2 and us-west-2, which is four regions to native customization's one. AWS has published a walkthrough of importing DeepSeek-R1-Distill-Llama-70B through it.

By that description, importing a fine-tuned Llama 3.3 70B should work. My artifacts were 141GB in bf16, comfortably inside the documented 200GB.

The import failed:

too large to fit on available hardware
Enter fullscreen mode Exit fullscreen mode

That is the whole error. It does not say which limit was hit, whether the constraint was my artifacts, the region, or capacity at that moment. Searching it turns up other people receiving the same generic message on far smaller models, including an 8B, which suggests it is a catch-all rather than a statement about 70B specifically.

So the honest position is narrower than "it cannot be done." The documented path supports the architecture and the size. It did not complete for me, on my artifacts, in my account, on the day I tried, and the error was not specific enough to tell me why. Someone else may well succeed with it, and AWS's own material suggests they do.

What that means practically, if you are planning:

  • Native customization is the constrained path. One region for Llama 70B, and no choice about it.
  • Import is the less constrained path on paper, including into eu-central-1, which matters if EU residency is a hard requirement for you. Try it before you conclude anything from my result.
  • Budget for the possibility that it does not complete, and for an error that will not tell you why.

For my own build, native customization is what worked, so that is where the model lives, and the application runs in a different region from the custom model deployment. To be precise about which half of that was forced: the customization and its deployment had to be in us-west-2. Where the application runs was my choice, and I could move it.

Part 3: InProgress means two completely different things

This is the part I have not found documented anywhere, and it is the one that costs money.

Launch a customization job and poll it. The status reads InProgress.

That tells you nothing useful, because InProgress covers two states that are not remotely the same:

  1. The job is sitting in a capacity queue waiting for a GPU. No trainer has started. You are being charged nothing.
  2. The job is training. You are burning GPU time.

From the outside these look identical. Same status, same API, same console. The only thing that separates them is nested one level down:

job = bedrock.get_model_customization_job(jobIdentifier=job_id)

job["status"]
# 'InProgress'   <- tells you nothing

job["statusDetails"]["trainingDetails"]["status"]
# 'NotStarted'   <- queued, free to stop
# 'InProgress'   <- training, stopping now pays for nothing
Enter fullscreen mode Exit fullscreen mode

statusDetails.trainingDetails.status is the only honest signal. Check it before you stop anything.

Why it matters in cash terms. Over one week of capacity contention I stopped five jobs. Four had never left the queue.

Jobs stopped Reached the trainer Billed 4 No $0.00 1 Yes $29.89

Four jobs, each killed after many hours, cost nothing at all. Queue time is free. Had I believed the top-level status, I would have assumed all five cost me something and drawn exactly the wrong conclusion about how expensive iteration is.

This is consistent with what AWS documents, once you read it precisely. The guidance on stopping a job says Bedrock charges for the tokens it used to train the model before you stopped it. If training never started, no tokens were used, and there is nothing to charge for. The documentation and the invoice agree. What neither tells you is which of the two states your job is in, and that is the gap trainingDetails fills.

One more thing that trips people up here: validation completing does not mean training has started. Validation proves your S3 permissions and your data schema are fine. It says nothing about whether a GPU has been allocated.

Part 4: the threshold I invented

Here is the mistake, because it is more useful than the finding.

Jobs were stalling in the queue. I went back through my own job history and worked out that the longest queue wait ever followed by successful training was about 11 hours. So I adopted a rule: past 11 hours the job is hung, kill it and relaunch.

That rule was garbage, and it took me a while to see why.

Every job in my history that had waited longer than 11 hours had been killed by me before it could recover. The number did not measure Bedrock's behaviour. It measured my patience. I then used it to justify killing the next job, which fed the same number back into the same conclusion.

The data point that broke it: one job waited 23 hours and 15 minutes in the queue, then trained normally and produced a working model.

An earlier job had been stopped at 17 hours 31 minutes on the strength of a threshold that did not exist. It cost $0.00, because it had never left the queue, but it cost most of a day.

The queue has no published upper bound that I could find. The documented threshold applies to a different state. AWS's troubleshooting guidance says training time runs "between 3-4 hours, up to 24 hours, depending on configuration and traffic", and that you should contact Support if a job has been in Training for more than 24 hours. In Training, not in queue. The phrase "depending on configuration and traffic" is doing real work there: capacity is shared, and the wait before training is not the thing being bounded.

The correct response to a stalled customization job, given that queue time is free and every relaunch goes to the back of the queue: wait. Impatience has a real cost here. Patience has none.

Part 5: the things that will bite you next

Four more, briefly, all learned the expensive way.

The schema is per model family. The limits are not. Llama 3.3 takes the Converse format, with schemaVersion, system[] and messages[]. Llama 3.1 rejects it outright, with ValidationException: Unable to parse S3 file due to invalid data schema/format, and wants flat {"prompt": ..., "completion": ...} instead. Both share the same 16,000 token ceiling, the same 100 to 10,000 record range, the same epoch and learning rate bounds. Matching limits are not matching schemas, and I lost a launch to assuming otherwise.

prompt/completion has no system field. Move from 3.3 to 3.1 and your system prompt has to be prepended into every training record, then reproduced byte for byte at inference. Get one character wrong later and the model quietly degrades, with no error anywhere.

Your real concurrency limit may be smaller than you think. The quota that binds is Custom models with a creating status per account, which on my account is 2. Not the scheduled-customization-jobs quota, which on my account is 10 and looks like the constraint until you hit the other one. Check both in Service Quotas rather than assuming my numbers are the defaults.

Storage is per model per month. On my bill, a retained rollback model comes to about $1.95 a month to sit there. Cheap, and worth it for the rollback, but not free, and easy to accumulate.

The decision tree, compressed

If you are considering fine-tuning a Llama on Bedrock, the honest version is short.

  1. For native customization, the region is decided for you. us-west-2 or nothing, for Llama 70B. If you go that route, design the cross-region call in from the start.
  2. Try Custom Model Import before you accept that. It documents support for Llama 3.3, a 200GB text ceiling and four regions including eu-central-1. Mine failed with a generic hardware error inside the documented limit, so budget for that outcome, but do not take my result as the rule.
  3. Check trainingDetails, never the top-level status, before you stop a job or conclude anything about cost.
  4. When it stalls, wait. Queue time is free, relaunching is not, and any hang threshold you derive from your own history is measuring you.
  5. Read the schema doc for your exact model version, not the family. The limits will match and the format will not.

None of it sits in one place, which is why it is here. The pricing page still says Llama 2.

The $2,000 Inference Server: Standing Up Local AI on Ten-Year-Old Hardware
📱Michael Brewer·Sep 8, 2026·5 min read·Global

The $2,000 Inference Server: Standing Up Local AI on Ten-Year-Old Hardware

#ai#selfhosted#llm#homelab

I run a local inference server that handles thousands of agent requests a day. It cost about $2,000 in used parts, and the newest silicon in it taped out around 2016. This series is the story of standing it up, and more honestly, the story of how much of what I "knew" about it turned out to be wrong.

I didn't pick this hardware to prove a point. I picked it because it's what I could afford. It turned out to be the best teacher I could have bought.

The bill of materials

Server guts used from the enterprise-surplus market (eBay receipts, January 2026); case, PSU, and cooler new from Newegg:

Component Cost AMD EPYC 7302P + Supermicro H11SSL-i (16 cores / 32 threads) $555 128 GB DDR4-2666 ECC (8x 16 GB) $551 2x NVIDIA Tesla P40 (24 GB each) $403 1 TB Intel DC P4510 U.2 NVMe $100 U.2 adapter and power cabling $63 Case, PSU, CPU cooler (Newegg, approximate) ~$330 Total ~$2,000

For context, that's roughly two months of what an always-on agent workload would cost me in frontier API bills. The machine paid for itself before I finished tuning it.

The P40 is the heart of the build and the source of most of the pain. It's a Pascal datacenter card from 2016: 24 GB of VRAM, compute capability 6.1, no Tensor Cores, no NVLink. Two of them give you 48 GB of VRAM on paper. One of the first hard lessons in my notes is titled "a 47GB model does not fit in 48GB." The driver reserves about 6%, so usable is 45 GiB. Budgeting at the spec-sheet number OOMs.

What this hardware cannot do

Knowing the "no" list up front would have saved me weeks. Here it is:

No vLLM. Compute capability 6.1 is too old. I didn't take the documentation's word for it; I have an experiment directory proving it. Ruled out for real.

No Tensor Cores means FP16 is a trap. On Pascal, FP16 math runs at 1/64th the rate of FP32. The card's strength is INT8 through the dp4a instruction, about 47 TOPS. Everything about a working Pascal config flows from that one fact: quantized models, integer matmul kernels, and skepticism toward any advice written for newer cards.

No concurrent GPU models. One large model resident at a time. Swaps take about 30 seconds through the Portainer API. You design around it or you fight it forever.

No fast cold starts. 20 to 40 seconds to load a model, depending on size.

Most advice doesn't apply. This one cost me the most. The internet's LLM performance guidance is written on Ampere and newer. Some of it transfers to Pascal, some of it is irrelevant, and some of it is actively destructive. A "40% faster" split mode I found recommended in a vendor blog crashes Pascal outright with an illegal memory access. Telling those categories apart is most of the work, and it's the subject of this whole series.

What it can do

Here's what those constraints actually bought, with measured numbers from my own logs, not estimates:

  • A 26B mixture-of-experts model (Gemma 4 26B-A4B, Q8) decodes at 41 tokens/sec. That's the fast path.
  • The daily workhorse, a 27B dense model at Q6_K, runs 13-17 tokens/sec single-stream with speculative decoding, around 15 tokens/sec aggregate across four parallel slots.
  • That same stack serves a 262k-token context window in about 23 GB of VRAM, using quantized KV cache.
  • On the CPU side, small 4B models handle classification and routing at 15-25 tokens/sec on a handful of cores, always on, never competing with the GPUs.
  • Over one measured 40-hour production window the stack processed 8,129 requests with a 0.17% failure rate and zero manual interventions.

Twelve different GPU model stacks are compiled, deployed, and benchmarked on this machine today, swapped on demand behind a single OpenAI-compatible endpoint. Agents talk to it all day. Most requests never touch a paid API.

None of that requires modern hardware. It requires knowing the machine you actually have.

Why old hardware teaches you more

On an H100, plenty of mistakes just cost you a little throughput you never notice. On a P40, mistakes fail loudly. The wrong split mode crashes. The wrong precision runs 64 times slower. The wrong context budget OOMs. The hardware gives you honest feedback because there's no headroom to hide in.

That feedback forced habits I now think of as the real payoff of the project:

Measure on your own hardware. Community numbers are hypotheses, not facts. Every claim in my notes carries a date and the file the measurement lives in.

Change one variable at a time. I learned this by breaking it. A "modernized" rebuild changed four things at once and prompt processing collapsed from 153 tokens/sec to 29. Nothing was attributable until a clean A/B isolated each variable. That incident became a standing rule.

Verdicts expire; mechanisms survive. The most useful things I know about this machine aren't rules like "flag X is always right." They're conditions: "flag X wins on these architectures because of this mechanism, and loses on those." Over six months, roughly half of my March conclusions were overturned or narrowed by September. Each reversal has a measurement behind it. That's not embarrassing; that's the discovery process working.

That last point is the spine of this series. Performance guidance for a moving target like llama.cpp is perishable. Flags I tuned around got deleted upstream. An environment variable I exported religiously turned out to be dead code that was never read. A "critical" recompile fixed a problem the running binary didn't have. Every one of those stories is a post.

Where this started

The homelab behind this goes back to 2016: Docker, networking, self-hosted services, a decade of running my own infrastructure because I wanted to know how things work. The AI chapter is recent. I started messing with LLMs in the fall of 2025, and the substrate was already there, which is the only reason the timeline in this series is months instead of years.

I work in SQL and data pipelines by day, on donor management systems for nonprofits. Nights and weekends I pointed the homelab at inference. This series is the honest record of what happened next: what I believed, what the machine proved, and what I had to unlearn.

The series

  1. This post. The hardware, the constraints, the thesis.
  2. The flag we tuned around got deleted. Row-split was the P40 answer, until upstream removed it. What replaced the lost throughput came from somewhere I wasn't looking.
  3. Cargo-cult flags. The "critical" env var that was never read at runtime, and how reading kernel source settled it.
  4. When the reason changes, the flag flips. Flash Attention was measurably wrong on Pascal, until quantized KV cache made it mandatory. Same silicon, new condition.
  5. Measure the binary you run. Two documents argued opposite positions about a SIMD flag that was already enabled. The running binary knew; the build directory lied.
  6. Template beats quant. A full quantization step moved my benchmark score not at all. The wrong chat template zeroed it.
  7. Buying speed with architecture. What MoE sparsity, speculative decoding, and parallel slots actually bought, with the A/B numbers.
  8. The gate caught me cheating. The promotion ledger that invalidated my own candidate because I skipped the test I designed.
  9. Eggs, cholesterol, and GPU flags. The wrap-up: a six-rung ladder of evidence quality, and why the mature answer is a condition, not a verdict.

Everything in it comes from dated, recorded measurements. Where something was never tested, it's labeled as never tested. Where I was wrong, the original wrong belief is quoted, because the reversal is the content.

$2,000, ten-year-old silicon, and more real systems education than any cloud bill has ever bought me.

Your Agent Remembered the Fact. It Answered Like a Stranger.
🔍Edward Izgorodin·Sep 8, 2026·4 min read·Global

Your Agent Remembered the Fact. It Answered Like a Stranger.

#ai#agents#llm#machinelearning

A memory system can return the exact fact a request depends on whenever that fact is named, and still let the request be answered the way a stranger with no memory would have answered it.

The numbers below are from InMind, arXiv 2607.24368v1, 27 July 2026, CC BY 4.0, by Ruizhe Li, Mingxuan Du, Benfeng Xu and Zhendong Mao, read from the HTML text on 7 September 2026. I have not run the benchmark.

Condition, same 125 tasks Result (%) Direct question naming the fact, six memory systems 76.0 to 100.0 Indirect request, fact present in the answerer context, six memory systems 0.8 to 12.0 Indirect request scored end to end, six memory systems at most 14.4 Indirect request scored end to end, best of three Naive RAG controls 16.0 Indirect request, fact placed in context by hand 84.0 Indirect request, the always-in-state probe 68.8

The numbers are theirs and the grouping is mine: rows one to three are ranges across the twelve memory-system configurations of their Table 1, row four is a single control from the same table, and rows five and six are single rows. The authors call the last row a diagnostic, not a controlled ablation.

Two abilities travel under one word

Sylwia Laskowska published a glossary of agent terms on 3 September 2026 (the post). Her definition names the hard half, which is not having to explain the same thing for the tenth time. Her example shows the easy half, with a founder whose other company builds rockets: three weeks after saying he wants to buy an AI coding company, he asks what it was, and the agent tells him.

That question named the thing to look for. Change the request and keep the store identical: put together an offer for that coding company, with whatever budget the rocket program leaves. Nothing there points back at the earlier note, and that note is the price.

I left the distinction in her comments (comment 3e881). The test fits in a line: ask the agent for something that depends on what it knows without naming it, then compare with what a stranger would have written. If they match, the store is full and the memory is not working.

The gap is access, not ability

The table rules out two comfortable explanations. The tasks do not outrun the model. With the fact placed in context by hand, the same model answers 84.0 percent of the indirect requests, and the authors put it in one sentence: "What separates 84.0% from 16.0% is access, not ability." Nor is the fact lost: a direct question returns it at up to 100.0 percent after 38 sessions of intervening traffic.

Resolution does not settle it either: an embedding with eight times the dimensionality raises the measured presence of the fact for all six systems, and no query-time configuration goes above 16.0 percent end to end. What is left is selection, and the authors call that open problem routing: deciding which facts stay visible before anyone asks.

Their own diagnostic for it is the last row: one markdown file capped at 200 lines, placed in the prompt before the query arrives. They offer it as a measurement, not an architecture, since a file that size fills up and facts start pushing each other out as the store grows.

A fourth condition for the test battery

Sergei Parfenov proposed a battery for the same failure (his post): independent copies of the same starting state, one getting the direct question, one the task alone, one the task with the constraint written in, plus requests where the constraint should not apply. The one run he reports is narrower and he says so: a local BM25 probe over 12 fixtures, 12 of 12 on direct questions and 4 of 12 on indirect tasks, with no model in the loop.

He names the same hazard and handles it his way, by recording the context the model actually received. I would add a fourth copy: the same state with the decisive fact replaced by a decoy of the same shape and length, or removed outright. What you report is then a difference, with the fact minus without the fact, and it needs no view into the retrieved context.

He argues that from the answer-only scores in the same paper, and those scores show why a level misleads. Scored on the answer alone, without requiring that the fact reached the model, the retrieval configurations land between 18.4 and 29.6 percent in their Table 4, against 3.2 to 16.0 percent scored end to end in Table 1. In one audited case the answer carried the relevant allergen warning, a generic caution rather than a personalized one, while the retrieved context held no mention of the allergy: it came from general knowledge about macarons. Without a copy to subtract, that credit lands on a memory that delivered nothing.

What this does not prove

InMind is 125 constructed tasks, 113 of them grounded in citable public sources, and pairs where an ordinary retrieval cue would have given the answer away were filtered out on purpose. It stresses one failure mode rather than sampling ordinary traffic.

GPT-5-mini both answers and judges. The authors call an independent judge model their most significant methodological gap, and their audit of 100 records puts the context-aware application judge at 85.0 percent accuracy, all 15 errors false positives. Their scoring pays for applying the fact and charges nothing for over-eagerness, which is the failure the negative controls in the battery above are aimed at.

None of this measures a particular deployed assistant, including any system I work on, and the argument gives no exemption to whoever makes it: a store that fires only when the query names the fact fails regardless of who ships it. The fourth condition is a proposal for which I have published no run.

Disclosure: I work on Mnemoverse, a memory engine for AI agents connected over MCP, so weigh the argument accordingly.

What Your Loss Function Actually Tells the Model: MSE, Cross-Entropy, and the softmax Bug That Trains Anyway
💡Wesam Khallaf — Author of PyTorch From Ground Up·Sep 8, 2026·13 min read·Global

What Your Loss Function Actually Tells the Model: MSE, Cross-Entropy, and the softmax Bug That Trains Anyway

#pytorch#deeplearning#machinelearning#beginners

the last three articles walked the training loop backwards, .backward() and what it fills in, the four gradients computed by hand, then optimizer.step() turning those gradients into an actual update. all of that starts at one number, the loss, and i kept saying "and then the loss comes from somewhere" and moving on. so this one is about where it comes from, which is a much shorter piece of arithmetic than you would expect, and then about the one mistake in this area that every beginner is warned about and almost nobody has actually measured.

the warning is "never apply softmax before CrossEntropyLoss", and you will find it in every tutorial, every course and most stack overflow answers on the subject, almost always with the words "training will fail quietly". i wanted a number for "quietly", so i ran it. it does not fail. it trains, it reaches roughly the same accuracy, and it lies to you the whole way in a specific and measurable manner, which turns out to be a lot more interesting than failing would have been.

The short answer

if the answer your model produces is a number, use MSELoss. if the answer is a category, use CrossEntropyLoss. binary yes or no, BCEWithLogitsLoss. that is the whole decision for the vast majority of problems, and neither of the two main ones has anything hidden inside it:

MSE           = ((pred - target) ** 2).mean()
CrossEntropy  = -log( softmax(logits)[correct_class] )
Enter fullscreen mode Exit fullscreen mode

the loss function is the only place in the entire loop where you tell the model what "better" means. the architecture, the optimizer, the data, all of it is machinery in service of that one definition.

MSE, which is exactly what it says

take the difference, square it so negative errors do not cancel positive ones, average over the batch.

import torch
import torch.nn as nn

pred   = torch.tensor([2.5, 0.5, 3.0])
target = torch.tensor([3.0, 0.0, 3.0])

print(nn.MSELoss()(pred, target))            # tensor(0.1667)
print(((pred - target) ** 2).mean())         # tensor(0.1667)
Enter fullscreen mode Exit fullscreen mode

by hand the errors are (-0.5, 0.5, 0.0), squared they are (0.25, 0.25, 0.0), and 0.5 / 3 is 0.1667. the nn.MSELoss() object is a wrapper around that one line and nothing else.

the squaring is the part with consequences. shift all your predictions by 1.0 and the loss is 1.0, shift them by 2.0 and it is 4.0, so an error twice as large costs four times as much and MSE will always spend most of its effort on your worst outliers. that is either what you want or the reason your model is being dragged around by three bad rows in the dataset.

the MSE trap that costs an afternoon

this one is not in the docs where you would look for it. give MSE a prediction of shape (4, 1) and a target of shape (4,), which happens the moment you use nn.Linear(n, 1) and your labels came out of a dataframe column:

pr = torch.tensor([[1.0], [2.0], [3.0], [4.0]])   # (4, 1)
tg = torch.tensor([1.0, 2.0, 3.0, 4.0])           # (4,)

print(nn.MSELoss()(pr, tg))     # tensor(2.5000)
Enter fullscreen mode Exit fullscreen mode

the prediction is exactly right on every row and the loss is 2.5. broadcasting expanded (4,1) against (4,) into a (4,4) grid and compared every prediction against every target, so twelve of the sixteen comparisons are between rows that have nothing to do with each other. pytorch does warn here, and the warning is easy to scroll past because your training loop still prints a falling loss:

UserWarning: Using a target size (torch.Size([4])) that is different to the input size
(torch.Size([4, 1])). This will likely lead to incorrect results due to broadcasting.
Please ensure they have the same size.
Enter fullscreen mode Exit fullscreen mode

fix is target.unsqueeze(1) or pred.squeeze(1), and then the loss is 0.0 like it should be. if the (4,4) part is surprising, it is the same three broadcasting rules from the broadcasting article, just showing up somewhere you were not looking for them.

Cross-entropy, by hand

classification is a different shape of problem. the model outputs one raw score per class, called logits, and the target is an integer saying which class was right. cross-entropy turns the scores into probabilities with softmax, then takes the negative log of the probability sitting on the correct class. more probability on the right answer, lower loss.

logits = torch.tensor([
    [2.0, 1.0, 0.1, -1.0],    # sample 0, correct class 0
    [0.5, 2.5, 0.3,  0.2],    # sample 1, correct class 1
    [0.1, 0.2, 3.0,  0.1],    # sample 2, correct class 2
])
labels = torch.tensor([0, 1, 2])

print(nn.CrossEntropyLoss()(logits, labels))    # tensor(0.3015)
Enter fullscreen mode Exit fullscreen mode

now the same thing by hand for sample 0:

l = torch.tensor([2.0, 1.0, 0.1, -1.0])
p = torch.softmax(l, dim=0)
print(p)                    # tensor([0.6381, 0.2347, 0.0954, 0.0318])
print(p.sum())              # tensor(1.0000)
print(-torch.log(p[0]))     # tensor(0.4493)
Enter fullscreen mode Exit fullscreen mode

softmax exponentiates every score and divides by the total, so the four numbers come out positive and sum to one. class 0 got 63.8 percent of the probability and -log(0.6381) is 0.4493. the other two samples give 0.2974 and 0.1577, and the mean of the three is 0.3015, which is what the loss function returned.

one habit worth building right here. whenever you print a softmax, print its sum on the next line. it costs four characters and it is the only cheap check that exists, because a vector that does not add up to 1 is not a probability distribution and something upstream of it is wrong, usually a softmax taken over the wrong dimension. i have run into more than one published table of "probabilities" that quietly fails that test, and the sum is what catches it every time.

why the negative log, and not something simpler

because of what it does to the gradient. compute the gradient of cross-entropy with respect to the logits and you get one of the cleanest results in the whole subject:

lg = torch.tensor([[2.0, 1.0, 0.1, -1.0]], requires_grad=True)
nn.CrossEntropyLoss()(lg, torch.tensor([0])).backward()

print(lg.grad)        # tensor([[-0.3619, 0.2347, 0.0954, 0.0318]])
Enter fullscreen mode Exit fullscreen mode

that is p - y, the predicted probability vector minus the one-hot target, exactly, to every decimal place. 0.6381 - 1 = -0.3619 on the correct class and the raw probability on each of the others. the softmax and the log cancel each other's derivatives and what reaches your network is just "how far off was each probability". if you followed the backpropagation article, this is the number that starts the whole chain, and every gradient in the model is that vector pushed backwards through the layers.

it also means the size of the gradient is bounded by 1 per class and it goes to zero exactly when the prediction is right. no tuning, no scaling, it just behaves.

The softmax bug, actually measured

here is the thing everyone tells you. CrossEntropyLoss applies softmax internally, so if you also apply softmax in your model's forward, you softmax twice. everyone repeats it and i have repeated it, and almost nobody says what it actually costs, so that is what i went and measured.

so, first, it does not fail:

correct = nn.CrossEntropyLoss()(logits, labels)
bugged  = nn.CrossEntropyLoss()(torch.softmax(logits, dim=1), labels)

print(correct)   # tensor(0.3015)
print(bugged)    # tensor(0.9388)
Enter fullscreen mode Exit fullscreen mode

no error, no warning, two perfectly ordinary numbers. and there is no way for pytorch to catch this, because a probability vector is a valid float tensor and "valid logits" is not a thing you can test for.

what happened is that softmax ran on top of softmax. sample 0's logits are [2.0, 1.0, 0.1, -1.0], spread over three units. after one softmax they are [0.6381, 0.2347, 0.0954, 0.0318], spread over about 0.6. after the second one they are [0.3578, 0.2391, 0.2080, 0.1951], spread over 0.16. every pass through softmax squeezes the numbers closer together, and the model's confidence is what gets squeezed out.

what that costs, in one number

i pushed it to the extreme, a model that is maximally confident and correct, and the same model maximally confident and wrong, and asked both losses what they think.

                       confident + right      confident + wrong
logits in                    0.0000                40.0000
softmax in                   1.4612                 2.4612
Enter fullscreen mode Exit fullscreen mode

that is with 10 classes. a correctly wired cross-entropy has a floor at zero and no ceiling at all, so being badly wrong is expensive without limit and the loss carries real information about how wrong you are. once you softmax first, the entire range of model behaviour from perfect to catastrophic gets compressed into a band exactly 1.0 wide. i checked this for 3, 4, 10 and 100 classes and the band is 1.0000 every single time, it only slides upward as the class count grows.

so the loss stops being a measurement. and the gradient goes with it:

gradient on the true-class logit of a confidently wrong sample, 10 classes

logits in     -1.000e+00
softmax in    -4.871e-18
Enter fullscreen mode Exit fullscreen mode

seventeen orders of magnitude. the example your model is most badly wrong about, the one it most needs to learn from, produces a gradient of effectively zero. that is not a rounding artifact, it is the second softmax having saturated, and the derivative of a saturated softmax is nothing.

and yet it trains

this is the part i did not expect and it is the reason i think the standard warning is told wrong. ten classes, twenty dimensions, same network, same seed, same optimizer, the only difference being one torch.softmax in the forward pass:

epoch          1        10        50       200      1000      4000     final test acc
------------------------------------------------------------------------------------
logits in    20.3%     75.9%     85.6%     85.9%     85.0%     82.5%       82.5%
softmax in   12.6%     24.7%     62.4%     85.6%     86.4%     85.4%       85.4%
Enter fullscreen mode Exit fullscreen mode

the bugged run is far slower, 24.7 percent against 75.9 at epoch 10, and it needs roughly four times as long to get anywhere. but it gets there. it finishes ahead, in fact, because the correct run had started overfitting by epoch 4000 and the crushed gradients acted as a brake. i am not claiming the bug is good, on a real problem with a real budget being four times slower is the whole ball game, and i would not want to defend "my regularizer is a bug" to anyone. the point is that "training will fail quietly" sets you up to look for a failure, and there is no failure to find.

what you get instead is worse in a more annoying way. your loss is now uninterpretable. that final softmax in run reports a loss of 1.53. with 10 classes and correct wiring, 1.53 would mean a mediocre model, since a model guessing at random sits at log(10) = 2.3026 and 1.53 is two thirds of the way to useless. with the bug, 1.53 is 0.07 above the best value that run can physically produce. same number, opposite meanings, and nothing on your screen says which one you are looking at.

and accuracy will not save you either, because softmax is monotone, so argmax does not move and your accuracy metric is completely unaffected by the bug. i checked it over a thousand random rows, the predicted class is identical in every one. the only two things that change are the number you watch and the speed you learn, which is exactly the pair you would least like to have quietly corrupted.

how to spot it in thirty seconds

look at the last line of your forward. if it is a softmax, a sigmoid, or anything else that squashes into a range, and your loss is CrossEntropyLoss or BCEWithLogitsLoss, that is the bug. those two losses do the squashing themselves, in a numerically stable way, which is a second reason to let them.

the other check is the floor. run a batch you know the model gets right and see whether the loss can reach something near zero. if the lowest loss you ever see is suspiciously far from zero and suspiciously close to a constant, you are looking at a band, not a measurement.

# wrong
class Net(nn.Module):
    def forward(self, x):
        return torch.softmax(self.fc(x), dim=1)     # then CrossEntropyLoss -> bug

# right
class Net(nn.Module):
    def forward(self, x):
        return self.fc(x)                            # raw logits, that is all

# and at inference time, when you want probabilities to show a user:
with torch.no_grad():
    probs = torch.softmax(model(x), dim=1)
Enter fullscreen mode Exit fullscreen mode

softmax at prediction time is normal and correct. softmax before the loss is the bug. same function, and the only thing that differs is which side of the loss it sits on.

Why not just use MSE for classification

people ask this, and the answer is the same gradient story. take a model that is confidently wrong on a 3-class problem, logits [-6, 0, 0] with the true class being 0, and look at the gradient on the true-class logit under each loss:

cross-entropy                  -0.998762
MSE on softmax outputs         -0.00123478
Enter fullscreen mode Exit fullscreen mode

809 times smaller. MSE on a squashed output inherits the squashing, so the more wrong the model is the less it learns, which is precisely backwards. cross-entropy's p - y is at its largest exactly when the model is at its most wrong. that is the entire reason cross-entropy is the classification loss and not just a convention.

The error messages, verified

these are the literal strings from torch 2.14, not from memory, because the ones you find on stack overflow are frequently from 2019.

what you did what you get float labels into CrossEntropyLoss RuntimeError: expected target dtype to be Long or Byte, but got Float a label larger than num_classes - 1 IndexError: Target 9 is out of bounds. batch sizes do not match ValueError: Expected input batch_size (3) to match target batch_size (2). raw logits into BCELoss RuntimeError: all elements of input should be between 0 and 1 softmax into CrossEntropyLoss nothing at all, which is the whole article

that last row is why this piece exists. four of the five mistakes stop your program. the fifth one promotes itself to a training run.

worth noting that the BCELoss row is the friendlier cousin of the softmax bug. BCELoss genuinely requires probabilities and BCEWithLogitsLoss requires raw scores, and if you mix those up in the direction of feeding logits to BCELoss you at least get told, because a logit of 1.5 is outside [0, 1] and that is checkable. the other direction, sigmoid then BCEWithLogitsLoss, is silent in exactly the way described above.

Reduction, briefly

by default every loss averages over the batch, and you almost always want that, because it makes your learning rate independent of batch size.

pred = torch.tensor([1.0, 2.0, 5.0])
tgt  = torch.tensor([1.0, 3.0, 3.0])

nn.MSELoss(reduction='mean')(pred, tgt)   # tensor(1.6667)
nn.MSELoss(reduction='sum')(pred, tgt)    # tensor(5.)
nn.MSELoss(reduction='none')(pred, tgt)   # tensor([0., 1., 4.])
Enter fullscreen mode Exit fullscreen mode

sum scales your effective learning rate with batch size, which is a real bug source when someone changes the batch size and training suddenly diverges. none is genuinely useful, it gives you the per-sample loss, which is how you weight samples differently or go find which rows your model hates.

The rule

regression, MSELoss. multi-class, CrossEntropyLoss on raw logits. binary or multi-label, BCEWithLogitsLoss on raw logits. never squash before a loss whose name contains "Logits", and CrossEntropyLoss counts even though its name does not say so. MSE is ((pred - target) ** 2).mean() and cross-entropy is -log(softmax(logits)[label]), and if you can write both of those from memory you understand loss functions well enough to move on.

Try it before you close the tab

take the ten-class setup, or any classifier you already have, and add torch.softmax(x, dim=1) to the end of the forward pass. do not change anything else. then train it and look only at the loss curve and tell me you could spot which one is broken. that experiment took me about four minutes and it is the reason i no longer trust a falling loss on its own.

then print the smallest loss your run ever reaches and compare it to log(num_classes). a healthy run gets near zero on data it has memorised. a run stuck at a constant well above zero is telling you something, and this bug is one of the things it could be telling you.

what is the loss value you would consider "good" on your current problem, and do you know why that number and not a different one? i asked myself that while writing this chapter and my honest answer was that i had been reading loss curves by their shape for a long time and had never once checked what the floor should be.


This is one chapter's worth of an idea from my book, PyTorch From Ground Up, which builds everything from tensors upward so nothing stays vague. If it helped: 8 chapters are free, no email required, there's a free one-page tensor cheat-sheet here, every example runs in the companion notebooks on GitHub, and the full book is on Leanpub or in paperback and Kindle on Amazon.


More in this series

How Training Actually Works, the part where the training loop stops being magic:

The shape mechanics underneath all of it, worth having solid first:

Coming next in How Training Actually Works: building the same network twice, once from raw tensors and once from nn.Module, and what the module system is actually keeping track of for you.