How to Build a Knowledge Base for AI From a Website

Build an AI knowledge base from a website in Python: crawl to markdown, chunk, embed and search with cited answers in five small files, no database.

Written byAndrii
Published on
How to Build a Knowledge Base for AI From a Website

How to Build a Knowledge Base for AI From a Website in Python

How to build a knowledge base for AI from a website: crawl the pages to markdown, split them into chunks, embed every chunk, find the chunks closest to a question, and let a language model answer from them with the source URLs. This post does it in five small Python files and about 250 lines, with no database and no framework. The same steps work for a product docs site or a help center that a support bot has to answer from.

What comes out at the end is chunks.jsonl (text plus source URL), embeddings.npy (one vector per chunk), a search.py command that lists the best matches, and an ask.py command that answers with cited URLs, or says not found. This is the loop behind "chat with a website" tools. How it works is explained in how an AI knowledge base answers questions.

The demo site is the FastAPI documentation, and every number below comes from one real run on October 25, 2026. The decisions behind the pipeline (why markdown, where to store vectors, scale) are in the map post, Website to Markdown for Knowledge Bases. This post is the code.

How to build a knowledge base for AI: what you will build

Pipeline from crawl to markdown, chunks, embeddings, storage and query

One file per step, each one reads what the previous one wrote:

Project folder with five Python files, requirements.txt, the pages folder, chunks.jsonl and embeddings.npy

Left out on purpose: a database, a framework, reranking, hybrid search. Python 3.11 or newer is assumed.

Setup: build a knowledge base in Python

Create a virtual environment and install four pinned packages:

# requirements.txt
webcrawlerapi==2.1.5
numpy==2.4.6
requests==2.34.2
tiktoken==0.14.0
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
export WEBCRAWLERAPI_API_KEY=YOUR_API_KEY
export OPENROUTER_API_KEY=YOUR_OPENROUTER_KEY

The first key comes from a WebCrawlerAPI account: create an API key there. Embeddings and answers go through OpenRouter, which exposes both with one key and one HTTP style, plain requests. Any embedding or chat provider works the same way, only the two URLs and model names change.

The whole run is cheap, and the totals are in What it costs. WebCrawlerAPI charges per crawled page ($0.002 on pay as you go, see pricing).

Step 1: Crawl the site to markdown

The site is fastapi.tiangolo.com. Before crawling, the licence and the rules were checked on October 25, 2026: the FastAPI repository is MIT licensed, and the docs robots.txt has no Allow or Disallow rules. The script still passes respect_robots_txt=True. For another site, do this check first, and see web scraping ethics. This step turns a website into LLM-ready markdown, one file per page.

# crawl.py
import os, re, sys, time, pathlib
from webcrawlerapi import WebCrawlerAPI

START = "https://fastapi.tiangolo.com/tutorial/"
ONLY = r"^https://fastapi\.tiangolo\.com/(tutorial|advanced)/"
LIMIT = int(sys.argv[1]) if len(sys.argv) > 1 else 60
MAX_POLLS, DEADLINE = 120, 600  # stop after 120 checks or 10 minutes

client = WebCrawlerAPI(api_key=os.environ["WEBCRAWLERAPI_API_KEY"])
job = client.crawl_async(
    url=START,
    items_limit=LIMIT,
    output_formats=["markdown"],
    whitelist_regexp=ONLY,
    respect_robots_txt=True,
    max_age=0,  # skip the 7-day cache, fetch fresh pages
)
print("job", job.id)

started = time.time()
for _ in range(MAX_POLLS):
    job = client.get_job(job.id)
    print(f"status={job.status} items={len(job.job_items)}")
    if job.status not in ("new", "in_progress"):
        break
    if time.time() - started > DEADLINE:
        break
    time.sleep(max(job.recommended_pull_delay_ms, 1000) / 1000)
else:
    sys.exit("gave up: too many polls")
if job.status != "done":
    sys.exit(f"job not finished, status={job.status}")

