The World Vol 8 · Words
ch 87 / 105
Chapter 87

Meaning as a Direction

The corpse question

A person can ask where does a corpse rot? and see that the founding line about bodies, husks and fallen things answers it. A text search cannot: the question has five words, the line has twenty-three, and none of the words appear in both lists.

A model can turn any piece of text into a fixed-length row of numbers, and what that row carries is a direction; two texts that mean nearly the same thing come back pointing nearly the same way, and the angle between the rows is the comparison. Lowercasing, stemming and rare-word weights cannot manufacture a link between corpse and bodies. The link is in what they mean, and meaning is not a property of a string.

Length is a fact about the model's arithmetic, not about the text. A comparison that lets length in is partly comparing the wrong thing, so the row has to be reduced to its direction before one row is held against another.

The measuring is arithmetic this book did years ago in two numbers. Volume 3 built a vector as a pair kept together with operations of its own: Len, which is the square root of the sum of the squares, and Unit, which divides both numbers by that length so the result reaches exactly one and points the same way. Both rules are written for two numbers and neither of them cares how many there are. A row of 768 is the same sum with more terms in it, and by the end of this page it will be running over rows that a model wrote.

Four things will exist that do not now. A file in internal/lang with the dot product, the length, the normalisation and the cosine in it, worked first on vectors of three whole numbers that anybody can check with a pencil. One more method on the client from earlier in this volume, which asks a model for a row instead of for a sentence, and which is how the width of a row gets read off a real answer instead of asserted. A committed fixture of the chronicle's own lines turned into numbers, so that every similarity printed in this book is computed over bytes that do not move. And a ranking: sixteen lines of this world's history sorted against a question, with the line that has no word in common with it at the top.

It is fair to ask why this world needs any of it today. Ninety-one lines fit in a terminal, and nobody has to search a file they can read. The answer is that the chronicle is the one thing here that only grows: it takes an entry every year of simulated time and it never rewrites one, so a valley left running unattended for a month has a history no person is going to read in order. Being able to put a question to it in ordinary English and get the four lines it is about is useful the moment it exists, on the corpus that exists, and it needs nothing else built first to be useful.

One boundary, drawn now so that nothing later has to be taken back. Nothing on this page feeds a result to a model. A question comes in, rows come back, the rows get printed, and that is the end of it. No answer is composed, no line of the chronicle is handed to anything that generates text, and nobody in The Hollow says a word. What this builds is a search over a file that you run by asking in English: it remembers nothing, weighs nothing and decides nothing, it sorts sixteen rows and prints the top of the list, and that is a whole thing on its own.

The three-number vectors

Nothing about this measurement needs 768 of anything. It needs at least two numbers to have a direction at all, and three is enough to see everything the operation does, so the arithmetic gets built and checked at three before a model is asked for anything. The vectors below are chosen so that every length is a whole number and every answer is a fraction with a small denominator. A reader who works them out on paper and gets what the program got has checked the program, which is a better position to be in than trusting it.

The whole file is four functions. The first is the dot product: multiply the two rows together number by number and add up what comes out.

▣ Build · stage 1 — the dot product, the length, and the angle
// internal/lang/near.go
// Dot is the sum of the products, pairwise, and it is refused when
// the two rows are not the same width.
//
// Two vectors of different widths have no angle between them at all,
// and the widths that turn up in practice are two different models'.
// Returning a number for that pair would be answering a question
// nobody asked.
func Dot(a, b []float32) (float64, error) {
	if len(a) != len(b) {
		return 0, fmt.Errorf("lang: a row of %d numbers against a row of %d", len(a), len(b))
	}
	if len(a) == 0 {
		return 0, fmt.Errorf("lang: a row of no numbers has no direction")
	}
	var s float64
	for i := range a {
		s += float64(a[i]) * float64(b[i])
	}
	return s, nil
}
// internal/lang/near.go
// Length is how far the row reaches from the origin: the square root
// of the sum of the squares, which is the same rule this book has
// used for a two-number vector since the valley learned to move.
func Length(a []float32) (float64, error) {
	s, err := Dot(a, a)
	if err != nil {
		return 0, err
	}
	return math.Sqrt(s), nil
}
// internal/lang/near.go
// Cos is the dot product over the product of the two lengths.
//
// On rows that are already at length one the two divisions are by
// one and this is the dot product; the divisions stay in anyway,
// because whether a server hands back unit rows is a fact about that
// server on that day and not a promise anybody made.
func Cos(a, b []float32) (float64, error) {
	d, err := Dot(a, b)
	if err != nil {
		return 0, err
	}
	na, err := Length(a)
	if err != nil {
		return 0, err
	}
	nb, err := Length(b)
	if err != nil {
		return 0, err
	}
	if na == 0 || nb == 0 {
		return 0, fmt.Errorf("lang: no angle between a row of zeros and anything")
	}
	return d / (na * nb), nil
}
$ go run ./cmd/near -mode paper
near: five directions against one question, in three numbers each

  the question                       (3, 4, 0)

                                            the row   length        dot    cosine
  the question itself                     (3, 4, 0)   5.0000
  the same direction, twice as long       (6, 8, 0)  10.0000    50.0000    1.0000
  a little to one side                    (4, 3, 0)   5.0000    24.0000    0.9600
  square on to it                        (-4, 3, 0)   5.0000     0.0000    0.0000
  straight back the other way           (-3, -4, 0)   5.0000   -25.0000   -1.0000
  pointing elsewhere, and long          (12, 9, 20)  25.0000    72.0000    0.5760

  ranked on the dot product alone
    1    72.0000  length 25.00  pointing elsewhere, and long
    2    50.0000  length 10.00  the same direction, twice as long
    3    24.0000  length  5.00  a little to one side
    4     0.0000  length  5.00  square on to it
    5   -25.0000  length  5.00  straight back the other way

  ranked on the cosine
    1     1.0000  length 10.00  the same direction, twice as long
    2     0.9600  length  5.00  a little to one side
    3     0.5760  length 25.00  pointing elsewhere, and long
    4     0.0000  length  5.00  square on to it
    5    -1.0000  length  5.00  straight back the other way

  the two orders disagree about which row is first
  and the row the dot product put there is the longest one

