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

Asking the Chronicle a Question

Ninety-one chronicle lines

The database holds ninety-one chronicle lines: eleven founding rows and eighty yearly rows from the daemon. The last page turned sixteen of them into rows of 768 numbers and ranked them against an English question, but it did that from a file. This world's history lives in a table.

The vectors go in their own table, keyed by the chronicle line and by the digest of the model that produced them. A late-filled v vector(768) column on chronicle would update the one history table this book has promised not to rewrite, ninety-one times on the first afternoon and once a year after. Rows from two models are not comparable either, so a vector without the model identity cannot be safely filled again.

The chronicle is appended to and never rewritten because its order is history. Its entry column is GENERATED ALWAYS AS IDENTITY, so an insert that tries to choose its own number is refused and no row can be pushed into the middle. A written row is finished.

A second rule falls out of the first and settles how much of the rest of this world has to change. The eighth table is derived: every row in it can be worked out again from a line of the chronicle and a model, so losing all of them costs the running of a job and nothing more. So it does not belong in a snapshot, and the snapshot does not change. The eight parts a save takes are the same eight parts, and this page adds none and removes none. There is a precedent for that on this world's own disk: the ground is not in a snapshot either, because a valley is made again out of three numbers and a generator, and a thing you can make again is a thing you do not have to keep.

Four things will exist by the end of this page. A seventh migration, which installs an extension and creates one table. A job that reads the lines no model has seen yet, asks for a row apiece, and writes them back, holding nothing while it is not running. The same similarity the last chapter built in Go, this time running inside the server, agreeing with the Go to seven decimal places on the same committed rows. And a count of what one question costs, taken twice at two table sizes, so that how the cost grows is measured instead of asserted.

One boundary, drawn again because it holds for the whole of this volume. Nothing here hands a result to a model. A question comes in, rows come back, the rows get printed. No answer is composed out of what a search returned, nothing is weighted for recency or importance, and nobody in The Hollow says a word on this page. What gets built is a table you can put an English question to, and that is a whole thing on its own.

The seventh migration

The schema of this world is a directory of numbered files, applied in the order of their numbers and never edited once they have run anywhere. It has been six files and seven tables since the afternoon it was written. This is the seventh file, and the first one in the set that installs something as well as making something.

Before any of it goes near a server, the build can say what it holds. The files are compiled into the binary, so a program can print the whole schema with no database anywhere near it, and a build whose migrations are numbered wrong fails before it dials anything.

▣ Build · stage 1 — seven files, and what the seventh adds
$ go run ./cmd/beside -mode plan
beside: the schema this build holds, and not one statement of it run

  n    file                       sha256, first 16     bytes  stmts  what it makes
  0001 0001_world.sql             55b759d43aa929d4       641      1  world
  0002 0002_chronicle.sql         58878101d9384245       735      2  chronicle
  0003 0003_creature.sql          5f95f200d36d9d2e      1740      5  creature
  0004 0004_death.sql             43845ea86c947233       696      2  death
  0005 0005_species.sql           54029ed637c5b65d       858      1  species
  0006 0006_snapshot.sql          e80d9a5289d0f66c      1183      2  snapshot, snapshot_part
  0007 0007_chronicle_vector.sql  b3d9bebcf37a8003      2313      2  the vector extension, chronicle_vector

  7 files, 8166 bytes, 8 tables, applied in the order of their numbers and no other
  the seventh is the only one that installs anything, and the only one whose
  table can be dropped without this world losing a fact it does not still hold

  the eighth table, and where its width is written down
    declared in     0007_chronicle_vector.sql
    held in Go as   store.Dim
    both of them    768

The first six checksums are the six this schema has always had, unchanged, because a migration that has been applied anywhere is finished and a change goes in a new file. The seventh is two statements and a little over two kilobytes, most of which is the argument for four columns. What it makes is on the right of its row: an extension, and one table.

The last block is the one to watch. A vector column is declared at a width and the program filling it has to know the same width, so one fact now lives in two places. There is a test further down this page whose only job is to hold the two against each other.

Here is the file. The first statement is short and it is the one that needs arguing, because it carries a keyword this book spent a whole worked failure warning about.

▣ Build · stage 2 — the seventh migration, whole
-- The extension has to exist before the type below does. It is the
-- only statement in this schema that installs anything, and it is in
-- the same file as the one table that needs it, because "this server
-- can hold a vector" and "this world holds these vectors" are one
-- change and a migration is one change.
CREATE EXTENSION IF NOT EXISTS vector;

IF NOT EXISTS, in a book whose migration chapter spent a worked failure on the difference between a statement that is harmless to repeat and a system that is idempotent. It is the right tool here for the reason it was the right tool on the ledger table, and the reason needs stating precisely. The ledger records what this world's migrations did to this database. An extension belongs to the database and not to a migration: an operator may have installed it before this world ever ran, and the ledger has no row that could say so. Every other statement in this schema is protected by the ledger. This one has nothing to look itself up in, so it protects itself.

-- What a model turned one line of the chronicle into: one row a line,
-- a row a model.
--
-- It is a table beside the chronicle and not a column on it. The
-- chronicle is appended to and never rewritten, and a column filled
-- in afterwards would be an UPDATE of the one table this world has
-- promised never to take one. So the numbers go in a row of their
-- own, keyed by the entry they are about.
--
-- digest is the point of the table. A row of numbers is welded to the
-- weights that made it: two models' rows are not comparable in any
-- way at all, and a table that does not say which model filled it is
-- a table nobody can safely fill again. So it is not a note beside
-- the vector, it is half the key. model is the tag a person reads and
-- digest is what actually decided the numbers, because a tag can be
-- re-pointed at other weights and a digest cannot.
--
-- There is no column saying how wide a row is: the type says so, and
-- one fact in two places is two places that can disagree. There is no
-- wall clock either. Everything here can be worked out again from the
-- chronicle and the model, so when it was worked out answers nothing.
CREATE TABLE chronicle_vector (
    entry  bigint      NOT NULL,
    digest text        NOT NULL,
    model  text        NOT NULL,
    v      vector(768) NOT NULL,

    CONSTRAINT chronicle_vector_key PRIMARY KEY (entry, digest),

    -- Which of the two tables is the derived one, written down where
    -- the server keeps it: there is no vector for a line that was
    -- never written, and taking a line away takes its vectors.
    CONSTRAINT chronicle_vector_of FOREIGN KEY (entry)
        REFERENCES chronicle (entry) ON DELETE CASCADE,

    -- A digest is a sha256 written out in lower case, and a pin that
    -- is not one pins nothing.
    CONSTRAINT chronicle_vector_is_pinned CHECK (digest ~ '^[0-9a-f]{64}$'),
    CONSTRAINT chronicle_vector_names_its_model CHECK (model <> '')
);

Four columns, and the argument for each is the same argument the creature table's seven got: a column exists because something filters, joins, groups or climbs on it. entry is what this row is about and the only way back to the words. v is the thing being stored. The middle two are the chapter.