out = pathlib.Path("pages")
out.mkdir(exist_ok=True)
done = failed = 0
for item in job.job_items:
    if item.status != "done":
        failed += 1
        print("failed:", item.original_url, item.status)
        continue
    slug = re.sub(r"[^a-z0-9]+", "-", item.original_url.split(".dev/")[-1].split(".com/")[-1].lower()).strip("-")
    front = f'---\nurl: {item.original_url}\ntitle: "{item.title}"\n---\n\n'
    (out / f"{slug}.md").write_text(front + item.get_markdown())
    done += 1
print(f"{done} pages saved, {failed} failed, {time.time() - started:.1f}s")

What the script does:

  • whitelist_regexp keeps the crawl inside the Tutorial and Advanced User Guide sections, and items_limit=60 stops it at 60 pages. See the crawl API docs for every parameter.
  • max_age=0 forces a fresh crawl instead of the 7-day cache, see caching.
  • The polling loop uses the delay the API recommends and has two exits, 120 checks or 10 minutes, so a stuck job cannot poll forever. For error codes, see errors; the script only prints the status.
  • Pages are saved as pages/<name>.md with url and title in front matter. Those two fields become the citations later.

Terminal output of python crawl.py: job id, polling lines and 60 pages saved in 47.9 seconds

The run on October 25, 2026: 60 pages saved, 0 failed, 47.9 seconds from start to the last file. The folder is about 806 KB of markdown.

Building the crawler yourself is a fair option for a small static site: how to crawl a website with Python compares the ways. LangChain users can take a loader instead, see LangChain document loaders for websites.

Step 2: Split the markdown into chunks

A page in one vector is blurry, and a sentence in one vector has no context. So pages are cut into chunks of a few hundred tokens. This version is deliberately simple: split on ## and ### headings (ignoring lines inside code fences), merge neighbouring sections until a chunk would pass 350 tokens, and cut any single section above 800 tokens on line breaks. No overlap logic.

# chunk.py
import json, re, pathlib, hashlib
import tiktoken

enc = tiktoken.get_encoding("cl100k_base")
BUDGET = 350  # merge small sections until a chunk would pass this many tokens
HARD = 800    # a single section above this is cut on line breaks

def count(text):
    return len(enc.encode(text, disallowed_special=()))

def read_page(path):
    raw = pathlib.Path(path).read_text()
    head, body = raw[4:].split("\n---\n", 1)
    url = re.search(r"^url: (.*)$", head, re.M).group(1)
    title = re.search(r'^title: "(.*)"$', head, re.M).group(1)
    body = re.sub(r"\[\u00b6\]\([^)]*\)", "", body)  # drop the permalink links after headings
    body = re.sub(r"^\[Skip to content\].*\n", "", body, flags=re.M)
    body = "\n".join(l for l in body.splitlines() if len(l) < 1500)  # drop base64 blobs
    return url, title, body

def sections(body, title):
    """Yield (heading_path, text), splitting on ## and ### outside code fences."""
    path, buf, fence = [title], [], False
    for line in body.splitlines():
        if line.startswith("```"):
            fence = not fence
        m = None if fence else re.match(r"^(#{2,3}) (.+)", line)
        if m:
            if "\n".join(buf).strip():
                yield " > ".join(path), "\n".join(buf).strip()
            buf = []
            path = path[: len(m.group(1)) - 1] + [m.group(2).strip()]
        buf.append(line)
    if "\n".join(buf).strip():
        yield " > ".join(path), "\n".join(buf).strip()

def cut(text):
    """Cut a very long section on line breaks into pieces of at most HARD tokens."""
    if count(text) <= HARD:
        return [text]
    pieces, cur = [], ""
    for line in text.splitlines():
        if cur and count(cur + "\n" + line) > HARD:
            pieces.append(cur)
            cur = ""
        cur = (cur + "\n" + line) if cur else line
    return pieces + [cur]