Take the second row by hand. Its numbers are 6, 8 and 0, so its length is the square root of 36 + 64 + 0, which is the square root of 100, which is 10. Its dot product with the question is 3×6 + 4×8 + 0×0 = 18 + 32 = 50. The question's own length is the square root of 9 + 16, which is 5, and 50 divided by 5×10 is exactly 1. That row is the question doubled: same direction, twice as far along it, and the cosine says the angle between them is nothing at all.

The last row is the one to sit with. Its numbers are 12, 9 and 20, its length is the square root of 144 + 81 + 400 = 625, which is 25, and its dot product with the question is 36 + 36 + 0 = 72. Seventy-two is the largest dot product on the table, larger than the 50 belonging to the row that is the question exactly. And its cosine is 0.576, which puts it third. It won the first ranking by being long, and it lost the second one by pointing somewhere else, and both of those are the same fact about it.

∑ Math Interlude — the dot product, and the cosine of the angle

All of the above was arithmetic in plain numbers, and here is the shorthand for it. A vector is still written as its numbers in brackets, in order, exactly as volume 3 wrote (3, 4). It has more of them now, and a row of 768 gets written (a1, a2, …, a768) so that a single number in it can be pointed at.

The multiply-and-add operation gets a dot: a · b, said "a dot b". Written out for three numbers it is a·b = a1b1 + a2b2 + a3b3, and for (3, 4, 0) and (6, 8, 0) that is 18 + 32 + 0 = 50. Writing out 768 of those terms is not practical, so the sum gets a symbol of its own, a capital sigma, with the counter underneath it and where to stop on top: i=1n aibi means take every i from 1 to n, multiply the two numbers in that position, and add all the results together. That is the loop in Dot, written in one line.

Length comes for free from the same operation, because a·a is every number multiplied by itself and added up, which is what goes under the square root: |a| = √(a·a). For (12, 9, 20) that is √(144 + 81 + 400) = √625 = 25. Volume 3's rule for two numbers is this rule with the sum stopped early.

And the measurement this chapter is about:

cos θ = (a · b) ÷ (|a| × |b|)

The Greek letter θ, said "theta", is the usual name for an angle, and cos θ is a number between −1 and 1 that says how far apart two directions are: 1 for the same direction, 0 for square on, −1 for straight back the other way. The one thing to notice is where the lengths went. Both of them are divided out, which means multiplying either vector by any positive number leaves the answer untouched: (6, 8, 0) and (3, 4, 0) and (300, 400, 0) all score 1 against the question, because they are the same direction at three different distances.

The last piece of shorthand is the hat from volume 3, and it now earns its keep. â means a with every number divided by |a|, so that |â| = 1. If both rows have already had that done to them, both divisions in the rule above are divisions by one, and cos θ is â · b̂. That is why systems that compare millions of these normalise everything once on the way in: it turns every later comparison into a bare multiply-and-add.

(a1, …, an)a vector of n numbers, in a fixed order
nhow many numbers a row holds: 3 on this page's paper example, 768 for the model below
aithe number in position i of the row a
i=1n xiadd up x1, x2 and so on to xn
a · bthe dot product: multiply the two rows position by position and add the lot
|a|the length of a, which is √(a·a) and never negative
âa at length 1: every number of a divided by |a|
θan angle; cos θ runs from 1 for the same direction to −1 for the opposite one
Five directions from one origin, and what the cosine says about each A pair of axes crossing at an origin. Five arrows leave it. The question, (3, 4, 0), goes up and to the right and is drawn heavier than the rest. A second arrow lies along exactly the same line but reaches twice as far, labelled (6, 8, 0) and cosine 1.00. A third leans slightly clockwise of the question, labelled (4, 3, 0) and cosine 0.96. A fourth goes up and to the left at a right angle to the question, labelled (-4, 3, 0) and cosine 0.00. A fifth points down and to the left, directly opposite the question, labelled (-3, -4, 0) and cosine -1.00. Underneath, the rule for the cosine, a note that the two arrows sharing a line score the same, and a note that the run's fifth row has a number in its third place and does not lie in this plane. FIVE DIRECTIONS FROM ONE ORIGIN (6, 8, 0) length 10 cosine 1.00 the question (3, 4, 0) length 5 (4, 3, 0) cosine 0.96 (-4, 3, 0) cosine 0.00 (-3, -4, 0) cosine -1.00 cos t = (a . b) / (|a| x |b|) the two arrows on one line score the same: both lengths are divided out the run's fifth row has a number in its third place and is not in this plane
Figure 87.1 — four of the five rows the bench prints have a zero in the third place, so they lie flat and can be drawn. The two along one line are the whole idea: the far one is ten long and the near one is five, and the measurement cannot tell them apart because there is no angle between them to find.

