Tokens And Embeddings

Smaller Numbers: Storing Embeddings in One Byte or One Bit

0 of 13 complete

0%

Contents

Back|Tokens And EmbeddingsSmaller Numbers: Storing Embeddings in One Byte or One Bit
1/13
33 min left
Prerequisites
Shorter Embeddings: Keep the First Numbers, Measure What You Loserequired
Related Topics
Embedding StorageDatabase Types & StorageChunking: The First Lever on Retrieval QualityRetrieval and RAG in ProductionIs There Really a Best Chunk Size? Measured on This Course's Own LessonsRetrieval and RAG in ProductionThe RAG Scale Cliff: What Breaks Between 100 and 5 Million DocumentsRetrieval and RAG in ProductionHybrid Retrieval: When Keyword Search Beats Your EmbeddingsRetrieval and RAG in Production
1 of 13

The Same Lake, Twice

Hold a large colour photo of a mountain lake in one hand. In the other, hold a tiny black-and-white print of the same lake. The small one has far less detail, but you would never mistake it for a different place.

A flat illustration of an engineer at a desk holding a large, detailed colour photo of a mountain lake in one hand and a tiny black-and-white print of the same lake in the other, beside an open laptop. A line says a tiny black-and-white print is still clearly the same lake, and embedding numbers can be stored with less detail too.

The last lesson made shorter by keeping fewer numbers. This lesson keeps every number, but stores each one with less detail. It is a different way to save space, and it loses far fewer right answers.

The Words You Need First

A hand-drawn word list. Bit: the smallest piece of computer memory, 0 or 1. Byte: 8 bits; one byte holds a whole number from -128 to 127. float32: the usual way to store a number with decimals, 4 bytes. int8: a whole number stored in 1 byte. Binary: keep only one bit per number, above zero or not. Rescore: check a short list again with the full numbers. A note says embedding, cosine and top 5 are from earlier lessons in this chapter.

Bit. The smallest piece of computer memory. It is either 0 or 1.

Byte. 8 bits together. One byte can hold a whole number from -128 to 127 (when it keeps a sign, as here).

float32. The usual way a computer stores a number with decimals, like 0.0473. It takes 4 bytes. This is how Ollama gives you each number.

int8. A whole number stored in 1 byte. The "8" means 8 bits.

Binary. Keeping only 1 bit per number: is it above zero or not. That is the number's sign: plus if above zero, minus if below.

Scale factor. The number you multiply by to make other numbers bigger or smaller.

Rescore. Check a short list of results again, this time with the full numbers.

Ranked first. The right lesson was the very top result, a stricter test than top 5.

Noise. Small differences that happen by chance. With 470 questions, one question either way can change from rounding alone.

Try It: Three Ways to Store the Same Numbers

This file takes the five short texts from the last lesson and stores their three ways: 4 bytes for each number, 1 byte for each number and 1 bit for each number. Then it scores each one against the question. Here it is, open in my VS Code.

A real screenshot of smaller_numbers.py open in VS Code, 34 lines: an embed function using nomic-embed-text through Ollama's /api/embed, five texts about rate limiting, caching, load balancing, sharding and message queues, a question about too many requests, a 1-byte version made by scaling every number into -127 to 127 with one shared factor and rounding, a 1-bit version that keeps only whether each number is above zero, and a loop printing three scores per text.

And this is what it printed:

A real screenshot of VS Code's terminal after running python smaller_numbers.py. Rate limiting: 4 bytes 0.667, 1 byte 0.668, 1 bit 570 of 768 agree. Caching: 0.536, 0.536, 526. Load balancing: 0.532, 0.532, 520. Sharding: 0.486, 0.486, 507. Message queues: 0.547, 0.547, 534.

  • With 4 bytes, scored highest, 0.667.
  • With 1 byte, the scores are almost the same, 0.668 for rate limiting.
  • With 1 bit, the score is a count: how many of the 768 positions have the same sign in the question and the text. Rate limiting agreed in 570, more than any other.