def chunks_of(url, title, body):
    pieces = [(path, part) for path, text in sections(body, title) for part in cut(text)]
    cur_path, cur = None, ""
    for path, text in pieces:
        if cur and count(cur + "\n\n" + text) > BUDGET:
            yield cur_path, cur
            cur = ""
        if not cur:
            cur_path = path
        cur = (cur + "\n\n" + text) if cur else text
    if cur:
        yield cur_path, cur

rows, seen = [], set()
for f in sorted(pathlib.Path("pages").glob("*.md")):
    url, title, body = read_page(f)
    for heading_path, text in chunks_of(url, title, body):
        cid = hashlib.sha1((url + heading_path + text).encode()).hexdigest()[:12]
        if cid in seen:  # tabbed code samples repeat the same text
            continue
        seen.add(cid)
        rows.append({"id": cid, "url": url, "title": title,
                     "heading_path": heading_path, "text": text, "tokens": count(text)})

with open("chunks.jsonl", "w") as out:
    for r in rows:
        out.write(json.dumps(r) + "\n")

sizes = sorted(r["tokens"] for r in rows)
pages = len({r["url"] for r in rows})
print(f"{len(rows)} chunks from {pages} pages")
print(f"tokens per chunk: median {sizes[len(sizes) // 2]}, max {sizes[-1]}, total {sum(sizes)}")

read_page also drops permalink links, the "Skip to content" line and any line over 1,500 characters (base64 images), and duplicate chunks are skipped by id. Those lines were added after reading the first output, which is a good habit: look at the chunks before embedding them.

Every chunk keeps url, title and heading_path next to the text. Citations need the first and the last. For a longer look at chunk sizes and overlap, see chunking strategies for a knowledge base. A dedicated chunking tutorial is planned. Heading-based against fixed chunks was measured in the map post, with a mixed result, so nothing here claims it is better.

Terminal output of python chunk.py: 479 chunks from 60 pages, median 319 tokens, and the first two lines of chunks.jsonl

Result: 479 chunks from 60 pages, about 8 per page. Median 319 tokens, maximum 800, 173,566 tokens in total (cl100k_base). 13 chunks are under 30 tokens, mostly a short heading with a line under it. chunks.jsonl is 869 KB.

Step 3: Embed the chunks

An embedding model turns text into a vector, and texts with similar meaning land close together. The script embeds heading_path + text, so a chunk that says "the default is 7 days" travels with the heading that explains what the default is. The URL stays metadata.

# embed.py
import os, json, time, pathlib
import numpy as np
import requests

MODEL = "openai/text-embedding-3-small"
BATCH = 64
CACHE = pathlib.Path("embed_cache.json")  # chunk id -> vector

def embed_texts(texts):
    """Embed a list of strings through OpenRouter. Returns (vectors, tokens used)."""
    r = requests.post(
        "https://openrouter.ai/api/v1/embeddings",
        headers={"Authorization": "Bearer " + os.environ["OPENROUTER_API_KEY"]},
        json={"model": MODEL, "input": texts},
        timeout=120,
    )
    r.raise_for_status()
    body = r.json()
    data = sorted(body["data"], key=lambda d: d["index"])
    return [d["embedding"] for d in data], body["usage"]["prompt_tokens"]

def embed_input(chunk):
    return chunk["heading_path"] + "\n\n" + chunk["text"]

if __name__ == "__main__":
    chunks = [json.loads(l) for l in open("chunks.jsonl")]
    cache = json.loads(CACHE.read_text()) if CACHE.exists() else {}
    todo = [c for c in chunks if c["id"] not in cache]
    print(f"{len(chunks)} chunks, {len(chunks) - len(todo)} cached, {len(todo)} to embed")

    tokens, started = 0, time.time()
    for i in range(0, len(todo), BATCH):
        batch = todo[i : i + BATCH]
        vectors, used = embed_texts([embed_input(c) for c in batch])
        for c, v in zip(batch, vectors):
            cache[c["id"]] = v
        tokens += used
        print(f"batch {i // BATCH + 1}: {len(batch)} chunks, {used} tokens")
    CACHE.write_text(json.dumps(cache))

    E = np.array([cache[c["id"]] for c in chunks], dtype="float32")
    E /= np.linalg.norm(E, axis=1, keepdims=True)  # unit length: dot product = cosine
    np.save("embeddings.npy", E)
    print(f"saved embeddings.npy {E.shape}, {tokens} new tokens, {time.time() - started:.1f}s")