The 768-number embedding row

Real rows come from a model, and asking for one takes a method the client from earlier in this volume did not have. It is shorter than the one that asks for a sentence, and the difference is the interesting part: there is nothing to configure.

▣ Build · stage 2 — one more method on the client
// internal/lang/serve.go
// Embed turns one line of text into one row of numbers.
//
// It asks for the row the model computed and does no arithmetic on
// it. Some servers hand back a row already at length one and some do
// not, and a caller that assumes either is a caller whose ranking
// changes when somebody upgrades something. This world reads the row
// as it comes, keeps it as it came, and divides the length out at the
// moment it compares two of them.
func (c *Client) Embed(ctx context.Context, model, text string) ([]float32, error) {
	if model == "" {
		return nil, fmt.Errorf("lang: an embedding with no model named")
	}
	if text == "" {
		return nil, fmt.Errorf("lang: an embedding of no text at all")
	}
	body, err := json.Marshal(embedReq{Model: model, Prompt: text})
	if err != nil {
		return nil, fmt.Errorf("lang: %s: %w", model, err)
	}
	var res embedRes
	if err := c.post(ctx, "/api/embeddings", body, &res); err != nil {
		return nil, err
	}
	if len(res.Embedding) == 0 {
		return nil, fmt.Errorf("lang: %s: the server answered with a row of no numbers", model)
	}
	return res.Embedding, nil
}

No temperature, no seed, no token limit, no context length. Everything the generating method had to argue about was there because something was being sampled, and nothing is being sampled here: the model reads the text, runs it forward once, and reduces what comes out to one row. There is no distribution to draw from and no draw to make. That makes an embedding a much duller thing to ask for than a sentence, and much easier to reason about.

The comment on the method is doing real work and it is the reason the length is not divided out here. A server that returns rows at length one has made a choice, and it is a choice it can revise in a release note. Code that leans on it reads correctly, ranks correctly, and then one day ranks by length instead, with nothing in the diff to say so. Keeping the row as it arrived and dividing at the comparison costs two square roots per comparison and cannot break that way.

Now the question nothing in this world has answered yet: how wide is a row? The kit names three models and pins each by the digest of its weights, and it says nothing about widths, because a width is not a setting anybody chooses. It belongs to the model. So the bench asks for one row and reports everything about the answer except the answer.

▣ Build · stage 3 — the width, read off a real answer
$ podman exec -w /bench world-go go run ./cmd/near -mode live -q 1
near: one line sent to a real model, and everything about the answer except the answer

  the server         world-lm:11434
  the model          nomic-embed-text:v1.5
  pinned at          970aa74c0a90ef74
  the text           "Where does a corpse rot?"

  what came back
    numbers                        768
    of them finite                 768
    at length one, within 1e-06    false
    at length nothing              false

  against the committed row for the same text
    same width                     true
    every number within 1e-06      true
    identical, number for number   true

  768 is this model's own width and nothing here chose it
  none of the numbers is on this page; yours would not be these ones anyway

768. Every figure in the rest of this chapter is that number, and it was read off an answer rather than looked up. A different embedding model answers with a different width and every one of those widths is that model's, which is the same fence this volume put around tokens a second and around bits a weight: a number measured against one model is not a number about another one, whatever the two have in common.

It is also a different kind of width from the ones this volume has been reading out of model files, and mixing the two up is easy. A generating model has a width inside it, the size of the vector each token is turned into on its way through the stack, and that number belongs to the file and to the arithmetic that reads it. The 768 above is the width of what comes out of a model built to do one job: read a whole piece of text and produce one row that stands for the lot. A model of the first kind can be asked to do this too, and answers at a width of its own, which is a third number in the same conversation and about neither of the others.

The third line of the first block is the one that saves trouble later. This server does not hand back rows at length one. It could have; several do; this one gives the numbers the model computed and leaves the arithmetic to whoever asked. Had that line come back true, everything on this page would still be right, and the code would still divide, and the difference would only show up on the day somebody swapped the server.

The rest of the run is a comparison and never a printout. It says the live answer is the same width as the committed one, that no number in it is more than a millionth away from the committed one, and that on this machine today the two are identical to the last bit. What it does not do is print any of the 768 numbers, and that restraint is the rule this whole chapter is built on. Every float a model produced stays off the page, and yours would be different numbers anyway.

Which raises the problem that the rest of the volume has to solve. A book that computes a similarity from a live model prints a number nobody else can reproduce: not a reader on different hardware, not the same reader after an upgrade, and not this world's own verification a year from now. The answer is the one this volume already used for a header too big to carry. Ask once, write it down, and compute everything afterwards over the bytes that were written.

It is useful to be exact about what that buys, because it is the difference between a chapter of arithmetic and a chapter of anecdotes. A row out of a live model depends on the machine, the build of the server, the arithmetic the backend chose and how the request happened to be batched, and none of those is under a reader's control or under this book's. A similarity computed from such rows is therefore not a number anybody can check. A similarity computed from committed rows is exactly as checkable as any other division: the bytes are in the repository, the loop is nine lines, and a run of it here and a run of it on a machine built ten years from now print the same digits. Every score, every ranking and every byte count on the rest of this page is of the second kind.