digest is half the primary key, and putting it there is the difference between a table that can be filled again and one that cannot. Suppose it were a note beside the vector, with entry alone as the key. The day somebody changes the embedding model, every row is a row of numbers about other weights, and the job asks “which lines have no vector yet” and is told: none of them. With the digest in the key the question becomes “which lines have no vector from these weights”, the answer is all of them, and old rows and new can sit in the table together while the change is going on. Both runs are further down this page.

model is the tag a person reads, and it is a separate column because the two answer different questions. A tag is a name somebody chose and can re-point at other weights in an afternoon; a digest is what actually decided the numbers. Storing only the tag puts this table back where it started; storing only the digest leaves sixty-four characters of hex where a person needs a name. Both are kept, and the key is built on the one that cannot lie.

Two columns are deliberately absent. Nothing says how wide a row is, because the type says so. And there is no timestamp: the snapshot table carries the only wall clock in this schema, and here everything can be worked out again from the chronicle and the model, so when it was worked out answers nothing anybody will ask.

The foreign key with ON DELETE CASCADE has nothing to do with deleting chronicle lines, since nothing deletes one. It writes down, where the server will enforce it, which of the two tables is the derived one: no vector exists for a line that was never written. The two checks cost one evaluation an insert and never again. digest ~ '^[0-9a-f]{64}' is a pattern match, because a pin that is not a sha256 in lower case pins nothing and the earliest complaint about that is the useful one.

And there is no second index. The backfill climbs one column, and that column is already the leading half of the primary key, so an ordered structure for it exists and came free. A spare index on rows three kilobytes wide would be cheap, and cheap is not a reason: nothing in this world asks a question a second index would answer.

Now run it, twice, in one go, so there is no question of the second block coming from a different afternoon.

▣ Build · stage 3 — applied, and then not applied
$ podman kill world-db && podman start world-db
world-db
world-db
$ podman exec -w /bench world-go go run ./cmd/beside -mode up -twice
beside: bringing a database up to the schema this build holds

  the first run
    0001 0001_world.sql             applied
    0002 0002_chronicle.sql         applied
    0003 0003_creature.sql          applied
    0004 0004_death.sql             applied
    0005 0005_species.sql           applied
    0006 0006_snapshot.sql          applied
    0007 0007_chronicle_vector.sql  applied
    7 applied, 0 already there, and this run changed the database

  the second run
    0001 0001_world.sql             already applied, and its bytes still agree
    0002 0002_chronicle.sql         already applied, and its bytes still agree
    0003 0003_creature.sql          already applied, and its bytes still agree
    0004 0004_death.sql             already applied, and its bytes still agree
    0005 0005_species.sql           already applied, and its bytes still agree
    0006 0006_snapshot.sql          already applied, and its bytes still agree
    0007 0007_chronicle_vector.sql  already applied, and its bytes still agree
    0 applied, 7 already there, and this run changed nothing at all

  the vector extension, which 0007 asked for   0.8.6
  tables in this database, the ledger aside    8

The database is killed and started first so that it is properly empty: its data directory is a tmpfs, so a restart is a fresh server with nothing in it. Seven applied; then seven skipped, none applied, and the run says the bytes still agree, which is the checksum being checked and not merely stored. The second run sent no statements at all. It read seven rows and decided.

The extension's version on the last-but-one line is compared for the reason the server's own version string is: the image it arrives in is pinned by digest, so a version that moved means the pin moved, and finding that out is the point of pinning.

⌥ Tool — psql, and where the only copy of it lives

psql is Postgres's own terminal client, and the only copy of it on this bridge is inside the database container, so every line of it below starts with podman exec world-db. -c runs one statement and exits. The backslash commands are psql's own and not SQL: \dx lists the extensions a database has installed, \d with a table name prints its columns, indexes and constraints, and \? lists the rest. Read a schema back through psql instead of through your own program, because your own program will tell you what it meant to make.

$ podman exec world-db psql -U world -d world -c 'SELECT version();'
                                                           version                                                            
------------------------------------------------------------------------------------------------------------------------------
 PostgreSQL 16.15 (Debian 16.15-1.pgdg12+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14+deb12u1) 12.2.0, 64-bit
(1 row)
$ podman exec world-db psql -U world -d world -c '\dx'
                             List of installed extensions
  Name   | Version |   Schema   |                     Description                      
---------+---------+------------+------------------------------------------------------
 plpgsql | 1.0     | pg_catalog | PL/pgSQL procedural language
 vector  | 0.8.6   | public     | vector data type and ivfflat and hnsw access methods
(2 rows)

Two things in those blocks. The server is not the server the last volume ran on: same Postgres 16, different build of it, because the image had to change. The plain one does not carry this extension, building it inside the container would need a network in the build, and the answer was to pin a different image by digest and take the consequences, of which this line is one. Any number the last volume measured against its own server is a number about that server. The second thing is the description the extension gives of itself, which names two access methods this page will not use and which comes back at the end of it.

That query carries no ORDER BY and it is the one kind that does not need one: a call with no table under it returns exactly one row whatever the database holds, so there is no order to fix. Every other query on this page has one.

$ podman exec world-db psql -U world -d world -c '\d chronicle_vector'
            Table "public.chronicle_vector"
 Column |    Type     | Collation | Nullable | Default 
--------+-------------+-----------+----------+---------
 entry  | bigint      |           | not null | 
 digest | text        |           | not null | 
 model  | text        |           | not null | 
 v      | vector(768) |           | not null | 
Indexes:
    "chronicle_vector_key" PRIMARY KEY, btree (entry, digest)
Check constraints:
    "chronicle_vector_is_pinned" CHECK (digest ~ '^[0-9a-f]{64}$'::text)
    "chronicle_vector_names_its_model" CHECK (model <> ''::text)
Foreign-key constraints:
    "chronicle_vector_of" FOREIGN KEY (entry) REFERENCES chronicle(entry) ON DELETE CASCADE

That block is this section's argument printed by the server instead of claimed by the author, and reading it is how you find out whether a constraint you wrote in a file is a constraint the database is actually keeping. Four columns, one primary key on two of them, two checks, one foreign key, and no other index anywhere.