Notes:

  • Chunks go out in batches of 64, one HTTP call each. The token count comes from the usage field of the response, so the cost is measured, not guessed.
  • embed_cache.json stores vectors by chunk id. The id is a hash of url, heading path and text, so an unchanged chunk is never embedded twice, and an edited chunk gets a new id.
  • Vectors are normalised to length 1, so a dot product later is the cosine similarity.
  • chunks.jsonl stays the source of truth. Changing the model means deleting the cache and running the script again. A comparison of embedding models is planned.

Terminal output of python embed.py: 8 batches, 181,045 tokens, then a second run with 479 chunks cached and 0 new tokens

First run: 479 chunks in 8 batches, 181,045 tokens, 13.9 seconds, $0.00362 at $0.02 per 1M tokens. Tokens are higher than the 173,566 in the chunk file because the heading paths are embedded too. The output is embeddings.npy, 479 vectors of 1,536 floats, 2.9 MB. Second run: 479 cached, 0 new tokens, 0.3 seconds, no cost. The cache file itself is 14.8 MB of JSON, the price of the simple format.

Step 4: Search it

Search is the question embedded with the same model, one matrix product against all vectors, and the best five by score.

# search.py
import sys, json, time
import numpy as np
from embed import embed_texts

chunks = [json.loads(l) for l in open("chunks.jsonl")]
E = np.load("embeddings.npy")

def embed_query(query):
    vectors, _ = embed_texts([query])
    q = np.array(vectors[0], dtype="float32")
    return q / np.linalg.norm(q)

def top_k(q, k=5):
    scores = E @ q  # cosine similarity against every chunk
    best = np.argpartition(-scores, k)[:k]  # k best, unordered
    best = best[np.argsort(-scores[best])]  # now ordered
    return [(float(scores[i]), chunks[i]) for i in best]

def search(query, k=5):
    return top_k(embed_query(query), k)

if __name__ == "__main__":
    query = " ".join(sys.argv[1:])
    t0 = time.perf_counter()
    q = embed_query(query)
    t1 = time.perf_counter()
    hits = top_k(q)
    t2 = time.perf_counter()
    for rank, (score, c) in enumerate(hits, 1):
        print(f"{rank}. {score:.3f}  {c['url']}")
        print(f"   {c['heading_path']}")
        print(f"   {c['text'][:110]!r}...")
    print(f"embed call {1000 * (t1 - t0):.0f} ms, numpy search {1000 * (t2 - t1):.2f} ms")

np.argpartition finds the top five without sorting all 479 scores, and only those five get sorted. Run it:

python search.py "CORSMiddleware allow_origins"
python search.py "How do I send an email after the API has already responded?"

Terminal output of search.py for two queries: the first matches the CORS page by name, the second finds the background tasks page from a paraphrase

The first query uses the exact names from the docs, and the CORS page takes the top four places (best score 0.596). The second is a paraphrase with few shared keywords, and /tutorial/background-tasks/ is first, but with a low score of 0.378 and wrong neighbours right behind it. The search worked, but the scores say it was close.

It does not always work: a differently worded question about running code after a response ranked a middleware section first, and the background tasks page was not in the top five. Hybrid search or a reranker could help there. Neither was tried here.

Speed: the query embedding call took 572 to 597 ms in the runs shown, and the numpy search 0.5 to 2.9 ms across the runs shown. The network call is the slow part, not the maths. Where brute force stops being fine was not measured; for sizing see the vector database comparison.

Step 5: Answer with citations