▣ Build · stage 4 — sixteen lines of history, kept as numbers
// exemplars/exemplars.go
//go:embed chronicle.jsonl hero.header.json small.header.json vectors.json
var Files embed.FS

// Vectors is the name of the committed embedding fixture inside
// Files.
const Vectors = "vectors.json"
// exemplars/exemplars.go
// Vec is one embedded piece of text: what was sent, and the row that
// came back.
//
// Entry and Kind are filled in for a chronicle line and empty for a
// question, because a question is not in the chronicle and never
// becomes one. Nothing here is written back to the world.
type Vec struct {
	Entry int64     `json:"entry,omitempty"`
	Kind  string    `json:"kind,omitempty"`
	Text  string    `json:"text"`
	V     []float32 `json:"v"`
}
$ podman exec -w /bench world-go go run ./cmd/near -mode fix
near: the chronicle's rows turned into numbers, once, and written down

  the model          nomic-embed-text:v1.5
  pinned at          970aa74c0a90ef74
  the server         world-lm:11434, waiting up to 10m0s for an answer
  the corpus         16 of the chronicle's 91 rows, and 4 questions

  every answer was 768 numbers wide, which is the model's own and not a setting
  written to near87-vectors.json, 173584 bytes

  against the copy this build carries
    exemplars/vectors.json           ac91f2b9ab427785     173584 bytes
    near87-vectors.json              ac91f2b9ab427785     173584 bytes

  the 15360 numbers this run was given, against the committed ones, at a tolerance of 1e-06
    not one of them differs at all

Sixteen chronicle lines and four questions, twenty rows of 768 numbers, 173,584 bytes on disk. The lines are the eleven founding entries and five of the eighty yearly counts, chosen as a list and not as a rule: a rule like every tenth row would embed a different set the day the chronicle grew, and every number in this chapter would move with it. The file records which model produced it and at which digest, and it carries its own label, so that a reader who finds it in ten years knows what it is without finding this page: Yours will differ: a different model, build, backend or machine answers with different numbers.

The last two blocks of the run are the check that keeps the fixture honest, and they are the same check the header dumps in this volume get. The mode does the work again against the live server, writes its own file under a name of its own, and holds the result against the copy the build carries. Here the two files came out byte for byte identical, which is a fact about this machine on this day and not a promise: the guarantee that travels is the one the tolerance line makes, that no number moved by more than a millionth. Take the model away and the arithmetic in this chapter still runs, on the same bytes, to the same digits, on any machine.

With the rows carried, the first thing to look at is the quantity this chapter keeps dividing out.

▣ Build · stage 5 — every row, and how long it is
$ go run ./cmd/near -mode table
near: the committed rows, and how long each of them is

  the model     nomic-embed-text:v1.5
  pinned at     970aa74c0a90ef74
  the rows      16 chronicle lines and 4 questions, 768 numbers each

  entry   chars   length   the line
  1         323  17.5036   This is The World. I built it and I run it, and o...
  2         194  20.0103   The land is The Hollow. It is one valley with sto...
  3         161  19.5147   The animals are hobbs. One of them is a hobb. The...
  4         210  19.3554   Asteria keeps it. She is not down in the valley a...
  5         178  19.0002   People gather at Firstlight. Nobody is there and ...
  6          96  20.3982   The way in is The Commons. Anybody who comes here...
  7          92  20.8602   The doors between one part of the ground and anot...
  8         181  19.1976   What changes hands is grain. Nothing here trades ...
  9          96  20.1037   Past the mapped ground are the Unwritten Places. ...
  10        123  19.9950   What is finished with goes to the Midden. Bodies,...
  11        138  19.4362   The thing worth going after is the Wellspring. I ...
  12         60  20.1230   year 1: 11 hobbs standing in The Hollow, 45 born ...
  16         62  20.4787   year 5: 43 hobbs standing in The Hollow, 252 born...
  21         65  20.2115   year 10: 191 hobbs standing in The Hollow, 1013 b...
  51         66  19.9020   year 40: 235 hobbs standing in The Hollow, 1773 b...
  91         66  20.1346   year 80: 238 hobbs standing in The Hollow, 1965 b...

          chars   length   the question
             24  22.4438   Where does a corpse rot?
             31  22.5440   How does a newcomer get inside?
             26  22.5639   Do the animals have names?
             28  22.5054   How many creatures survived?

  the shortest row is 17.5036 and the longest 20.8602, a spread of 19.2%
  and none of that spread is about what the line says

Read the length column against the character column and look for a relationship. Entry 1 is the longest line in the fixture at 323 characters and it has the shortest row, 17.5036. Entry 7 is one of the shortest lines at 92 characters and has the longest row, 20.8602. Entry 12 and entry 91 are the same kind of sentence with different numbers in it, 60 characters against 66, and their rows come out 20.1230 and 20.1346. The four questions, which are shorter than every line in the table, all sit above 22.4.

So length is not text length, and it is not information content, and it is not importance. It is what the model's last few operations happened to produce, and the nineteen percent spread across these sixteen rows is a spread in something nobody asked for. That is the quantity the cosine divides away, and the reason for dividing it away is not tidiness: it is that leaving it in means ranking partly on it.

The chronicle search ranking

Everything is in place. The rows are on disk, the arithmetic is four functions, and the question at the top of this chapter can now be put to sixteen lines of this world's history. The bench scores every row against the question, sorts, and prints the top of the list with a column nobody would normally put in a search result: how many words each line has in common with the question that found it.