▣ Build · stage 4 — eight tables, and nothing in any of them
$ podman exec -w /bench world-go go run ./cmd/beside -mode show
beside: the world, as the server describes it back

  table              column       type                        null  indexes
  chronicle          entry        bigint                        no        2
                     tick         bigint                        no        1
                     kind         text                          no        0
                     text         text                          no        0
  chronicle_vector   entry        bigint                        no        1
                     digest       text                          no        1
                     model        text                          no        0
                     v            vector(768)                   no        0
  creature           id           bigint                        no        1
                     row          integer                       no        0
                     species      integer                       no        1
                     tick         bigint                        no        1
                     parent       bigint                       yes        1
                     mate         bigint                       yes        1
                     generation   integer                       no        0
                     genome       jsonb                         no        0
  death              id           bigint                        no        1
                     tick         bigint                        no        1
                     cause        text                          no        0
  snapshot           world        text                          no        1
                     tick         bigint                        no        1
                     taken_at     timestamp with time zone      no        0
                     parts        integer                       no        0
  snapshot_part      world        text                          no        1
                     tick         bigint                        no        1
                     part         text                          no        1
                     bytes        bytea                         no        0
  species            number       integer                       no        1
                     opened       bigint                        no        0
                     closed       bigint                       yes        0
                     holder       bigint                       yes        0
  world              world        text                          no        1
                     seed         bigint                        no        0
                     opened       bigint                        no        0
                     genesis      jsonb                         no        0
                     one          boolean                       no        1

  8 tables, 36 columns, and one of the columns is 768 numbers

  rows in each of them
    chronicle                 0
    chronicle_vector          0
    creature                  0
    death                     0
    snapshot                  0
    snapshot_part             0
    species                   0
    world                     0

The same schema read out of the server's catalogue by the program that made it, with the ledger left out and a column added for how many indexes name each column. The new table's entry and digest are named by one index between them, and model and v by none. Then the counts, all eight of them nought: a schema is made before there is anything to put in it.

The eighth table beside the chronicle, and what a snapshot still takes Two boxes side by side. The left is the chronicle, with the columns entry, tick, kind and text, labelled appended and never rewritten. The right is chronicle_vector, with the columns entry, digest, model and a vector of 768 numbers, labelled one row a line and a row a model. An arrow runs from the left box to the right, labelled entry. Below them a row of eight small boxes standing for the eight parts a snapshot takes, unchanged by this page. Below that, two boxes for the two things a snapshot does not take: the ground, which is made again from three numbers, and the vectors, which are made again from the lines and the model. THE CHRONICLE, AND THE TABLE BESIDE IT chronicle entry bigint tick bigint kind text text text appended, never rewritten entry chronicle_vector entry bigint digest text model text v vector(768) one row a line, a row a model WHAT A SNAPSHOT TAKES, UNCHANGED BY THIS PAGE valley bed stands bank air pool roster luck AND WHAT IT DOES NOT, FOR ONE REASON the ground made again from three numbers the vectors made again from the lines and the model drop either one and nothing is lost that this world does not still hold
Figure 88.1 — the arrow runs one way and the right-hand box is the one that can be thrown away. Everything in it comes from a line on the left and a set of weights named in its own digest column, which is why the eight parts below it are the same eight parts they have always been.

Filling the vector table

Two different things put rows in this table, and the order they do it in on this page is not an accident. One is a copy of the committed fixture: the rows this book asked a model for once and wrote down, which every score printed on this page is computed from. The other is the job, which asks a live model for numbers that depend on the machine, on the build of the server and on the arithmetic its backend chose, and which therefore go into the table and never onto a page. The fixture goes in first, the scoring happens while the table holds nothing but those rows, and the job runs after. Both need a chronicle to be about, and the table holding one is empty.

▣ Build · stage 5 — ninety-one lines, checked against the dump they came from
$ podman exec -w /bench world-go go run ./cmd/beside -mode found
beside: the committed chronicle, put back into a database that has none

  the dump           chronicle.jsonl, 91 rows
  the document       11 keys, written at tick 0
  founded            11 lines at tick 0, in one transaction
  appended           80 lines, one a year, in the order they were written

  the table against the dump, row for row, ORDER BY entry
    rows in the dump                 91
    rows in the chronicle            91
    identical, entry tick kind text  91

  entry     tick   kind      text
  1         0      genesis   This is The World. I built it and I run it, a...
  11        0      genesis   The thing worth going after is the Wellspring...
  12        3150   count     year 1: 11 hobbs standing in The Hollow, 45 b...
  91        287550 count     year 80: 238 hobbs standing in The Hollow, 19...

  91 rows, 7032 bytes of text between them, and not one of them will ever
  be rewritten: that is the law the next table on this page is built around

The founding document goes in as eleven rows in one transaction, the way it always has, and the eighty yearly counts are appended one at a time in the order they were written. Then the table is read back and held against the committed dump, row for row, on all four columns. Ninety-one identical, which is what lets everything below be a number instead of a claim.

Seven thousand and thirty-two bytes of text. Hold on to that; the vectors about to be written for the same lines are considerably larger than the lines.

A vector has to cross a socket to get into a column, and this build has no idea how to send one. The driver it vendors was written before this extension existed and has never heard of the type, and this volume adds no dependency at all. So the numbers go the way the server has always accepted numbers: written out as text, with the type named on arrival.

▣ Build · stage 6 — a vector, written out
// internal/store/vector.go
// Text is how a vector is written down for the server: the numbers in
// square brackets, separated by commas, which is the literal the type
// parses.
//
// Each number is written in the shortest form that reads back as the
// same float32, so nothing is lost between the row a model produced
// and the row the table holds. The alternative is a fixed number of
// decimal places, which is either longer than it needs to be or
// quietly rounds, and there is no way to tell which by looking.
func Text(v []float32) string {
	var b strings.Builder
	b.WriteByte('[')
	for i, x := range v {
		if i > 0 {
			b.WriteByte(',')
		}
		b.Write(strconv.AppendFloat(nil, float64(x), 'g', -1, 32))
	}
	b.WriteByte(']')
	return b.String()
}

'g' with a precision of −1 asks for the shortest string that reads back as the same float32, and that is the part that matters. A fixed number of decimal places is either longer than it needs to be or quietly rounds, with no way to tell which by looking. This way the row the server holds is the row the model produced, bit for bit, and a test on this page proves it over numbers chosen to be awkward. The cost is that a vector on the wire is much larger than a vector in a column, and the next run says by how much.

// internal/store/vector.go
// Meant writes rows into the eighth table, all of them in one COPY.
//
// The creature table was loaded with the driver's own bulk copy,
// which builds the server's binary format. That road is shut here:
// the binary format needs the driver to know the type, and this one
// does not. So the copy is written out in the text format the server
// has always accepted, one row a line, columns separated by tabs, and
// the vector arrives as the literal Text produced.
//
// It is still one statement and one pass over the socket, which is
// the whole of what bulk loading buys.
func (d *DB) Meant(ctx context.Context, rows []Vector) (int64, error) {
	if len(rows) == 0 {
		return 0, nil
	}
	var b strings.Builder
	for _, r := range rows {
		if err := check(r); err != nil {
			return 0, err
		}
		fmt.Fprintf(&b, "%d\t%s\t%s\t%s\n", r.Entry, r.Digest, r.Model, Text(r.V))
	}
	conn, err := d.pool.Acquire(ctx)
	if err != nil {
		return 0, fmt.Errorf("store: filling the eighth table: %w", err)
	}
	defer conn.Release()
	const q = `COPY chronicle_vector (entry, digest, model, v) FROM STDIN`
	tag, err := conn.Conn().PgConn().CopyFrom(ctx, strings.NewReader(b.String()), q)
	if err != nil {
		return 0, fmt.Errorf("store: filling the eighth table: %w", err)
	}
	return tag.RowsAffected(), nil
}