The last file is the website-to-LLM step: it takes the five best chunks, puts them in the prompt with their URLs, and tells the model to answer only from them.

# ask.py
import os, sys
import requests
from search import search

MODEL = "anthropic/claude-haiku-5.5"
SYSTEM = (
    "You answer questions about the FastAPI documentation. Use only the context below. "
    "Be concise: four sentences at most. Cite the URL of every source you used in square brackets. "
    "If the context does not contain the answer, reply with exactly: not found"
)

def ask(question, k=5):
    hits = search(question, k)
    context = "\n\n".join(f"[source: {c['url']}]\n{c['text']}" for _, c in hits)
    r = requests.post(
        "https://openrouter.ai/api/v1/chat/completions",
        headers={"Authorization": "Bearer " + os.environ["OPENROUTER_API_KEY"]},
        json={
            "model": MODEL,
            "temperature": 0,
            "max_tokens": 600,
            "messages": [
                {"role": "system", "content": SYSTEM},
                {"role": "user", "content": f"Context:\n\n{context}\n\nQuestion: {question}"},
            ],
        },
        timeout=120,
    )
    r.raise_for_status()
    body = r.json()
    answer = body["choices"][0]["message"]["content"].strip()
    urls = list(dict.fromkeys(c["url"] for _, c in hits))
    sources = [u for u in urls if u in answer] or urls  # prefer the ones the model cited
    return answer, sources, body["usage"]

if __name__ == "__main__":
    answer, sources, usage = ask(" ".join(sys.argv[1:]))
    print(answer)
    if answer.lower().strip(".") != "not found":
        print("\nSources:")
        for url in sources:
            print(" -", url)
    print(f"\n({usage['prompt_tokens']} prompt tokens, {usage['completion_tokens']} completion tokens)")

The prompt asks for four sentences at most, URLs in square brackets, and the exact reply not found when the context has no answer. The script prints the sources the model actually cited, and falls back to all retrieved URLs if it cited none. Temperature is 0.

Terminal output of ask.py: an answer about background tasks with its source URL, an answer about pwdlib with its source URL, and not found for an AWS Lambda question

Eight questions were asked, each written to be answered from one or two pages, plus one the docs do not cover. Results of the run:

QuestionAnswer fromPrompt / completion tokens
How do I send an email after the API has already responded?/tutorial/background-tasks/2,056 / 188
How do I forbid extra fields in a query parameter model?/tutorial/query-param-models/3,712 / 230
How do I allow a frontend on another domain to call my API?/tutorial/cors/2,082 / 361
Which library does the JWT tutorial use to hash passwords?/tutorial/security/oauth2-jwt/ (pwdlib)2,590 / 171
How do I read a cookie in a path operation?/tutorial/cookie-params/ and /tutorial/cookie-param-models/1,896 / 287
How do I upload a file?/tutorial/request-files/3,439 / 219
What status code does a path operation return by default for a successful POST?/tutorial/response-status-code/ (200)2,142 / 338
How do I deploy a FastAPI app to AWS Lambda?not found2,120 / 4

All seven answerable questions cited a page that holds the answer, and the unanswerable one returned not found instead of a made-up guide. That is a small test with questions written by the author, not a benchmark. The answers were read by hand against the pages for this post, and no grader was run.

Chat with a website

A terminal chat is a loop around the same function:

from ask import ask

while True:
    answer, sources, _ = ask(input("question> "))
    print(answer, sources)

That is the whole "chat with a website" trick, really. There is no memory between questions, so a follow-up like "and for POST?" will not work; rewriting it with the previous turn is the usual fix and is not in these files.

Limits

No reranking, no hybrid search, no freshness check, no tables or images. One site, one embedding model, eight questions.

Swap the store

chunks.jsonl is the contract: id, url, heading path, text, and one vector per row from embeddings.npy. Only the two numpy lines in search.py change.