All three picked the right text. To run it: install Ollama from ollama.com, open it and leave it running. Run ollama pull nomic-embed-text, save the file below as smaller_numbers.py, and run python3 smaller_numbers.py (on Windows: python smaller_numbers.py). If you see "Connection refused", open the Ollama app first.

How Each Way Works

One embedding number stored three ways, one square for one bit: float32 uses 32 squares, 4 bytes; int8 uses 8 squares, 1 byte; binary uses 1 square, 1 bit. A note says every number in the embedding gets the same treatment, 768 of them for nomic and 1,024 for bge-m3.

1 byte (int8). numbers are small. For nomic here, they were all between about -0.25 and 0.25. To fit them into the whole numbers -127 to 127, multiply every number by one shared scale factor, then round. When you compare, divide by the same factor again, so the numbers are back on their old scale.

A hand-drawn example in three boxes: a number, for example 0.05; times the shared scale 501, about 25.0; rounded to 25, 1 byte. A note says the scale is 127 divided by the largest size in the stored pieces, ignoring the minus sign, 501 for nomic here, and to compare, divide back by it.

The factor is 127 divided by the largest size found in the stored pieces, ignoring the minus sign. That way the biggest number becomes exactly 127 or -127. For nomic here it was about 501. Rounding throws away only a tiny bit of each number.

1 bit (binary). Keep only whether each number is above zero. To compare a question with a piece, turn the question into signs too, and count the positions where the two agree. More agreement means a closer match.

Three boxes from the small example: 570, the most, rate limiting; 507, the fewest, sharding; 768 numbers per embedding. A note says from the run above, the right text agreed in the most positions.

The Real Test

An isometric row of four blocks joined by arrows: 112 lessons, 1,823 pieces, four ways to store them (4 bytes, 1 byte, 1 bit, and 1 bit with rescore), and 470 questions asked. A note says these are lesson 6's pieces and quiz questions, both models.

The same test as lessons 6 and 8:

  • Documents: 112 lessons of this course, cut into 1,823 pieces of 200 words. Each lesson gets the score of its best-matching piece.
  • Questions: the 470 quiz questions inside those lessons. The right answer is the lesson a question came from.
  • Two models: nomic-embed-text and bge-m3, full-size vectors.
  • Four ways to store the pieces: 4 bytes, 1 byte and 1 bit for each number, plus one more, explained below: 1 bit first, then the best 50 pieces checked again with the full numbers.

The question itself is always embedded at full detail. Only the stored pieces are made smaller. With 1 bit, the question also becomes signs, so both sides look the same.

Here is the lab's report, from the terminal.

A real terminal recording of quantize.py's report: 470 quiz questions, 1,823 pieces of 112 lessons. nomic-embed-text, 768 numbers: float32, 3,072 bytes a piece, 339 first and 441 in the top 5; int8, 768 bytes, 338 and 442; binary, 96 bytes, 291 and 405; binary plus rescore, 96 bytes searched plus 3,072 kept, 339 and 435. bge-m3, 1,024 numbers: float32, 4,096 bytes, 356 and 439; int8, 1,024 bytes, 356 and 439; binary, 128 bytes, 325 and 415; binary plus rescore, 128 plus 4,096 kept, 356 and 438.

As a check, 4 bytes should repeat lesson 8's full-size result, and it did: 441 and 439 in the top 5.

One Byte Cost Nothing Here

A bar chart of the right lesson in the top 5, of 470, for both models: 4 bytes, 441 and 439; 1 byte, 442 and 439; 1 bit, 405 and 415; 1 bit plus rescore, 435 and 438. Titled one byte costs nothing here.

  • 1 byte: nomic found the right lesson in the top 5 for 442, against 441 with 4 bytes. bge-m3 found 439 both ways, and not one of its questions changed. For nomic, 1 question moved into the top 5 and none moved out. A difference of one question is noise. In a quarter of the space, the results were the same.
  • 1 bit: nomic fell to 405, 36 fewer. bge-m3 fell to 415, 24 fewer. Question by question, nomic lost 37 and gained 1; bge-m3 lost 28 and gained 4. One bit per number is thirty-two times smaller, and here it lost many right answers.

