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.

120 minutes 6 steps 16 GB RAM Not started yet

  • 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.

bash
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.

chunks.py
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.

index_docs.py
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.

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.

ask.py
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.

Where this comes from

  1. Nomic Embed Text v1.5 model card, including the task prefixes
  2. openai-python: the official client library

Stuck on a step, or found something here that does not work? Email info@redroostertec.com and a person will answer.