The creature table was loaded with the driver's own bulk copy, which builds the binary format the server speaks natively. That road is shut here, because the binary format needs the driver to know the type. What is left is the text format: one row a line, tab-separated columns, still one statement and one pass over the socket, which is what a bulk load buys over a thousand inserts.

$ podman exec -w /bench world-go go run ./cmd/beside -mode load
beside: the committed rows, copied into the table beside the chronicle

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

  one COPY, in the text format the server has always taken
    rows before                           0
    rows the copy reported               16
    rows after                           16
    the literals it sent             136230 bytes
    one row, as the server holds it     3076 bytes
    all of them                       49216 bytes

  what the table holds, GROUP BY model, ORDER BY digest
    model                        pinned at            rows
    nomic-embed-text:v1.5        970aa74c0a90ef74       16

  16 of the chronicle's 91 lines now have a row from this model, and 75 do not

Sixteen rows in one statement. The two byte counts are the pair to look at: 136,230 bytes of literals went over the socket and 49,216 bytes came to rest, so writing the numbers out as text costs a factor of nearly three on the wire and nothing at all in the table. One stored row is 3,076 bytes, which is 768 four-byte numbers and four of the server's own bookkeeping, read back off the server with pg_column_size.

The last two blocks are the questions this table exists to answer: what is in it, grouped by the model that filled it, and how many lines that model has not seen. Seventy-five, because the fixture covers sixteen. Those seventy-five are the job's work and it does them two sections from here; the sixteen are what the next section puts questions to.

Cosine inside Postgres

The measurement is settled: the cosine of the angle between two rows, the dot product over the product of the two lengths, built in Go on the last page over vectors of three numbers anybody can check with a pencil. The extension brings its own copy, written in C and running where the rows already are, and the first question here is whether the two agree. The statement is nine lines of ordinary SQL, which is most of the point.

▣ Build · stage 7 — a search, in nine lines of SQL
// internal/store/vector.go
// search is the statement itself, kept where a plan can be asked for
// it as well as an answer.
const search = `
SELECT v.entry, c.text, 1 - (v.v <=> $1::vector) AS cos
  FROM chronicle_vector v
  JOIN chronicle c ON c.entry = v.entry
 WHERE v.digest = $2
 ORDER BY v.v <=> $1::vector, v.entry
 LIMIT $3`

<=> is the extension's cosine distance: nought for two rows pointing the same way, one for square on, two for opposite. Subtracting it from one gives the cosine, which is what the rest of this book calls the measurement, so the two halves of this chapter can be printed in the same column and compared digit for digit.

The join is the reason any of this is in a database. The eighth table holds an entry and some numbers; the words are one column away, in the table the entry points at, so a search that returns lines of history instead of row identifiers costs one join clause and no memory at all.

WHERE v.digest = $2 does more than keep unwanted rows out: it is what makes the answer mean anything. In a table designed to hold two models' rows during a change, a search that did not name a model would be sorting numbers with no relation to each other, and would return a ranking, and would look fine.

The ORDER BY is on the distance ascending, which is the form the extension's own index types can use, with the entry on the end so that two rows scoring alike come back the same way round on every machine. Every query in this book carries one.

$ podman exec -w /bench world-go go run ./cmd/beside -mode ask -q 1
beside: one question, asked of the table and asked of the same rows in Go

  the question       Where does a corpse rot?
  the model          nomic-embed-text:v1.5
  pinned at          970aa74c0a90ef74
  scored by          1 - (v <=> the question), and by lang.Cos

  rank  entry  the server    lang.Cos         apart  the line
  1        10    0.602859    0.602859      6.32e-08  What is finished with goes to the Mid...
  2         3    0.533825    0.533825      2.15e-08  The animals are hobbs. One of them is...
  3         2    0.516247    0.516247      5.18e-08  The land is The Hollow. It is one val...
  4         7    0.474781    0.474781      5.49e-09  The doors between one part of the gro...
  5         1    0.451704    0.451704      4.92e-09  This is The World. I built it and I r...
  6        91    0.449923    0.449923      8.44e-10  year 80: 238 hobbs standing in The Ho...

  every one of the 16 rows scored both ways, not just the 6 printed
  the widest gap between the two is 6.32e-08, at entry 10
  which is agreement to 7 decimal places

Two columns of the same six numbers. The server computed the left one from the rows in the table, the Go computed the right one from the same rows read out of the committed fixture, and the third column is the distance between them. The widest gap anywhere in the sixteen is 6.32 in the eighth decimal place, so the two agree to seven, and the run says so instead of leaving it to be counted off the page.

Seven places is neither a disappointment nor luck. Both sides add up 768 products of numbers stored as float32, and they do not add them in the same order: one is a Go loop running from the first number to the last, the other is C the compiler is free to turn into several partial sums added at the end. Floating-point addition is not associative, so two correct implementations of one sum disagree in the last bits, and the third column is the size of that disagreement. A gap in the third decimal place would be the alarming case, because it would mean the two were computing different things.

The ranking itself is the one the last chapter got, on the same rows, out of a different program: the line about the Midden first, with no word in common with the question. Nothing about the search changed by moving into the database. What changed is that the rows no longer have to be anywhere in particular for it to run.

$ podman exec -w /bench world-go go run ./cmd/beside -mode ask -q 2 -top 4
beside: one question, asked of the table and asked of the same rows in Go

  the question       How does a newcomer get inside?
  the model          nomic-embed-text:v1.5
  pinned at          970aa74c0a90ef74
  scored by          1 - (v <=> the question), and by lang.Cos

  rank  entry  the server    lang.Cos         apart  the line
  1         6    0.636975    0.636975      4.55e-09  The way in is The Commons. Anybody wh...
  2         7    0.531150    0.531150       6.4e-09  The doors between one part of the gro...
  3         5    0.467180    0.467180      5.75e-08  People gather at Firstlight. Nobody i...
  4         9    0.447553    0.447553      3.58e-08  Past the mapped ground are the Unwrit...

  every one of the 16 rows scored both ways, not just the 4 printed
  the widest gap between the two is 5.78e-08, at entry 1
  which is agreement to 7 decimal places

The second question is the same result from a different direction, and its widest gap is 5.78 in the eighth place, at a row the printed four do not include. The run is being careful on purpose: it scores all sixteen both ways and reports the worst disagreement anywhere, not the worst among the rows that happened to be shown.

Now the question this section was really for. The chronicle grows by one line a year for as long as the world runs, and every line will want a row of 768 numbers. What does asking a question cost as that goes on?