Two panels, top 5 of 470. nomic with 1 byte: 442, against 441 with 4 bytes. bge-m3 with 1 byte: 439, against 439. A note says one byte per number, and the same answers.

Counting only the answers that came first shows the same thing.

A bar chart of the right lesson ranked first, of 470. 4 bytes: 339 and 356. 1 byte: 338 and 356. 1 bit: 291 and 325. 1 bit plus rescore: 339 and 356. A note says 1 bit alone ranked 291 and 325 first, and with rescore 339 and 356, as many as 4 bytes.

Rough First, Exact Second

One bit alone lost answers. But bits are small, so comparing them takes very little arithmetic. That suggests a trick: use them for a rough first pass, then check only a short list carefully.

A sequence diagram with three columns: the question, a bits index with 1 bit for each number, and the full numbers at 4 bytes for each number. Step 1: compare signs with all pieces. Step 2: pass on the best 50 pieces. Step 3: score those 50 again. Step 4: return the top 5. A note says the full numbers still have to be kept somewhere, but only 50 are read per question.

  1. Compare the question's signs with every piece's signs. Keep the best 50.
  2. Score only those 50 again with the full 4-byte numbers, and return the top 5.

The 50 is a choice, not a rule. A longer list catches more but reads more full numbers. This lesson tried only 50.

A hand-drawn sketch: a question box points to all 1,823 pieces; an arrow labelled bits leads down to the best 50; an arrow labelled full numbers leads to the top 5. The Ollama logo sits below. A note says the bits find a short list quickly, and the full numbers put it in the right order.

It worked. Ranked first, it matched 4 bytes exactly: 339 and 356. In the top 5, nomic went from 405 to 435, and bge-m3 from 415 to 438.

A card: top 5 of 470, 1 bit alone then 1 bit with the best 50 re-scored. nomic: 405 to 435, against 441 with 4 bytes. bge-m3: 415 to 438, against 439. A note says only 50 of 1,823 pieces were read at full size per question.

There is a catch, and it matters. The second pass needs the full 4-byte numbers, so you still have to keep them somewhere. Rescoring does not save storage. What it saves is work: the first pass runs over small bits, and the full numbers are read for only 50 pieces per question. A system can keep the bits in memory (fast, but costly) and the full numbers on disk (slower, but cheap). This lesson did not measure the speed.

What Each Way Costs to Keep

Storage for the numbers alone, 1 million documents with nomic's 768 numbers, marked as arithmetic and not a measurement. 4 bytes for each number: 3.1 GB, float32 as returned. 1 byte for each number: 0.77 GB, a quarter. 1 bit for each number: 0.10 GB, a thirty-second. A note says rescoring also keeps the full 4-byte numbers, usually on cheaper storage.

This is arithmetic, not a measurement: 1 million documents, times 768 numbers, times the size of each number.

  • 4 bytes for each number: 3.1 GB.
  • 1 byte for each number: 0.77 GB.
  • 1 bit for each number: 0.10 GB.

How much faster each one makes a real database, this lesson did not measure.

Count It Yourself

This box holds the real ranks from the test for nomic-embed-text: for each of the 470 questions, which place the right lesson got in the list, for all four ways. Press Run. Then change WAY to "int8" or "binary_rescore", and TOP to 1.

Which Way to Store Your Embeddings

A flowchart: is memory the problem? If no, keep 4 bytes. If yes, try 1 byte and measure. Still too big? If yes, 1 bit plus rescore, and measure; if no, keep 1 byte. A note says here 1 byte lost nothing, and 1 bit alone had 36 fewer for nomic and 24 fewer for bge-m3.

  1. If memory is not a problem, keep the 4-byte numbers. Nothing to decide.
  2. If it is, try 1 byte for each number first. Here it lost nothing, and it is a quarter of the size.
  3. If that is still too big, use 1 bit with a rescore step, and keep the full numbers on cheaper storage.
  4. Measure on your own questions each time. You need questions where you already know the right document, like the quiz questions here.

