Part 5 ended with the assistant inventing an insurance card. The cure is not a cleverer prompt; it is putting the clinic's own documents in front of the model every time it answers. That needs three things the app does not have yet: the documents cut into pieces a model can use, each piece turned into a vector so the right pieces can be found, and a place to keep them. This part builds all three inside the PostgreSQL the clinic already runs, and ends with 23 chunks in a table and a test that proves the second ingest changes nothing.
The documents
Five markdown files, all fictional, under a knowledge folder in the repo: three public (patient information, fees and payment, vaccinations and tests) and two for staff only (front-desk procedures, suppliers and internal rates). Each starts with two lines of front matter, a title and an audience. The audience is the important one. The patient assistant in Part 9 must never see the internal rates, and the way to guarantee that is to record the audience on the data and filter on it in SQL, not to ask the model nicely. That decision is made here, at ingest, and enforced in Part 7's query.
The chunker keeps the headings
A model cannot be handed a whole document per question; the context would be all documents, every time. So documents are cut into chunks: split at level-two and level-three headings, then at paragraphs, so no chunk exceeds about 350 words. The detail that matters is that every chunk is prefixed with its document title and heading, "Fees and payment > Consultation fees", before it is embedded. A paragraph that says "600" means nothing alone; with its heading it means the general consultation fee. Without that prefix, retrieval finds the right words and the wrong meaning. The chunker is a pure function with its own unit tests: prefixing, the word cap, front matter excluded, ordinals continuous.
Embeddings and the table
The embedding model from Part 2, nomic-embed-text, turns each chunk into 768 numbers. Similar meanings land near each other in that space, which is what makes "where can I leave my scooter" find the parking paragraph that says two-wheelers. The vectors live in PostgreSQL with the pgvector extension: a knowledge_document table with slug, title, audience, source path and a content hash, and a knowledge_chunk table with ordinal, heading, text, a token estimate and an embedding column of type vector(768). The audience column is a varchar with a check constraint, the house rule from season one rather than a PostgreSQL enum. An HNSW index with cosine ordering makes the nearest-neighbour query fast even when the knowledge base is a thousand documents rather than five.
docker-compose.yml: image: pgvector/pgvector:pg18
Program.cs: o.UseNpgsql(cs, npg => npg.UseVector())
OnModelCreating: modelBuilder.HasPostgresExtension("vector");
Two packages, Pgvector and Pgvector.EntityFrameworkCore, one EF Core migration named Knowledge, and the test fixture switches its Testcontainers image to the same pgvector build so the integration tests run against the real extension.
The ingest command
The app grows a second entry point: started with the argument ingest, it
builds the host, walks the knowledge folders, and exits without serving a single page.
Per document it computes a content hash, skips the document if the stored hash matches,
otherwise deletes its chunks and embeds the new ones sixteen at a time through the same
IEmbeddingGenerator abstraction the retriever will use. It prints a table and a total.
The first real run embedded 23 chunks in a few seconds against the local model; the second
run skipped all five documents.
The integration test does the same with a fake embedding generator, deterministic from the text, so the suite never needs Ollama: five documents, audiences match the folders, every chunk is 768 wide, a re-run skips everything, and editing one document in a temporary copy re-embeds that one and nothing else. Thirty-nine tests, all green, including the thirty-one the app already had.
What went wrong
Adding a return 0; to the ingest branch turned the top-level program into one
that returns an int, and the normal web path, which ends in app.Run(), suddenly "fell off
the end" with CS0161. An explicit return after Run fixed it. The tool manifest for dotnet-ef
landed in the repo root instead of the .config folder it belongs in, and was moved. And
psql now prints a collation-version warning on the reused volume, because the pgvector
image's C library is older than the one that created the database; harmless, and one
ALTER DATABASE statement silences it when the clinic wants a clean console.
Perishable facts, as of September 2026: pgvector/pgvector:pg18, Pgvector 0.3.2 and Pgvector.EntityFrameworkCore 0.3.0, nomic-embed-text at 768 dimensions. Change the embedding model and every chunk must be re-embedded, and the column width may change with it; the content hash will not notice, so add the model name to the hash if you ever swap models.
Model pick: the schema and the chunking rules were decided in the brief at high effort; the build went to the cheaper model at medium. The one design choice a cheaper model would not have made on its own, the heading prefix, was in the brief because Part 2's thin scooter-versus-parking margin showed why it matters.
What the AI got wrong: the int-returning entry point, the misplaced tool manifest. Both caught by the compiler and a glance at git status, neither by a test, which is a reminder that tests catch what you thought to test.
The meter: the ingest itself costs nothing per run beyond a few seconds of the embedding model; 23 chunks embedded in under ten seconds on the GPU. The build meter reading accumulates for the retro.
Checkpoint: tag private-06 in
the repo. Pull the pgvector
image, run the app once so the migration applies, then
dotnet run --project src/ClinicLive -- ingest. You should see five
documents and 23 chunks, and a second run that skips all five. Next: making the
assistant use them.