Measuring that is awkward for the two reasons it was awkward the last time this book measured a query. A timing is this machine on this afternoon; a planner's cost estimate is a guess the server made in units of its own before it ran anything. Neither reproduces and neither is printed in this book. What does reproduce is a count of work: ask for the plan with ANALYZE and with the costs, the timings and the summary switched off, and what comes back is how many rows each node handed up and how many times it ran. Multiply those together and the answer is the same on any machine holding the same rows.

▣ Build · stage 8 — what one question read, counted
$ podman exec -w /bench world-go go run ./cmd/beside -mode cost -q 1
beside: what one question costs, counted in rows the server read

  the question       Where does a corpse rot?
  the table          16 rows, 768 numbers each
  asked for          EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)

  the plan the server made, with the costs switched off
  (two constants are cut here: the question's numbers, and the digest)
    Limit
      ->  Sort
            Sort Key: ((v.v <=> '[768 numbers]'::vector)), v.entry
            ->  Hash Join
                  Hash Cond: (c.entry = v.entry)
                  ->  Seq Scan on chronicle c
                  ->  Hash
                        ->  Seq Scan on chronicle_vector v
                              Filter: (digest = '970aa74c0a90ef74...'::text)

  LIMIT          returned    vector rows chronicle rows
  1                     1             16             91
  4                     4             16             91
  16                   16             16             91

  the LIMIT changes what comes back and not what was read: every row this
  model filled has to be scored before the closest one is known

  one question, in multiplications
    rows scored                    16
    numbers in a row               768
    multiply-adds                  12288

The plan carries two constants this book does not print: the question, which is 768 numbers a live model produced, and a sha256. Both are cut and the run says they were, which is the only honest way to publish a listing you have edited.

Read it from the bottom. The server reads the eighth table straight through, keeping the rows whose digest matches; builds a hash of them; joins the chronicle for the words; sorts by distance; takes the first few. There is not a number in any of those lines, so there is nothing in them to drift.

Then the counts, and the column that matters is the last but one. Asking for one row, four rows or all sixteen reads sixteen rows every time. The LIMIT decides what comes back and nothing about what was done, because a sort cannot know which row is closest until it has scored every row there is. That one fact decides how this table behaves as it grows, and seeing it measured before doing any arithmetic with it is what keeps the arithmetic honest.

The backfill job count

Sixteen lines of ninety-one have rows in the eighth table, and the seventy-five that do not are the job's whole reason for existing. It is the only thing in this world that speaks to a database and to a model server in the same function, and it is under thirty lines with the error handling in it. The question it asks first is the one the last two columns of the schema were added for.

▣ Build · stage 9 — the job, and the question it asks first
// internal/store/vector.go
// Waiting is the backfill's whole question: which lines of the
// chronicle have no row from this model yet, oldest first.
//
// The model is named by its digest and not by its tag, because the
// question is about the weights. A world whose tag was re-pointed at
// other weights overnight asks this and is told, correctly, that
// every line is waiting.
func (d *DB) Waiting(ctx context.Context, digest string, limit int) ([]Line, error) {
	if limit < 1 {
		return nil, fmt.Errorf("store: a backfill of %d rows at a time is not a backfill", limit)
	}
	const q = `
SELECT c.entry, c.tick, c.kind, c.text
  FROM chronicle c
 WHERE NOT EXISTS (SELECT 1 FROM chronicle_vector v
                    WHERE v.entry = c.entry AND v.digest = $1)
 ORDER BY c.entry
 LIMIT $2`
	rows, err := d.pool.Query(ctx, q, digest, limit)
	if err != nil {
		return nil, fmt.Errorf("store: reading what is waiting to be embedded: %w", err)
	}
	defer rows.Close()
	var out []Line
	for rows.Next() {
		var l Line
		if err := rows.Scan(&l.Entry, &l.Tick, &l.Kind, &l.Text); err != nil {
			return nil, fmt.Errorf("store: reading what is waiting to be embedded: %w", err)
		}
		out = append(out, l)
	}
	return out, rows.Err()
}

NOT EXISTS against the eighth table, matching on the entry and the digest. That second condition is the reason the digest is in the key: without it the query cannot be written, because there is nothing to match on. ORDER BY on the entry does the oldest waiting line first, which matters not at all for correctness and quite a lot for a job that gets killed halfway through: what it finished is the beginning of the history, and what it starts with next time is where it stopped.

LIMIT is there for a second reason, and it is the difference between a job whose memory is a fixed batch and one whose memory grows with a history that only grows. It costs an extra round trip a batch to have.

// cmd/beside/fill.go
	cl := lang.Dial(srv)
	passes, asked, written := 0, 0, int64(0)
	for {
		batch, err := db.Waiting(ctx, m.Digest, c.batch)
		if err != nil {
			return err
		}
		if len(batch) == 0 {
			break
		}
		passes++
		var rows []store.Vector
		for _, l := range batch {
			v, err := cl.Embed(ctx, m.Name, l.Text)
			if err != nil {
				return err
			}
			asked++
			rows = append(rows, store.Vector{Entry: l.Entry, Digest: m.Digest, Model: m.Name, V: v})
		}
		n, err := db.Meant(ctx, rows)
		if err != nil {
			return err
		}
		written += n
	}

Read a batch, ask for a row apiece, write them back, go round again until nothing is waiting. There is no cleverness in it, and the absence is a design choice. What it leaves out matters more, because every one of these is something a job like this grows: it holds no transaction open across a model call, keeps nothing in memory between passes, has no notion of priority or importance, and decides nothing about which lines deserve to be embedded. Every line gets one row, oldest first.

It also does not run inside a tick. The pool it works through is the one this world has always used, four connections at most and a lifetime measured in minutes, sized that way precisely because the tick loop is not a caller. A tick is a hundred milliseconds. A round trip to a model server is measured in seconds: a tick that waited on one would not be late, it would have stopped being a tick.

$ podman exec -w /bench world-go go run ./cmd/beside -mode fill -batch 32
beside: the job that fills the eighth table, run once by hand

  the server         world-lm:11434, waiting up to 10m0s for an answer
  the model          nomic-embed-text:v1.5
  pinned at          970aa74c0a90ef74
  a pass takes       32 waiting lines at a time

  before
    lines in the chronicle             91
    rows in the eighth table           16
    lines with no row from this model  75

  after
    passes over what was waiting       3
    lines sent to the model            75
    rows the copies reported           75
    rows in the eighth table           91
    lines with no row from this model  0

  what the table holds, GROUP BY model, ORDER BY digest
    model                        pinned at            rows
    nomic-embed-text:v1.5        970aa74c0a90ef74       91

  not one number this run received is on this page, and yours would differ anyway
$ podman exec -w /bench world-go go run ./cmd/beside -mode fill -batch 32
beside: the job that fills the eighth table, run once by hand

  the server         world-lm:11434, waiting up to 10m0s for an answer
  the model          nomic-embed-text:v1.5
  pinned at          970aa74c0a90ef74
  a pass takes       32 waiting lines at a time

  before
    lines in the chronicle             91
    rows in the eighth table           91
    lines with no row from this model  0

  after
    passes over what was waiting       0
    lines sent to the model            0
    rows the copies reported           0
    rows in the eighth table           91
    lines with no row from this model  0

  what the table holds, GROUP BY model, ORDER BY digest
    model                        pinned at            rows
    nomic-embed-text:v1.5        970aa74c0a90ef74       91

  not one number this run received is on this page, and yours would differ anyway

Seventy-five lines in three passes of thirty-two, and then the same command again, on the same database, doing nothing whatsoever. The job is idempotent for the reason the migration runner is: it consults a record of what has been done before it does anything, and the record is a row in a table and not a flag somebody remembered to set. The table now holds ninety-one rows from one model, and not one of the numbers in it is on this page.

▣ Build · stage 10 — the same question, after the job has run
$ podman exec -w /bench world-go go run ./cmd/beside -mode cost -q 1
beside: what one question costs, counted in rows the server read

  the question       Where does a corpse rot?
  the table          91 rows, 768 numbers each
  asked for          EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)

  the plan the server made, with the costs switched off
  (two constants are cut here: the question's numbers, and the digest)
    Limit
      ->  Sort
            Sort Key: ((v.v <=> '[768 numbers]'::vector)), v.entry
            ->  Hash Join
                  Hash Cond: (c.entry = v.entry)
                  ->  Seq Scan on chronicle c
                  ->  Hash
                        ->  Seq Scan on chronicle_vector v
                              Filter: (digest = '970aa74c0a90ef74...'::text)

  LIMIT          returned    vector rows chronicle rows
  1                     1             91             91
  4                     4             91             91
  91                   91             91             91

  the LIMIT changes what comes back and not what was read: every row this
  model filled has to be scored before the closest one is known

  one question, in multiplications
    rows scored                    91
    numbers in a row               768
    multiply-adds                  69888

The same command against the same database after the job has run. Ninety-one rows now, and the answer to all three questions is ninety-one. The plan is the same plan, the multiply-adds have gone from 12,288 to 69,888, and the rows read are exactly the rows there are, which is the relationship the arithmetic below is built on.

One caution about that block, and it is this volume's standing rule. Seventy-five of those rows were produced on this machine this afternoon by a live model, and no score computed from them appears here. What appears is a count, which is the same count whatever the numbers in them are.

Six things about all of that are checkable without a server anywhere near them, and two of them are the sort of mistake it would be embarrassing to hear about from a database.

▣ Build · stage 11 — six things the schema and the writes have to satisfy
$ go test ./internal/store/ -run 'TestTheMigrationsAreNumberedFromOneWithNoGaps|TestTheSchemaIsTheseTablesAndNoOthers|TestTheSchemaAndThisPackageAgreeOnTheWidth|TestAVectorIsWrittenDownWithoutLosingANumber|TestARowOfTheWrongWidthNeverReachesTheServer|TestAVectorWithNoModelBehindItIsRefused' -v
=== RUN   TestTheMigrationsAreNumberedFromOneWithNoGaps
    migrate_test.go:43: 7 migrations, numbered 1 to 7, every one of them read
--- PASS: TestTheMigrationsAreNumberedFromOneWithNoGaps (0.00s)
=== RUN   TestTheSchemaIsTheseTablesAndNoOthers
    migrate_test.go:79: 8 tables, in 7 files, and not one of them is the ledger
--- PASS: TestTheSchemaIsTheseTablesAndNoOthers (0.00s)
=== RUN   TestTheSchemaAndThisPackageAgreeOnTheWidth
    vector_test.go:37: 1 vector column declared in the schema, 768 wide, and store.Dim is 768
--- PASS: TestTheSchemaAndThisPackageAgreeOnTheWidth (0.00s)
=== RUN   TestAVectorIsWrittenDownWithoutLosingANumber
    vector_test.go:64: 8 numbers written and read back, every one of them the same bits, in 60 characters
--- PASS: TestAVectorIsWrittenDownWithoutLosingANumber (0.00s)
=== RUN   TestARowOfTheWrongWidthNeverReachesTheServer
    vector_test.go:77: refused: store: entry 1: a row of 767 numbers for a column of 768
--- PASS: TestARowOfTheWrongWidthNeverReachesTheServer (0.00s)
=== RUN   TestAVectorWithNoModelBehindItIsRefused
    vector_test.go:90: a row of the right width and no model behind it is still not a row this table takes
--- PASS: TestAVectorWithNoModelBehindItIsRefused (0.00s)
PASS
ok  	theworld/internal/store	0.005s

The second test is the one that moved. It pulls every CREATE TABLE out of the embedded SQL and fails unless the set is exactly the list this world keeps in Go. That list was seven names and is now eight:

// internal/store/migrate_test.go
var world = []string{
	"world", "chronicle", "creature", "death", "species", "snapshot", "snapshot_part",
	"chronicle_vector",
}

A test like that does not exist to stop a schema growing. It exists so that growing one is a decision somebody makes on purpose, in the same change that adds the file, which is what happened here.

The third keeps the width honest: it pulls vector(768) out of the migration with a pattern and holds the number against the constant the Go side uses, so the two places this fact lives cannot drift apart without something going red. The fourth writes eight awkward numbers out and reads them back, including a third, a value too small for any decimal place to reach, and the smallest float32 there is. Every one comes back as the same bits.

The last two are refusals and both happen before a socket is wanted. A row of the wrong width is caught with both widths in the message, which is what makes the error actionable and not merely correct. A row of the right width with no model behind it is refused too: a vector with no digest on it is the row this table exists to make impossible, and letting one in through a helper function would be a strange way to enforce a primary key.

∑ Math Interlude — what a question costs, and what a row costs

Two sums, both of them arithmetic over numbers the runs above printed. Start with the question. Every row of one model's has to be scored, and scoring one row is multiplying 768 pairs of numbers together and adding them up. At ninety-one rows that is 91 × 768 = 69,888 multiplications. The run printed exactly that.

Write the row count N and the width d, and one question costs N × d. The interesting part is what is missing: there is no term for how many rows you asked for, because the sort has to see everything before it can name a first. Asking for fewer answers does not make a question cheaper.

Second sum, the storage. One stored row measured 3,076 bytes, which is 768 × 4 = 3,072 bytes of numbers and 4 bytes of the server's own bookkeeping. Ninety-one of them is 91 × 3,076 = 279,916 bytes. The ninety-one lines those rows are about came to 7,032 bytes of text, so the numbers describing the history are 279,916 ÷ 7,032, which is a little under forty times the size of the history. That is the price of being able to ask a question in English, and it is a real price rather than a rounding error: an average line of this chronicle is 77 characters and its vector is three kilobytes.

Now run both sums forward, because that is the only way to find out when either of them stops being free. At ten thousand lines a question costs 7,680,000 multiplications and the table holds 30,760,000 bytes: nothing, on any machine. At a million lines the question costs 768,000,000 multiplications and the table is 3,076,000,000 bytes, which on the eight-gigabyte machine this world is built for is more than a third of everything there is. Both terms grow with N and neither grows with anything else, so the crossover is arithmetic and not opinion: this chronicle gains one line a year of simulated time, so it reaches ten thousand rows after ten thousand years of it.

Nhow many rows one model has filled: 16 while the table held only the committed ones, 91 after the job
dhow many numbers a row holds: 768, and it is this model's and not a setting
N × dmultiply-adds in one exact search: every row scored, every number in it touched
khow many answers were asked for. It appears in no sum on this page, which is the finding
bbytes one stored row takes: 3,076 here, measured off the server and not derived
N × bwhat the whole table occupies: 279,916 bytes at 91 rows
a ÷ ba divided by b

Where exact search stops

What has been built is an exact search: it scores every candidate and sorts them, so the row it returns is the closest row there is, always. That is a stronger promise than most search machinery makes, and it is available here because ninety-one rows is nothing and reading all of them costs less than deciding not to.

The extension said as much itself, in the listing psql printed earlier: a vector data type, and two access methods. Those exist because N × d eventually stops being free, and what they do about it is give up on exact. An approximate index builds a structure over the rows once, then visits a fraction of them at question time and returns the best it found among those. It is much faster, it is sometimes wrong, and how often it is wrong is a measurement of one index built one way over one set of rows: a hit rate measured on one says nothing about another. Such an index is welded to the model too, being built out of the rows in the column.

So this world ships none, and the decision is arithmetic and not taste. Ninety-one rows is 69,888 multiplications, less work than parsing the query text that asked for them. Ten thousand rows is seven and a half million, still nothing. Somewhere above a million rows a question stops fitting in the time anybody wants to give it, and that is the row count at which exact search stops being enough. This chronicle gains one line a year.

Why derived rows name their source

Everything on this page so far would work exactly as well with two of the eighth table's four columns taken out. The table fills, the search runs, the two arithmetics agree, and a reader could fairly ask what digest and model are buying. They buy one day, and it is a day that arrives: the day somebody changes the embedding model.

Two things go wrong on that day and they are different things. The model may answer at a different width, which the schema has an opinion about; and the rows already in the table become rows about weights nobody is using any more, which the schema had better have an opinion about too. Both of the runs below start from a database with nothing in it, so here is that database.

$ podman kill world-db && podman start world-db
world-db
world-db
$ podman exec -w /bench world-go go run ./cmd/beside -mode up
beside: bringing a database up to the schema this build holds

  the first run
    0001 0001_world.sql             applied
    0002 0002_chronicle.sql         applied
    0003 0003_creature.sql          applied
    0004 0004_death.sql             applied
    0005 0005_species.sql           applied
    0006 0006_snapshot.sql          applied
    0007 0007_chronicle_vector.sql  applied
    7 applied, 0 already there, and this run changed the database

  the vector extension, which 0007 asked for   0.8.6
  tables in this database, the ledger aside    8

Take the width first, because the server does most of the talking.

⚠ Worked failure — a column declared at one width, a model answering at another

vector(768) in the migration was not a choice. It is the width this world's embedding model answers at, read off a live answer on the last page and written into the schema. Somebody copying this design from a different model has a column declared at some other number, and the server's response when the two disagree, because everything a migration on this table will ever have to do is in the three answers below.

The bench takes the width as a flag, so the run says what it declared and cannot claim one thing while doing another. It builds the table itself instead of editing the migration, because a migration that has been applied anywhere is finished.

$ podman exec -w /bench world-go go run ./cmd/beside -mode wide -dim 384
beside: a column one width and a model another

  the column declared at  vector(384)
  the model               nomic-embed-text:v1.5
  answering at            768 numbers

  the table was made without complaint: a width is a promise about rows
  that have not arrived yet

  the first row of the fixture, sent to it
    tried     entry 1, 768 numbers
    SQLSTATE  22000
    said      expected 384 dimensions, not 768

  the same row with its last 384 numbers thrown away, sent to it
    tried     entry 1, 384 numbers
    taken     and the table now holds a direction nothing can be compared with

  the obvious repair, on a table that already has a row in it
    tried     ALTER COLUMN v TYPE vector(768)
    SQLSTATE  22000
    said      expected 768 dimensions, not 384

  narrow_vector holds 1 row: the one that fitted, and it is the wrong row

Three answers, and the middle one is the bad news.

The table is created without a murmur. Of course it is: a declared width is a promise about rows that have not arrived, and nothing has arrived. Whatever is wrong here will be found out by the first insert, which means by a job running unattended and not by the person who wrote the file.

That first insert is refused cleanly, with SQLSTATE 22000 and a message naming both numbers: expected 384 dimensions, not 768. Six words, and everything needed is in them. The column wants 384; the thing that arrived has 768; there is no reading of it that leaves anybody guessing.

Then the middle block, which is the one to sit with. Take the same row, throw away its last 384 numbers, and the server takes it. It has to: 384 numbers is what the column asked for, and nothing about a vector type says which 384. The table now holds a row with the right width, the right entry, the right digest, and a direction the model never produced. Search against it and a ranking comes back, wrong, with nothing saying so. An insert that fails at three in the morning wakes somebody up; an insert that succeeds after somebody quietly made the data fit does not.

The last block is the repair everybody reaches for. ALTER TABLE ... ALTER COLUMN v TYPE vector(768) comes back with expected 768 dimensions, not 384, the same message the other way round. The server is refusing to widen the row already in the column, and not the column: changing a column's type means rewriting every value in it, and there is no rule that turns a 384-number direction into a 768-number one. There could not be one. The missing numbers were never anybody's to invent.

Reason back from that and the lesson generalises to every migration this table will need. A width change is a refill and not an alteration: the rows have to be produced again by whatever model answers at the new width, which is exactly the operation the model column exists to make possible, so the two halves of this chapter turn out to be one thing looked at twice. A migration that widens the column can only be a migration that empties it first, and a table you can empty without losing anything is the only kind where that is an afternoon's work instead of a disaster.

Which leads straight to the other half. If a width change is a refill, then whether it can be done at all is decided by whether the table can say what filled it, and that is a question about the other column.

The day arrives for ordinary reasons. A better small model comes out; the one in use answers at a width that costs more than it returns; somebody re-points a tag at other weights and does not think to mention it. The bench takes a flag that builds the table as it is tempting to build it on the first afternoon: the vector, keyed by the chronicle line, and nothing about where the numbers came from. Everything else about the run is the same. The chronicle goes back into the database first, with the mode from the last section and the same output it printed there.

▣ Build · stage 12 — the same job, asked of two tables
$ podman exec -w /bench world-go go run ./cmd/beside -mode reembed
beside: what a backfill finds left to do after somebody changes the model

  the table          the eighth table, which records which model filled each row
  filled by          nomic-embed-text:v1.5, pinned at 970aa74c0a90ef74
  then configured    qwen2.5:0.5b-instruct-q4_K_M, pinned at c5396e06af294bd1
  which is           another of the three pins this world holds, and is asked nothing

  the chronicle holds 91 lines, and 16 of them were filled

  what the job finds waiting
    under the model that filled the table   75
    under the model now configured          91

  the second number is every line in the chronicle, and that is the right answer:
  a change of weights makes every row in the table a row about other weights.
  Nothing was rewritten for the job to be able to say so, and nothing had to be.
$ podman exec -w /bench world-go go run ./cmd/beside -mode reembed -naive
beside: what a backfill finds left to do after somebody changes the model

  the table          a vector table with no model on it, keyed by the chronicle line alone
  filled by          nomic-embed-text:v1.5, pinned at 970aa74c0a90ef74
  then configured    qwen2.5:0.5b-instruct-q4_K_M, pinned at c5396e06af294bd1
  which is           another of the three pins this world holds, and is asked nothing

  the chronicle holds 91 lines, and 16 of them were filled

  what the job finds waiting
    under the model that filled the table   75
    under the model now configured          75

  the same number twice, because the table cannot be asked the question. A row
  is there or it is not, and nothing in it says whose numbers those are, so the
  16 lines already filled are done for good, by whatever it was that filled them.

Read the middle block of each. The first run says 75 and 91: seventy-five lines still waiting under the model that filled the table, and ninety-one under the model configured now, which is every line there is. That second number is the right answer and getting it rewrote nothing. The sixteen rows already there stay where they are, correct about the weights that made them, while the job works through the whole chronicle again. Halfway through, the table holds both models' rows at once and the search names which set it wants.

The second run says 75 and 75, not because the answer is the same but because the question cannot be asked. The table has no column that could tell one model from another, so NOT EXISTS can only match on the entry, and a row is either there or it is not. The sixteen lines already done are done for good, by whatever it was that did them. Nothing errors, and every search against that table from then on sorts sixteen rows of one arithmetic against seventy-five of another and returns a ranking with no complaint at all.

That is the failure the two columns prevent; notice what kind it is. There is no error message in it anywhere. The naive table is a perfectly good table that answers every query it is given. It has lost the one fact needed to put it right, and it lost that at the moment it was designed.

The second pin in both runs is another of the three this world already holds, chosen for being a different sixty-four characters. Nothing is asked of it: a backfill's question is about a digest and not about what the digest names.

Why this works

One idea holds the whole design up and it travels a long way past vectors and past Postgres. Derived data has to record what it was derived from. Not for documentation, and not so a person browsing the table can satisfy their curiosity: so that the derivation can be done again, and so that the question “is this row still valid” has an answer that is a column comparison instead of an argument.

The same requirement shows up wherever something is computed once and stored. A cached rendering has to say which version of the document it came from; a compiled artefact has to name the compiler; a table of converted prices has to say which rate. In every one the stored value is useless the moment the thing behind it moves and the storage cannot tell that it moved. This world has kept such a column since it learned to migrate: the ledger holds a checksum of every file it applied, because a file name is not evidence about bytes.

The second idea is the one about append-only that this page started from, and it came out better than it went in. A rule saying “never rewrite a row” sounds like it costs flexibility, and here it bought some. Because a vector could not be a column on the chronicle it had to be a table; because it is a table, two models' rows can exist at once; because two models' rows can exist at once, changing model is an ordinary operation and not an outage. Obeying the constraint produced a better design than ignoring it would have, which happens often enough with append-only storage to be something to expect.

The third is the smallest and travels furthest. When two implementations of the same arithmetic sit beside each other, run both and print the difference. It costs a column on a listing, and it buys a number saying how far apart they are, so the day one of them changes the disagreement shows up as an eighth decimal place becoming a third, and not as a ranking somebody finds odd months later.

✓ Checkpoint — a table beside a table that never moves
  • Write down why a vector cannot be a column on chronicle, in terms of the one SQL statement a backfill would have to run, and say which property of that table the statement would break.
  • The eighth table's key is (entry, digest). Say what the backfill's NOT EXISTS clause would have to become if the key were entry alone, and what number it would return the day the model changes.
  • model and digest hold the same thing to two different readers. Say which of the two is in the key and what would go wrong if the other one were.
  • Given 3,076 bytes a row and 768 numbers in it, account for every byte, and then say what one hundred thousand chronicle lines would come to.
  • The LIMIT on the search changed from 1 to 91 and the rows read did not move. Explain that from the plan, in one sentence that mentions the sort.
  • Say why the snapshot did not gain a ninth part on this page, and name the other thing this world leaves out of a snapshot for the same reason.
⚡ Exercises — try first, then reveal
Exercise 1 — ask the two questions this page did not. The fixture carries four questions and the page printed rankings for two. Run the other two against the table, and then say, from the rankings alone, which kind of chronicle line this search can help with and which kind it cannot.

-mode ask -q 3 and -mode ask -q 4, against a database whose eighth table holds the committed rows. Both agree with the Go to the same seven places, because the agreement is a property of the arithmetic and not of the question.

The eleven founding lines say things in words and every one is findable by a question about what it says. The eighty yearly counts are one sentence with different numbers in it, and a question about numbers ranks them all close together with no sense of which is which, because nothing in a direction says 238 is larger than 43. A search by meaning finds the lines that are about a subject; asking which of them holds the biggest number is asking it something it was never doing.

Exercise 2 — give it the index it does not have. Add a btree index on digest with psql, run the cost mode again, and predict before you look whether the rows read will change.

CREATE INDEX chronicle_vector_by_digest ON chronicle_vector (digest); and then -mode cost -q 1. Nothing moves. Every row in the table has that digest, so an index on it rules out nothing, and the server reads the table straight through exactly as it did before, which it is right to do: an index is only worth descending when it removes rows from consideration.

It is the same argument the creature table made about its row column, which holds two distinct values in the whole table and will never be indexed. The index that would help this search is not one on digest: it is one on v, and it is a different kind of thing entirely, because it would answer with the rows it thinks are closest instead of the rows that are.

Exercise 3 — throw the derived table away. This page claims the eighth table can be dropped without loss. Drop it with psql, run the migrations again, and find out what “without loss” does and does not cover.

DROP TABLE chronicle_vector; and then -mode up. The migration is not applied again, and the run says so: the ledger holds a row for 0007 and the bytes still agree, so there is nothing left to do. The next thing that touches the table gets relation "chronicle_vector" does not exist, SQLSTATE 42P01.

Both halves of that are correct and they answer different questions. No information was lost: run the job against a fresh database and the world is exactly where it was. What was lost is the schema, and the ledger records what this world's migrations did and not what the database currently looks like. Dropping a table by hand puts a database in a state no file in the tree describes, reached here by a different road from the one the migration chapter's worked failure took. Derived is a claim about the rows and never about the table.