▣ Build · stage 6 — sixteen lines, ranked by angle
$ go run ./cmd/near -mode ask -q 1
near: the chronicle's rows ranked against one question

  the question   Where does a corpse rot?
  the rows       16, of 768 numbers each, from nomic-embed-text:v1.5
  scored by      the cosine: the dot product over the two lengths

  rank      cosine   length  entry words  the line
  1       0.602859  19.9950     10     0  What is finished with goes to the Midden. B...
  2       0.533825  19.5147      3     1  The animals are hobbs. One of them is a hob...
  3       0.516247  20.0103      2     0  The land is The Hollow. It is one valley wi...
  4       0.474781  20.8602      7     1  The doors between one part of the ground an...
  5       0.451704  17.5036      1     1  This is The World. I built it and I run it,...
  6       0.449923  20.1346     91     0  year 80: 238 hobbs standing in The Hollow, ...

  the line at the top is entry 10
  and it has not one word in common with the question

It found it. Entry 10 is the line about the Midden, it is at the top by a clear margin, and the words column on its row is a zero. The second place is the line about the animals, which is a reasonable thing to return for a question about a corpse; the third is the line describing the valley's ground, which is where things end up; and the sixth is a yearly count with a number of deaths in it. The list degrades the way a person would degrade it.

None of this is a lookup. Nothing in the fixture was tagged death, nobody wrote a synonym list, and the word corpse does not occur anywhere in the chronicle. What happened is that a model that has read a great deal of English puts texts about the same subject in nearly the same direction, and a question is a text. The whole search is one multiply-and-add per row and a sort.

Now the same rows and the same question, ranked the way a search that matches text would have to rank them.

▣ Build · stage 7 — the other ranking, for comparison
$ go run ./cmd/near -mode words -q 1
near: the same rows ranked by the words they share with the question

  the question   Where does a corpse rot?
  its words      where does a corpse rot
  the rows       16 chronicle lines

  rank words      cosine  entry  which words        the line
  1        1    0.451704      1  a                  This is The World. I built it and I r...
  2        1    0.533825      3  a                  The animals are hobbs. One of them is...
  3        1    0.394670      4  does               Asteria keeps it. She is not down in ...
  4        1    0.442220      5  a                  People gather at Firstlight. Nobody i...
  5        1    0.474781      7  a                  The doors between one part of the gro...
  6        0    0.516247      2                     The land is The Hollow. It is one val...

  the angle puts entry 10 first; the words put it 10 of 16
  and 5 rows are tied at the top of the word ranking, on one word each

The best any line manages is one word, and in four of the five cases that word is a. Those five are tied at the top on one word each, and the line the question was actually about comes tenth of sixteen with a score of zero, level with ten other lines that have nothing to do with it either. A stop-word list would take the four a rows out and leave the ranking even flatter. Nothing that can be done to a word-matching search rescues this query, because the query and its answer have no words in common at all, and that is not an unusual thing for a person to type.

Second question, and it is the same result from a different direction.

$ go run ./cmd/near -mode ask -q 2 -top 4
near: the chronicle's rows ranked against one question

  the question   How does a newcomer get inside?
  the rows       16, of 768 numbers each, from nomic-embed-text:v1.5
  scored by      the cosine: the dot product over the two lengths

  rank      cosine   length  entry words  the line
  1       0.636975  20.3982      6     0  The way in is The Commons. Anybody who come...
  2       0.531150  20.8602      7     1  The doors between one part of the ground an...
  3       0.467180  19.0002      5     1  People gather at Firstlight. Nobody is ther...
  4       0.447553  20.1037      9     0  Past the mapped ground are the Unwritten Pl...

Entry 6 is the founding line about The Commons, the way in, and again the question and the line share no word at all. What is nice about this one is the second and third places: Waygates, which are the doors between one part of the ground and another, and Firstlight, which is where people gather when they get here. A reader looking for how somebody arrives would want all three of those, roughly in that order.

The two ways of searching are not always opposed, and a chapter that only ever showed them disagreeing would be selling something. Here is a question whose words the chronicle does use.

▣ Build · stage 8 — where both rankings agree, and what that costs
$ go run ./cmd/near -mode words -q 3 -top 4
near: the same rows ranked by the words they share with the question

  the question   Do the animals have names?
  its words      do the animals have names
  the rows       16 chronicle lines

  rank words      cosine  entry  which words        the line
  1        2    0.449691      1  the names          This is The World. I built it and I r...
  2        2    0.445739      2  the have           The land is The Hollow. It is one val...
  3        2    0.722571      3  the animals        The animals are hobbs. One of them is...
  4        2    0.439437      4  the have           Asteria keeps it. She is not down in ...

  the angle puts entry 3 first; the words put it 3 of 16
  and 6 rows are tied at the top of the word ranking, on 2 words each

Do the animals have names? is a question the word ranking can nearly do. Entry 3 is the right answer, it does contain animals, and it comes third. The trouble is the company: six rows are tied on two words each, and the other five got there on the plus one of have or names. The cosine column beside them tells the same story with no ties at all: entry 3 scores 0.722571 and the rest of that tied group sits between 0.412 and 0.450. One method has the right answer buried in a six-way tie and the other has it clear of the field by a quarter.

And here is a question the angle handles honestly and does not answer.

