Once you are comfortable · 120 minutes
Ask questions of your own documents
The project people mean when they say they want private AI. You give it a folder — contracts, notes, a manual, years of correspondence — and it answers questions using only those documents, quoting which file each answer came from. Two models are running: one that turns text into numbers so you can search by meaning, and one that writes the answer. Neither of them is on somebody else's computer.
- Python
- openai
- numpy
- Pip text server
- Pip embedding server
Before you start
- Running in Pip: a text server and a embedding server.
- 16 GB of memory or more. Less than that and the model you need will not load.
- Installed already: Python 3.10+.
Worth reading first: Context: the model's short memory, What "private" really buys you, Designing a two-lane system.
Start two servers, not one
This project needs both kinds of model Pip runs. A text model writes the answers. An embedding model turns a piece of text into a list of numbers that represents its meaning, so two passages about the same thing land near each other even when they share no words. Searching by meaning rather than by keyword is the entire mechanism here.
Start a text server and an embedding server in Pip. The first gets 8081, the second 8082. Confirm both before writing anything — a project with two dependencies is twice as annoying to debug once there is code in the way.
curl -s http://127.0.0.1:8081/v1/models # the text model
curl -s http://127.0.0.1:8082/v1/models # the embedding model
How you know it worked
curl -s http://127.0.0.1:8082/v1/embeddings -H 'Content-Type: application/json' -d '{"input":"hello","model":"nomic-embed-text-v1.5"}'
A JSON reply with a long array of numbers in it. If the array has 768 entries, that is your embedding model's dimension — write it down, you will need it to stay consistent. If this returns an error about the model name, use the id the /v1/models call gave you.
Cut the documents into answerable pieces
You cannot embed a whole document usefully. One list of numbers for a forty-page contract represents the average of everything in it, which is to say nothing in particular. You need pieces small enough to be about one thing and large enough to make sense alone.
A few hundred words is the usual answer. Overlap the pieces so a passage split across a boundary still appears whole somewhere, and keep the filename with every piece — that is what lets the finished tool tell you where an answer came from.
from dataclasses import dataclass
from pathlib import Path
@dataclass
class Chunk:
text: str
source: str # which file it came from
index: int # where in that file, so you can cite it
def split(text: str, size: int = 1200, overlap: int = 200) -> list[str]:
step = size - overlap
pieces = []
for start in range(0, len(text), step):
piece = text[start:start + size].strip()
if len(piece) > 50: # a fragment that short cannot answer anything
pieces.append(piece)
return pieces
def load(folder: str) -> list[Chunk]:
chunks = []
for path in sorted(Path(folder).rglob("*")):
if path.suffix.lower() not in {".txt", ".md"} or not path.is_file():
continue
text = path.read_text(encoding="utf-8", errors="replace")
for index, piece in enumerate(split(text)):
chunks.append(Chunk(text=piece, source=str(path), index=index))
return chunks
How you know it worked
python -c "from chunks import load; c = load('./docs'); print(len(c), 'chunks'); print(c[0].source, '|', c[0].text[:80])"
A chunk count and a readable first chunk with its filename. If you get zero chunks, check the file extensions — this reads .txt and .md only.
Turn every piece into numbers, once
Embedding is the slow part, so it happens once and the result is saved. After this runs, asking a question costs one embedding rather than all of them.
There is a detail here that catches almost everybody. Several embedding models, Nomic's included, expect you to say what a piece of text is for — a document being stored, or a query being asked. You prefix the text accordingly. Skip the prefixes and it still works, badly, in a way that looks like the search is simply not very good.
import pickle
import sys
import numpy as np
from openai import OpenAI
from chunks import load
embedder = OpenAI(base_url="http://127.0.0.1:8082/v1", api_key="not-needed")
EMBED_MODEL = embedder.models.list().data[0].id
def embed(texts: list[str], prefix: str) -> np.ndarray:
"""prefix is 'search_document: ' when storing and 'search_query: ' when
asking. Nomic models are trained to expect it; without it the two kinds of
text land in slightly different places and retrieval quietly degrades."""
out = []
for start in range(0, len(texts), 32): # batch, or it takes all evening
batch = [prefix + text for text in texts[start:start + 32]]
reply = embedder.embeddings.create(model=EMBED_MODEL, input=batch)
out.extend(item.embedding for item in reply.data)
print(f" embedded {min(start + 32, len(texts))}/{len(texts)}")
matrix = np.array(out, dtype=np.float32)
# Normalise once, here. Then similarity is a dot product rather than a
# division per comparison, and every search afterwards is faster.
matrix /= np.linalg.norm(matrix, axis=1, keepdims=True)
return matrix
if __name__ == "__main__":
folder = sys.argv[1] if len(sys.argv) > 1 else "./docs"
chunks = load(folder)
if not chunks:
sys.exit(f"no .txt or .md files found under {folder}")
print(f"{len(chunks)} chunks to embed")
vectors = embed([chunk.text for chunk in chunks], "search_document: ")
with open("index.pkl", "wb") as handle:
pickle.dump({"chunks": chunks, "vectors": vectors, "model": EMBED_MODEL}, handle)
print(f"saved index.pkl — {vectors.shape[0]} vectors of {vectors.shape[1]} dimensions")
How you know it worked
python index_docs.py ./docs
Progress lines, then a saved index reporting how many vectors and how many dimensions. The dimension must match what you saw in step one. If it does not, you are embedding against a different model than you think.
Find the pieces that might answer a question
Embed the question the same way, compare it against everything, take the closest few. Because the vectors were normalised when they were stored, the comparison is one matrix multiplication — fast enough on tens of thousands of chunks that you do not need a vector database yet.
Knowing when you would need one is worth more than reaching for one now. At a few hundred thousand chunks this starts to feel slow, and that is the moment, not before.
import pickle
import numpy as np
from index_docs import embed
with open("index.pkl", "rb") as handle:
INDEX = pickle.load(handle)
def find(question: str, top: int = 5):
query = embed([question], "search_query: ")[0]
# Both sides are normalised, so the dot product is the cosine similarity.
scores = INDEX["vectors"] @ query
best = np.argsort(scores)[::-1][:top]
return [(INDEX["chunks"][i], float(scores[i])) for i in best]
if __name__ == "__main__":
import sys
for chunk, score in find(" ".join(sys.argv[1:]) or "what is this about?"):
print(f"{score:.3f} {chunk.source}#{chunk.index}")
print(f" {chunk.text[:150]}...\n")
How you know it worked
python search.py "a question you know the answer to"
Five chunks with similarity scores, highest first, and the top one should visibly relate to what you asked. Scores above about 0.6 are usually relevant and below about 0.3 usually are not, but calibrate that against your own documents rather than trusting the numbers.
Answer from those pieces, and only those
Now the part that separates this from a chatbot. The model is given the retrieved passages and told to answer from them alone — and, crucially, told what to do when they do not contain the answer.
A model that will not say "it is not in these documents" is worse than useless for this job, because you cannot tell its real answers from its invented ones. The instruction below spends as many words on refusing as on answering, deliberately.
import sys
from openai import OpenAI
from search import find
writer = OpenAI(base_url="http://127.0.0.1:8081/v1", api_key="not-needed")
MODEL = writer.models.list().data[0].id
JOB = """You answer questions using only the passages provided.
Rules, in order of importance:
1. If the passages do not contain the answer, say exactly:
"That is not in these documents." Say nothing else.
2. Never use knowledge from outside the passages, even if you are confident
it is correct. The user is asking about their documents, not the world.
3. Cite the source of each claim in square brackets, like [contract.txt].
4. If the passages disagree with each other, say so rather than choosing.
Answer in plain language, briefly."""
def ask(question: str, top: int = 5) -> str:
hits = find(question, top=top)
if not hits or hits[0][1] < 0.25:
# Nothing came back that is even plausibly related. Do not waste a
# generation on it, and do not give the model the chance to improvise.
return "That is not in these documents."
passages = "\n\n".join(
f"[{chunk.source}]\n{chunk.text}" for chunk, _ in hits
)
reply = writer.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": JOB},
{"role": "user", "content": f"Passages:\n\n{passages}\n\nQuestion: {question}"},
],
temperature=0.1,
)
return reply.choices[0].message.content
if __name__ == "__main__":
question = " ".join(sys.argv[1:])
if not question:
sys.exit('usage: python ask.py "your question"')
print(ask(question))
How you know it worked
python ask.py "something your documents definitely cover"
An answer with a filename cited in brackets. Then the test that matters: ask something your documents definitely do not cover — the capital of France, say — and confirm you get the refusal rather than an answer. A system that answers that question is not grounded, whatever it does the rest of the time.
Try to make it lie
Before you trust this with anything that matters, spend ten minutes attacking it. Retrieval systems fail in specific, repeatable ways, and you want to meet them now rather than in front of somebody.
Write down what happens for each of these. The failures you find are the specification for the extensions below.
- Ask something outside the documents entirely. You should get the refusal.
- Ask something covered in one sentence buried in a long file. Retrieval often misses these — the surrounding text drowns the signal.
- Ask a question whose answer changed between two documents. It should say they disagree, not pick one.
- Ask using words that never appear in the documents, but mean the same thing. This is what embeddings are for, and if it fails here the prefixes in step three are the first thing to check.
- Ask a two-part question. Retrieval usually finds passages for one part and the model answers only that part, confidently.
How you know it worked
At least one of those five failing. They all fail for somebody — knowing which ones fail for you is the difference between a demonstration and a tool you can rely on.
Building it with an agent
I want to ask questions of a folder of my own documents and get answers grounded in them, with citations, and a refusal when the answer is not there. It uses a local embedding model for search and a local text model for the answer. Nothing goes to a cloud API.
With Claude Code
This is the project where letting an agent build it all at once hurts most, because there are four places it can silently be wrong and they all look the same from the outside. Stage it:
I am building a retrieval-augmented question answering tool
over a local folder of .txt and .md files. Two local servers:
an embedding model at http://127.0.0.1:8082/v1 and a text
model at http://127.0.0.1:8081/v1. The openai package, no
cloud API anywhere.
Build it in four stages and stop after each so I can run it:
1. Chunking — split files, keep the source filename.
2. Indexing — embed chunks, normalise, save to disk.
3. Search — embed a query, cosine similarity, top 5.
4. Answering — pass passages to the text model, cite sources,
and refuse when the passages do not contain the answer.
My embedding model needs "search_document: " and
"search_query: " prefixes. Do not skip them.
That last instruction is worth typing every time. An agent has no way to know which embedding model you loaded, and it will not add prefixes it was not told about — and the resulting system works just well enough that you will not immediately notice.
With Codex
Give Codex the file layout and both endpoints together:
Create a Python project: chunks.py, index_docs.py, search.py,
ask.py, requirements.txt (openai, numpy).
chunks.py splits .txt and .md files under a folder into
~1200-character pieces with 200 overlap, keeping the source
filename and position with each piece.
index_docs.py embeds every chunk against
http://127.0.0.1:8082/v1 in batches of 32, prefixing each
with "search_document: ", L2-normalises the matrix, and
pickles chunks plus vectors plus the model id to index.pkl.
search.py embeds a query with "search_query: ", takes the
dot product against the stored matrix, returns the top 5
with scores.
ask.py sends those passages to the text model at
http://127.0.0.1:8081/v1 with a system prompt that answers
only from the passages, cites filenames in brackets, and
replies "That is not in these documents." when they do not
contain the answer. Skip the model call entirely when the
best similarity is below 0.25.
The similarity floor is the line to keep. It is the cheapest guard against confident nonsense and no agent adds it unprompted.
Read what it gave you
Generated code is a draft. These are the things that go wrong on this particular project, in roughly the order they are worth checking.
- Are the embedding prefixes there? Search the code for "search_document" and "search_query". Missing prefixes on a Nomic-style model degrade retrieval without breaking anything visibly.
- Is the same model used to index and to query? If index_docs.py and search.py each call models.list() independently and you have two embedding servers running, they can silently pick different ones.
- Are the vectors normalised before the dot product? If not, the "similarity" is dominated by chunk length rather than meaning, and long chunks win every search.
- Is there a similarity floor before the text model is called? Without it, an unanswerable question still gets five irrelevant passages and a confident answer built on them.
- Does the system prompt say what to do when the answer is absent? A prompt that only describes how to answer will always produce an answer.
- Does it cite? An answer you cannot trace back to a file is one you have to verify by reading everything, which is the problem you were trying to solve.
- Does the index record which model built it? Without that, a swapped embedding model produces an index that is wrong in a way nothing detects.
- Ask the agent what happens when two documents contradict each other. If it has no answer, neither will the tool.
Where to take it next
- Add PDF reading. Most real document folders are PDFs, and extraction quality — especially for tables and scans — will affect answers more than any model choice you make.
- Return the passages alongside the answer so the reader can check the citation without opening the file.
- Re-rank the retrieved chunks with the text model before answering: ask it which of the five are actually relevant, then answer from those. It costs a second generation and fixes most of the two-part-question failures.
- Keep the index up to date by storing each file's modification time and re-embedding only what changed.
- Move to a real vector store once this feels slow. Knowing why you outgrew the matrix multiplication is worth more than starting with the database.