Intermediate13 min read

Compare a reasoning model’s thinking settings

Run the same checked problem twice, then compare answer accuracy, token use, and response time.

Choose a question you can check

Some models and runtimes can generate a separate thinking field before the visible answer. That can use more of the generation budget. The API options vary by model. This exercise uses the same Qwen3 weights for both settings and a question you can check with arithmetic.

Keep Ollama running and save both scripts together. Download the small Qwen3 model for this lab.

Download compare_thinking.py
View compare_thinking.py
import argparse
from time import perf_counter

from semantic_notes import api

CASES = {
    "basic": (
        "A workshop has 18 seats. Four rows of three seats are taken. "
        "How many seats are free? Put just the number in your final answer.",
        6,
    ),
    "extra-seat": (
        "A workshop has 18 seats. Four rows of three seats are taken, and one additional "
        "seat is taken. How many seats are free? Put just the number in your final answer.",
        5,
    ),
}

parser = argparse.ArgumentParser(description="Compare two thinking settings on a small math problem.")
parser.add_argument("case", choices=CASES, default="basic", nargs="?")
args = parser.parse_args()
prompt, expected = CASES[args.case]
print(f"{prompt}\nExpected answer: {expected}\n")

try:
    for think in (False, True):
        started = perf_counter()
        result = api("/api/chat", {
            "model": "qwen3:0.6b",
            "messages": [{"role": "user", "content": prompt}],
            "think": think,
            "stream": False,
            "options": {"temperature": 0, "num_ctx": 2048, "num_predict": 1024},
        })
        message = result["message"]
        elapsed = perf_counter() - started
        print(f"Thinking requested: {think}")
        print(f"  Visible answer: {message.get('content') or '(empty)'}")
        print(f"  Thinking characters: {len(message.get('thinking') or '')}")
        print(f"  Generated tokens: {result.get('eval_count', 'unreported')}")
        print(f"  Stop reason: {result.get('done_reason', 'unreported')}")
        print(f"  Client elapsed time: {elapsed:.2f} s\n")
except (OSError, KeyError, TypeError, ValueError) as error:
    raise SystemExit(f"Could not run the comparison: {error}") from None
Download semantic_notes.py
View semantic_notes.py
import argparse
from contextlib import closing
from itertools import islice
import json
import math
from pathlib import Path
import sqlite3
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener

BASE = "http://127.0.0.1:11434"
MODEL = "all-minilm:latest"
MAX_PASSAGES = 200


class NoRedirects(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def api(endpoint, payload=None):
    request = Request(BASE + endpoint)
    if payload is not None:
        request.data = json.dumps(payload).encode("utf-8")
        request.add_header("Content-Type", "application/json")
    opener = build_opener(ProxyHandler({}), NoRedirects())
    with opener.open(request, timeout=120) as response:
        return json.load(response)


def model_digest():
    for model in api("/api/tags")["models"]:
        if model["name"] == MODEL:
            return model["digest"]
    raise ValueError(f"Pull {MODEL} with Ollama first.")


def embed(texts):
    result = api("/api/embed", {
        "model": MODEL,
        "input": texts,
        "truncate": False,
    })["embeddings"]
    if len(result) != len(texts):
        raise ValueError("Ollama returned the wrong number of embeddings.")
    return result


def cosine(left, right):
    if len(left) != len(right):
        raise ValueError("Vector dimensions changed. Rebuild the index.")
    length = math.hypot(*left) * math.hypot(*right)
    return sum(x * y for x, y in zip(left, right)) / length if length else 0


def build_index(folder, database):
    from index_notes import passages

    rows = list(islice(passages(folder), MAX_PASSAGES + 1))
    if not rows or len(rows) > MAX_PASSAGES:
        raise ValueError(f"Use 1–{MAX_PASSAGES} nonempty passages for this example.")
    digest = model_digest()
    stored = []
    for start in range(0, len(rows), 16):
        batch = rows[start:start + 16]
        vectors = embed([text for _, _, text in batch])
        stored.extend((*row, json.dumps(vector)) for row, vector in zip(batch, vectors))
    if model_digest() != digest:
        raise ValueError("The embedding model changed during indexing. Build the index again.")
    with closing(sqlite3.connect(database)) as connection, connection:
        connection.execute("CREATE TABLE IF NOT EXISTS model (name TEXT, digest TEXT)")
        connection.execute(
            "CREATE TABLE IF NOT EXISTS passages "
            "(source TEXT, number INTEGER, text TEXT, vector TEXT)"
        )
        connection.execute("DELETE FROM passages")
        connection.execute("DELETE FROM model")
        connection.execute("INSERT INTO model VALUES (?, ?)", (MODEL, digest))
        connection.executemany("INSERT INTO passages VALUES (?, ?, ?, ?)", stored)
    print(f"Saved {len(stored)} embeddings in {database}.")


def find_notes(database, query):
    if not query.strip() or not database.is_file():
        raise ValueError("Build the index and enter a nonempty question.")
    with closing(sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True)) as connection:
        connection.execute("BEGIN")
        identity = connection.execute("SELECT name, digest FROM model").fetchone()
        rows = connection.execute("SELECT source, number, text, vector FROM passages").fetchall()
    digest = model_digest()
    if identity != (MODEL, digest):
        raise ValueError("The embedding model differs from the one in the index. Rebuild it.")
    vector, = embed([query])
    if model_digest() != digest:
        raise ValueError("The embedding model changed during the query. Rebuild the index.")
    matches = [
        (cosine(json.loads(embedding), vector), source, number, text)
        for source, number, text, embedding in rows
    ]
    ranked = sorted(matches, key=lambda match: match[0], reverse=True)
    for score, source, number, text in ranked[:3]:
        print(f"{score:.3f} | {source}, passage {number}\n{text}\n")


def main():
    parser = argparse.ArgumentParser(description="Store local embeddings and search them later.")
    parser.add_argument("--db", type=Path, default=Path("notes-vectors.sqlite3"))
    commands = parser.add_subparsers(dest="command", required=True)
    build = commands.add_parser("build")
    build.add_argument("folder", type=Path)
    find = commands.add_parser("find")
    find.add_argument("query")
    args = parser.parse_args()

    try:
        if args.command == "build":
            build_index(args.folder, args.db)
        else:
            find_notes(args.db, args.query)
    except HTTPError as error:
        raise SystemExit(f"Ollama returned HTTP {error.code}: {error.read().decode()}") from None
    except (OSError, sqlite3.Error, ValueError) as error:
        raise SystemExit(str(error)) from None


if __name__ == "__main__":
    main()
ollama pull qwen3:0.6b
python compare_thinking.py

Read the answer and the budget

The script requests thinking off and on with matching prompts and generation settings. It prints the visible answer, the length of any message.thinking field, and the runtime’s generated token count and stop reason. It records the length of the thinking text without printing or saving the text itself.

You’re ready when

You see both results and can compare them with 18 − 4 × 3 = 6. In one local run, both settings produced 6 and the run with thinking generated more tokens. If the stop reason is length, the model reached the generation cap. Check whether you still got a complete answer.

Try an extra step

python compare_thinking.py extra-seat

Check both answers against 18 − (4 × 3 + 1) = 5. If a result says 13, it calculated seats taken. Repeat a warm run if you compare timing, and try additional questions before deciding which setting to use. Score the final answer against the calculation even when the model supplies a detailed explanation.

Check your understanding

What should you record when more generated tokens give the same answer?

Record accuracy, elapsed time, and token use for both runs. The right setting depends on what improves across your test set and how long users can wait.

Sources

Search the guide