$ go run ./cmd/near -mode ask -q 4 -top 4
near: the chronicle's rows ranked against one question

  the question   How many creatures survived?
  the rows       16, of 768 numbers each, from nomic-embed-text:v1.5
  scored by      the cosine: the dot product over the two lengths

  rank      cosine   length  entry words  the line
  1       0.636633  19.5147      3     0  The animals are hobbs. One of them is a hob...
  2       0.536719  20.4787     16     0  year 5: 43 hobbs standing in The Hollow, 25...
  3       0.505581  20.1230     12     0  year 1: 11 hobbs standing in The Hollow, 45...
  4       0.484232  19.9020     51     0  year 40: 235 hobbs standing in The Hollow, ...

The top line is the founding entry about the animals, which contains no number at all. The three lines behind it are yearly counts, which contain nothing but numbers, and they are in no particular order of year. A search by meaning found the lines that are about the subject, and that is all it did: it has no idea which of them holds a number, and no notion that a question beginning "how many" wants one. Ranking by subject is what this is for, and expecting an answer out of it is expecting something it was never doing.

Why this works

Underneath all of it is one substitution that turns up wherever a computer has to compare things it cannot compare directly. Two texts have no arithmetic between them. Two rows of numbers have all the arithmetic anybody wants. So the problem gets moved: put every text somewhere in a space with many directions in it, arrange for texts about the same subject to land near each other, and then the question are these two about the same thing becomes the question how far apart are these two directions, which is a division and a square root.

The arranging is the model's contribution and the reader did not do it. What the reader does own, entirely, is the measurement, and it is small enough to hold in your head. The same substitution shows up with different words attached wherever there are vectors of counts: two documents compared by which words are in them, two players compared by which items they bought, two songs compared by who listened. In every one of those the raw dot product rewards magnitude, which is a stand-in for volume rather than for likeness, and dividing by the two lengths is the standard repair.

The reason it is the standard repair needs to be stated plainly, because it is not obvious that dividing by lengths should mean anything. The dot product of two rows is already almost the right measurement: it is large when the two agree about which positions are big, and negative when they disagree. What spoils it is that it is also large when either row is large in every slot. Dividing by both lengths removes exactly that and nothing else. The result is bounded between −1 and 1 whatever the rows, which is what makes scores from different queries comparable at all, and which is the property that lets a system put a threshold on the number and refuse anything under it.

The one thing the paper example cannot show is why the rows are so wide. Three numbers give a direction and a perfectly good angle, and they also give very little room: with three, any large collection of subjects has to crowd, and two things with nothing to do with each other end up pointing much the same way because there is nowhere else for the second one to point. Widen the row and the crowding eases, because there are more independent ways for two texts to differ. Seven hundred and sixty-eight is somebody else's answer to how much room is enough, arrived at by training and not by argument, and the reason it matters to a reader is this: the width is what makes a score of 0.60 mean closest of sixteen by a distance instead of closest of sixteen because everything is close to everything.

One property of these numbers deserves a caution, and the run above supplied it. A cosine of 0.60 is a good score in this corpus and it is nothing like a fraction of a right answer. The scores are comparable within one query, against one model, over one set of rows, and 0.45 in this table means the sixth best of sixteen rather than forty-five percent related. The barrier that has governed every measured number in this volume governs these too: a similarity is about the model that produced the rows, and two models' scores do not belong on the same axis.

Why cosine needs normalised rows

The cosine is two operations, and one of them is optional in the sense that the code runs without it. A programmer who writes the multiply-and-add loop and stops has written something that compiles, returns a number, sorts a list and looks right. Here is what it actually does, on the fixture, with the same question.

⚠ Worked failure — the ranking that turned out to be about length

The bench takes a flag that scores on the dot product alone, with neither length divided out. Everything else about the run is the same.

$ go run ./cmd/near -mode ask -q 1 -raw
near: the chronicle's rows ranked against one question

  the question   Where does a corpse rot?
  the rows       16, of 768 numbers each, from nomic-embed-text:v1.5
  scored by      the dot product alone, with neither length divided out

  rank         dot   length  entry words  the line
  1     270.541254  19.9950     10     0  What is finished with goes to the Midden. B...
  2     233.807164  19.5147      3     1  The animals are hobbs. One of them is a hob...
  3     231.850915  20.0103      2     0  The land is The Hollow. It is one valley wi...
  4     222.284454  20.8602      7     1  The doors between one part of the ground an...
  5     203.474198  20.4787     16     0  year 5: 43 hobbs standing in The Hollow, 25...
  6     203.318534  20.1346     91     0  year 80: 238 hobbs standing in The Hollow, ...

  the line at the top is entry 10
  and it has not one word in common with the question

The right answer is still first. That is the worst possible outcome, and it is the reason this failure is worth ten seconds of anybody's time: the missing division did not break the run, it broke the reasoning behind the run, and a test that only asserted "entry 10 comes first" would pass. The damage is further down. Entry 16 has climbed into fifth on a length of 20.4787 while entry 1, which the cosine put fifth, has dropped out of the top six on a length of 17.5036. The lengths in these rows only vary by nineteen percent, so a nineteen percent thumb is on the scale, and it is enough to move rows around without ever being visible.

To see what the scoring is actually sorting on, give it rows whose lengths differ by more than nineteen percent. There is an ordinary way for that to happen and it needs no invention: somebody stores rows the way the server sent them, somebody later changes the client to normalise on the way in, and the table ends up holding both. The -twin flag builds exactly that, by adding every row a second time at length one and marking the copy with a tick.