StoreUse whenWhat changes in search.pyGuide
Postgres with pgvectorPostgres is already runningtop_k becomes one SQL query with ORDER BY embedding <=> ...pgvector knowledge base search
Dedicated vector database (Qdrant, Chroma, LanceDB, Pinecone)Many filters, tenants or a larger corpustop_k calls the client query methodComparison

Other options are Supabase, Elasticsearch, managed knowledge bases and local apps. Step-by-step tutorials for them are planned.

Keep it fresh

A knowledge base that was right on build day and wrong six months later is worse than none.

  • Recrawl on a schedule that matches the content, see how often to recrawl a website for an AI knowledge base. Keep max_age=0, or a recrawl can return a copy up to 7 days old.
  • Run chunk.py and embed.py again. The cache means only new or changed chunks are embedded.
  • Removed pages are not cleaned from embed_cache.json, so prune it, or the file only grows.

What it costs

All measured on October 25, 2026, one run, one crawl of 60 pages:

PartAmountCost
Crawl, 60 pages60 x $0.002 per page$0.12
Embeddings181,045 tokens in 8 batches$0.00362
8 answers20,037 prompt and 1,798 completion tokens$0.0029
Run totalabout $0.127

The per-1,000-pages line is arithmetic, not a measurement: crawl $2.00 at $0.002 per page, about 3.0M embedding tokens (about $0.06) and about 8,000 chunks, which is about 49 MB of float32 vectors. Crawling is the main cost, embeddings are a rounding error, and the answer model is cents per hundred questions. Engineering time is not in the table. Prices are as listed on October 25, 2026.

FAQ

How do I build a knowledge base for AI from a website?

Crawl the site to markdown, split the pages into chunks, embed each chunk, search the vectors with the embedded question, and give the top chunks to a language model that answers with source URLs. The five Python files in this post do exactly that, with no database.

Does ChatGPT have a knowledge base, and can I train ChatGPT on my website?

ChatGPT can work with files attached to a conversation (OpenAI help center, read October 25, 2026), which is an upload, not a crawled website. No training is needed here: the model stays unchanged, and the closest chunks are passed to it at question time.

How do I create my own AI database?

At this size, a JSON Lines file for text and metadata plus a numpy array for vectors is enough. A database helps with many more chunks, several writers, or filters by customer or language. See the swap table and the vector database comparison.

Do I need LangChain or a vector database for this?

No. The project uses the plain Python SDK, requests, numpy and tiktoken. LangChain fits when the rest of the stack uses it, and a vector database fits once the corpus outgrows a file.

How do I chat with a website in Python?

Call ask() from ask.py in a loop that reads input() and prints the answer with its sources. That is a knowledge base chatbot in Python without a web framework, and it has no memory between questions.

How do I keep the knowledge base up to date?

Recrawl on a schedule with max_age set to 0, then run chunk.py and embed.py again. The embedding cache is keyed by a hash of each chunk, so only new or changed chunks are embedded.

Conclusion

The whole loop fits in five files: crawl, chunk, embed, search, ask. To build an AI knowledge base from a docs site, this is enough to start. On 60 FastAPI pages it cost about 13 cents (crawl 47.9 seconds, embeddings 13.9 seconds), and the answers carried the source URL of the page that held the fact. The weak spots are the ones named above: paraphrased questions can miss, and nothing checks freshness.

Next steps:

Notes on the numbers

One run on October 25, 2026, Python 3.11, webcrawlerapi 2.1.5: 60 pages, 0 failed, items_limit 60, max_age 0. Embeddings openai/text-embedding-3-small (1,536 dimensions), answers anthropic/claude-haiku-5.5 at temperature 0 with the top 5 chunks. Not measured: other sites, larger question sets, hybrid search, reranking, and corpus sizes where numpy search gets slow.


About the Author

Andrii Mazurian
Andrew Mazurian@andriixzvf

Founder, WebCrawlerAPI · 🇳🇱 Netherlands

Engineer with 15 years of experience in APIs, big data, and infrastructure. Founded WebCrawlerAPI in 2024 with a single goal: to build the best data API, and have been shipping it every day since.