Many vector databases (databases built to store and search them) can store 1-byte or 1-bit embeddings for you. How each one does it varies, so check its documentation, and measure again after you switch.

Two cards with logos: Ollama running nomic and bge-m3, and NumPy rounding, taking signs and rescoring. A note says it is free and local.

What This Lesson Measured, and What It Did Not

Two columns. Measured: two models, four ways; 470 quiz questions; one simple 1-byte method. Not measured: speed in a real database, other 1-byte methods, questions from real users.

Measured: two models, four ways to store the numbers, 470 quiz questions over 1,823 pieces.

Not measured: speed inside a real database; other shortlist sizes than 50; other ways of making 1-byte numbers (this lesson used the simplest, one shared factor); and questions from real users. The quiz questions were written by the same author as the lessons, which probably makes them easier to match than real questions.

What to Do Next

A hand-drawn list of four things to do: run smaller_numbers.py and compare the three columns; store 1 byte for each number if memory matters; use 1 bit only with a rescore step, and keep the full numbers; and measure on your own questions, before and after. A note says less detail per number, the same answers, if you check.

  1. Run smaller_numbers.py and compare the three columns.
  2. Store 1 byte for each number if memory matters.
  3. Use 1 bit only with a rescore step, and keep the full numbers somewhere.
  4. Measure on your own questions, before and after.

The number to keep: 442 against 441, nomic in the top 5 with 1 byte for each number against 4 bytes. A note says a quarter of the memory, and one bit needs a rescore step.

Knowledge Check

Knowledge Check

5 questions - Score 80% to pass

Q1

Storing each embedding number in 1 byte instead of 4 gave nomic 442 in the top 5, against 441. What does that tell you?

Q2

How does the 1-byte method in this lesson turn a number like 0.05 into one byte?

Q3

With 1 bit per number, how are a question and a piece compared?

Q4

Ranking by bits, then re-scoring the best 50 with the full numbers, gave nomic 435 in the top 5, close to 441. What is the catch?

Q5

Memory is tight and you want to store embeddings smaller. Based on this lesson, what should you try first?

# Store each embedding number in 4 bytes, 1 byte or 1 bit, and see whether search still finds the right text.
# Needs Ollama (ollama.com) running, and:  ollama pull nomic-embed-text
import json, urllib.request

def embed(texts):
    body = {"model": "nomic-embed-text", "input": texts}
    req = urllib.request.Request("http://localhost:11434/api/embed", data=json.dumps(body).encode(),
                                 headers={"Content-Type": "application/json"})
    return json.loads(urllib.request.urlopen(req).read())["embeddings"]

texts = ["Rate limiting: cap how many requests each user may send per minute.",
         "Caching: keep a copy of data close to where it is used.",
         "Load balancing: spread incoming traffic across several servers.",
         "Sharding: split one big table across many machines.",
         "Message queues: let one service hand work to another later."]
question = "How can I stop one user from sending too many requests?"

docs = embed(["search_document: " + t for t in texts])
q = embed(["search_query: " + question])[0]

# 1 byte a number: scale every number into -127..127 with one shared factor, then round
scale = 127 / max(abs(x) for d in docs for x in d)
docs_int8 = [[round(x * scale) for x in d] for d in docs]

# 1 bit a number: keep only whether each number is above zero
bits = lambda v: [x > 0 for x in v]
q_bits = bits(q)

for t, d, d8 in zip(texts, docs, docs_int8):
    full = sum(a * b for a, b in zip(q, d))                     # length 1, so this is cosine
    one_byte = sum(a * b / scale for a, b in zip(q, d8))
    agree = sum(a == b for a, b in zip(q_bits, bits(d)))        # positions with the same sign
    print(f"{t.split(':')[0]:<15} 4 bytes {full:.3f}   1 byte {one_byte:.3f}   1 bit {agree}/{len(q)} agree")

Five texts prove nothing on their own. The real test comes next.