$ go run ./cmd/near -mode ask -q 1 -twin -raw -top 8
near: the chronicle's rows ranked against one question

  the question   Where does a corpse rot?
  the rows       32, of 768 numbers each, from nomic-embed-text:v1.5
  and again      every row a second time at length one, marked with a tick
  scored by      the dot product alone, with neither length divided out

  rank         dot   length  entry words  the line
  1     270.541254  19.9950     10     0  What is finished with goes to the Midden. B...
  2     233.807164  19.5147      3     1  The animals are hobbs. One of them is a hob...
  3     231.850915  20.0103      2     0  The land is The Hollow. It is one valley wi...
  4     222.284454  20.8602      7     1  The doors between one part of the ground an...
  5     203.474198  20.4787     16     0  year 5: 43 hobbs standing in The Hollow, 25...
  6     203.318534  20.1346     91     0  year 80: 238 hobbs standing in The Hollow, ...
  7     202.510987  20.1230     12     0  year 1: 11 hobbs standing in The Hollow, 45...
  8     201.902405  20.1037      9     0  Past the mapped ground are the Unwritten Pl...

  the line at the top is entry 10
  and it has not one word in common with the question
  16 rows come before the first one that was stored at length one, out of 16
  which is every one of them, so the first thing this ranking sorted on was length
  no row and its twin score more than 1.58e-09 apart, the worst of them entry 5

Thirty-two rows, sixteen of them the same sixteen lines. The first sixteen places all go to rows that were stored as the server sent them, and the seventeenth is the first one stored at length one. Not one of the normalised copies gets anywhere near the top, including the normalised copy of the line the question is actually about, which scored 270.541254 in its long form and would have to beat 201.902405 in its short one with a score that cannot exceed 1. Every twenty-long row, however irrelevant, beats every one-long row, however exact. The ranking sorted on length first and on meaning only within a group that happened to share one.

Reason back from the symptom and the cause is in the arithmetic, not in the data. The dot product of two rows scales with either of them: double one row and its score doubles, whatever it says. So a score built from it is a mixture of two quantities, how well the directions agree and how big the rows are, and nothing in the number afterwards can separate them again. It looks fine on a table where every row came from one model through one code path on one day, because then the lengths are all similar and the mixture is mostly the part you wanted. It is a ranking waiting for a corpus with any variation in it.

The repair is one line, and here it is doing its work on the same thirty-two rows.

$ go run ./cmd/near -mode ask -q 1 -twin -top 4
near: the chronicle's rows ranked against one question

  the question   Where does a corpse rot?
  the rows       32, of 768 numbers each, from nomic-embed-text:v1.5
  and again      every row a second time at length one, marked with a tick
  scored by      the cosine: the dot product over the two lengths

  rank      cosine   length  entry words  the line
  1       0.602859   1.0000    10'     0  What is finished with goes to the Midden. B...
  2       0.602859  19.9950     10     0  What is finished with goes to the Midden. B...
  3       0.533825  19.5147      3     1  The animals are hobbs. One of them is a hob...
  4       0.533825   1.0000     3'     1  The animals are hobbs. One of them is a hob...

  the line at the top is entry 10
  and it has not one word in common with the question
  0 rows come before the first one that was stored at length one, out of 16
  no row and its twin score more than 1.58e-09 apart, the worst of them entry 5

The list comes out in pairs, each row beside its own twin, and every pair scores the same to six places. No row and its twin are more than 1.58e-09 apart across the whole table, which is float32 rounding in the normalising and nothing else. That is the strongest available statement that the length carried no information: two rows that differ by a factor of twenty are, to this measurement, the same row.

The arithmetic is now load-bearing for anything this world does with a question, so it gets tests, and none of them needs a server. Four of them run over the committed rows, which is the substitution this volume has now made three times: bytes on disk standing in for something expensive, and the code under test unable to tell the difference.

▣ Build · stage 9 — seven things the measurement has to satisfy
$ go test ./internal/lang/ -run 'TestCosineOnThreeNumbersIsWhatThePaperSays|TestARowOfZerosHasNoDirectionToKeep|TestTwoRowsOfDifferentWidthsAreRefused|TestScalingARowChangesNoCosine|TestEveryCommittedRowIsTheModelsOwnWidth|TestTheDotProductAloneRanksByLength|TestTheLineTheAngleFindsSharesNoWordWithTheQuestion' -v
=== RUN   TestCosineOnThreeNumbersIsWhatThePaperSays
    near_test.go:56: five rows against (3,4,0): every length whole, every cosine exact to twelve places
--- PASS: TestCosineOnThreeNumbersIsWhatThePaperSays (0.00s)
=== RUN   TestARowOfZerosHasNoDirectionToKeep
    near_test.go:64: refused: lang: a row of 8 zeros has no direction to keep
--- PASS: TestARowOfZerosHasNoDirectionToKeep (0.00s)
=== RUN   TestTwoRowsOfDifferentWidthsAreRefused
    near_test.go:82: refused, and the message says both widths: lang: a row of 768 numbers against a row of 896
--- PASS: TestTwoRowsOfDifferentWidthsAreRefused (0.00s)
=== RUN   TestScalingARowChangesNoCosine
    near_test.go:117: 16 rows scaled by four factors over a millionfold range, no cosine moved by more than 1.84e-09
--- PASS: TestScalingARowChangesNoCosine (0.00s)
=== RUN   TestEveryCommittedRowIsTheModelsOwnWidth
    near_test.go:137: 16 rows and 4 questions, 768 numbers each, from nomic-embed-text:v1.5
--- PASS: TestEveryCommittedRowIsTheModelsOwnWidth (0.00s)
=== RUN   TestTheDotProductAloneRanksByLength
    near_test.go:176: on the dot product the first 16 places are the 16 rows that were never normalised; on the cosine the top two are entry 10 and its own twin
--- PASS: TestTheDotProductAloneRanksByLength (0.00s)
=== RUN   TestTheLineTheAngleFindsSharesNoWordWithTheQuestion
    near_test.go:207: "Where does a corpse rot?" found entry 10 with no word in common, while the best word match anywhere in the corpus had 1
    near_test.go:207: "How does a newcomer get inside?" found entry 6 with no word in common, while the best word match anywhere in the corpus had 1
--- PASS: TestTheLineTheAngleFindsSharesNoWordWithTheQuestion (0.00s)

The fourth is the property the whole chapter rests on, and it is checked over real rows instead of over an example picked to make it work: every committed row multiplied by 0.001, by 0.5, by 7.5 and by 1000, and no cosine anywhere moved by more than 1.84e-09. A millionfold range of lengths, and the measurement did not notice.

The sixth pins the failure above so that nobody quietly repairs it into a passing test with the division still missing, and the seventh pins the claim this chapter is named for: the line the angle finds for either of the first two questions shares no word with the question, while the best word match anywhere in the corpus is one word. The two refusals are the third and second: a comparison between rows of different widths, which is what two models in one table produces, and a row of nothing but zeros, which points nowhere and would otherwise be scored against everything and score zero against everything, reading as an answer.

One refusal is a deliberate departure from what volume 3's two-number vector does, and the difference says something about both. Vec2.Unit hands a zero step back unchanged, because a thing in the valley that is not moving is an ordinary situation and stopping the tick over it would be absurd. A row of 768 zeros out of a model is not an ordinary situation. It is a bug somewhere upstream, and the earliest possible complaint is the useful one.

✓ Checkpoint — a direction, and the angle to it
  • Work out the length of (12, 9, 20) and its dot product with (3, 4, 0) on paper, and turn the two into the cosine 0.576 without running anything.
  • Say why that row wins a ranking scored on the dot product and comes third on the cosine, in one sentence that mentions no code.
  • Say where the number 768 came from in this chapter, and what would have to happen for it to be a different number.
  • Explain why a committed fixture is what makes a similarity printable in a book, and what specifically could not be printed without one.
  • Given a table holding some rows at length one and some at length twenty, predict the first twenty places of a ranking scored without normalising, before looking.
  • Name two things the cosine over these rows found that a word-matching search could not, and one thing it did not find that a person asking would have wanted.
⚡ Exercises — try first, then reveal
Exercise 1 — find where the right answer went. Run the twin experiment scored on the dot product alone and print all thirty-two places. Say what rank the normalised copy of the line about the Midden lands on, what its score is against the row directly above it, and what would have to be different about a table for a ranking like this one to put a wrong line first.

-mode ask -q 1 -twin -raw -top 32. The normalised copy of entry 10 is seventeenth, scoring 13.530451, and the row above it is the founding line about grain scoring 157.300168, which is about trade and has nothing to do with the question. Nearly twelve times the score, for a line that is wrong. Every one of the sixteen rows ahead of the right answer is ahead of it on length.

For a wrong line to take first place, the table only has to hold the right row in its short form and any wrong row in its long one, which is precisely what a table filled by two clients that disagreed about normalising contains. A backfill run before and after a client change does it. A migration that copied rows in from somewhere else does it. Two services with their own copies of the same helper do it. The table has no column that would show any of that, and the ranking is the first symptom.

Exercise 2 — find the corpus's own limits. Ask the fixture the four questions it carries and then work out, from the rankings alone, which parts of this world's history the sixteen rows cannot answer anything about. Predict what a question about grain would return before you look.

Eleven of the sixteen rows are the founding entries and five are yearly counts written to one pattern, so anything the chronicle records in words is answerable and anything it records only as a number is not. How many creatures survived? returns the line about the animals first and three counts behind it, in no order of year, because nothing in a row of numbers says which count is which.

A question about trade returns entry 8, the founding line that names grain, and it will return it clearly, because that line is the only one in the fixture about exchange at all. Which is also the whole limitation: a question about how the price of anything moved would return the same line with the same confidence, and the chronicle does not hold a single price. A ranking always returns its best candidates, and it has no way at all to say that its best is not an answer.

Exercise 3 — normalise once instead of twice a comparison. Every comparison in this chapter computes two square roots it could have computed earlier. Work out how many square roots a search over n rows costs as written, how many it would cost with the rows stored at length one, and what you would have to give up to store them that way.

As written, one query over n rows costs n dot products and 2n square roots, and n of those recompute the same row lengths every single query. Normalising on the way in makes it n dot products and no square roots at all, because both lengths are one and the division is by one. Over a chronicle of any size that is the difference between a search that scales and one that does arithmetic nobody needed.

What you give up is the ability to tell later what the model actually returned. The original length is gone, so a row stored that way cannot be checked against a fresh answer number for number, and a table mixing normalised and unnormalised rows has nothing in it that says which is which. The middle course is what this chapter's code does: keep what arrived, and cache each row's length once beside it, which is one float per row and takes the square roots back out of the query.