cd /news/artificial-intelligence/build-your-own-perplexity-in-about-a… · home topics artificial-intelligence article
[ARTICLE · art-63789] src=pub.towardsai.net ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

Build Your Own Perplexity in About a Hundred Lines of Python

A developer demonstrates that building a functional answer engine like Perplexity requires only about a hundred lines of Python by combining three steps: searching the web, reading top results, and prompting a language model to answer with citations. The architecture relies on a search API and a language model instructed to use only provided sources, making the system transparent and improvable.

read14 min views1 publishedJul 17, 2026

An answer engine, the kind of tool that reads the web and hands you a written answer with citations instead of a list of blue links, sounds like it must be a huge system. It isn’t. Underneath, the whole idea is three steps you can write in an afternoon, search the web, read the top results, and ask a language model to answer using only what it found, with citations. This walks through a complete, working version you can run yourself, explains why each piece is there, and shows exactly how to make it better once the basic one is running.

The first time you use one of the AI answer engines, the ones that respond to a question with a written paragraph and little numbered citations rather than ten links, it feels like a fundamentally different kind of software than a search engine. It isn’t, really. It’s a search engine with a language model bolted onto the end, and the clever part isn’t any single component but the way three simple pieces fit together. Once you see the shape of it, the mystery evaporates, and you realize you can build a working version of the core yourself in about a hundred lines of code.

That’s what this piece does. We will build a small but genuinely working answer engine, the kind of thing Perplexity popularized, from scratch. It will take a question, search the web, read the top few results, and produce an answer that cites its sources. It won’t have the polish, the speed, or the scale of a production system, but it’ll do the real thing, and every piece will be visible and swappable. By the end you’ll understand not just how to build it but why answer engines are designed the way they are, and what separates a good one from a chatbot that makes things up.

Here’s the entire architecture, and it’s worth holding in your head before any code, because everything else is detail hung on this frame.

A question comes in. You send it to a search API and get back the top handful of web results. You fetch those pages and pull out their readable text. You hand that text to a language model along with the original question and a firm instruction, answer using only these sources, and cite them. The model writes the answer with little numbered references pointing back at the source list. That’s it. Search, read, then answer from what was read, with citations.

Every answer engine, no matter how sophisticated, is a version of this loop. The fancy ones add cleverness at each step, better search queries, smarter selection of which text to keep, streaming output, follow-up questions. But the spine is always these three moves, and the single most important one, the thing that makes it an answer engine rather than a chatbot, is that last instruction. The model isn’t answering from its own memory. It’s answering from fresh sources it was just handed, and it’s citing them, which is what makes the answer both current and checkable.

You don’t build a web crawler. You use a search API, a service that takes a query and returns ranked results, and there are several built for exactly this purpose. Some are designed specifically to feed language models and return clean, readable content alongside each result. Others are general web-search APIs. For our purposes, the only thing that matters is that you send a query and get back a list of results with URLs and titles. Here’s the search step.

def search_web(query, num_results=4):    resp = requests.post(        "https://api.tavily.com/search",        json={            "api_key": SEARCH_API_KEY,            "query": query,            "max_results": num_results,            "search_depth": "basic",        },        timeout=30,    )    resp.raise_for_status()    data = resp.json()    results = []    for item in data.get("results", []):        results.append({            "title": item.get("title", ""),            "url": item.get("url", ""),            "snippet": item.get("content", ""),        })    return results

The key design decision here is that the rest of the program only needs a list of results with URLs back from this function. That means the search provider is completely swappable. If you want to change which search service you use, you rewrite this one function and touch nothing else. That kind of clean seam, where one component can be replaced without disturbing the others, is worth building in deliberately, because you’ll want to experiment with different search backends and different models as you go.

How many results should you read. Three to five is a sensible default. More sources give the model a fuller picture, but each one costs tokens and time, so there’s a balance. Start with four.

The search results come with short snippets, but snippets are thin. To get a real answer, you want the actual content of the pages. So you fetch each result and extract its readable text.

def fetch_sources(results):    sources = []    for i, r in enumerate(results, start=1):        text = r["snippet"]  # fallback if the page will not load        try:            resp = requests.get(r["url"], timeout=15,                headers={"User-Agent": "Mozilla/5.0 (answer-engine example)"})            if resp.ok and resp.text:                extracted = extract_text(resp.text)                if len(extracted) > 200:                    text = extracted        except requests.RequestException:            pass  # keep the snippet fallback        sources.append({            "id": i, "title": r["title"], "url": r["url"],            "text": text[:PER_SOURCE_CHARS],        })    return sources

Two things in here matter more than they look. First, the fallback. Web pages fail to load all the time, they time out, they block you, they return junk. So if a fetch fails, the code falls back to the search snippet rather than crashing. One bad URL should never sink the whole answer, and building in that resilience from the start saves a lot of frustration. Second, the length cap, that PER_SOURCE_CHARS trim at the end. A small model has a limited context window, and even a large one costs more the more you feed it, so you keep a bounded slice of each page rather than dumping entire articles into the prompt. Trimming each source to a couple of thousand characters keeps the whole thing affordable and fast while still giving the model plenty to work with.

The extraction itself, turning messy HTML into plain text, can be as simple or as fancy as you like. Here is a deliberately simple version that strips scripts and styles, removes tags, and collapses whitespace.

def extract_text(html):    html = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", html, flags=re.S | re.I)    text = re.sub(r"<[^>]+>", " ", html)    text = re.sub(r"&[a-z]+;", " ", text)    text = re.sub(r"\s+", " ", text)    return text.strip()

This is the piece most worth upgrading later, a real readability library will pull out the main article and drop navigation and ads far better than a regex will. But this simple version works, and it keeps the example dependency-light so you can see the whole thing clearly.

This is the heart of the entire project, the step that turns a pile of web text into a trustworthy answer. You give the model the numbered sources and instruct it, in no uncertain terms, to answer using only those sources and to cite them inline.

def build_prompt(question, sources):    blocks = []    for s in sources:        blocks.append(f"[{s['id']}] {s['title']}\n{s['text']}")    context = "\n\n".join(blocks)    system = (        "You are a research assistant. Answer the user's question using ONLY the "        "numbered sources provided. Cite every claim inline using [n] that refers "        "to the source number. If the sources do not contain the answer, say so "        "plainly rather than guessing. Keep the answer concise and factual."    )    user = f"Sources:\n{context}\n\nQuestion: {question}\n\nAnswer with inline [n] citations:"    return system, user

Read that system prompt closely, because every clause is doing a job. “Using ONLY the numbered sources” stops the model from answering out of its own memory, which is what keeps the answer current and grounded rather than a possibly-stale recollection. “Cite every claim inline using [n]” is what produces those little numbered references and, more importantly, makes the answer checkable, a reader can click through and verify. And “if the sources do not contain the answer, say so plainly rather than guessing” is the instruction that fights hallucination directly, giving the model explicit permission to admit the sources came up short instead of inventing something plausible. That last clause matters enormously, because a confident wrong answer is far worse than an honest “the sources do not say.”

This is the real difference between an answer engine and a chatbot. A chatbot answers from what it absorbed in training. An answer engine answers from sources you just put in front of it and points at them. The prompt is where that discipline is enforced, which is why this small function, not the search or the fetching, is the actual core of the system.

With the three steps written, the whole flow is short enough to read at a glance.

def answer(question):    results = search_web(question)          # step 1: search    sources = fetch_sources(results)        # step 2: read    system, user = build_prompt(question, sources)    reply = generate_answer(system, user)   # step 3: answer from sources    print(reply)    for s in sources:                       # show the citations        print(f"[{s['id']}] {s['title']} - {s['url']}")

The generate_answer function is just a standard call to a chat model, passing the system and user prompts and returning the text. Point it at whatever model you like, most providers speak the same request shape, so you can switch models by changing a URL and a model name. Run the file with a question and it searches, reads, thinks, and prints an answer with a numbered source list underneath. That's a working answer engine.

The version above works, but the gap between “works” and “good” is where the interesting engineering lives. Here’s where the effort goes, in rough order of payoff.

Query rewriting is the highest-value addition. Users ask questions the way they talk, but that’s often not the best thing to type into a search engine. Before searching, you send the raw question to the model and ask it to produce a cleaner search query, or several. A vague question becomes two or three sharp queries, and the quality of everything downstream jumps, because better search results are the foundation the whole answer is built on.

Reranking is the next step. When you fetch several pages, not all of the text is equally relevant. A reranking step scores the fetched passages against the question and keeps only the most relevant ones to feed the model. This means you can search more broadly, pull more sources, and still hand the model only the best material, which improves the answer and controls token cost at the same time.

Streaming is the polish that makes it feel real. Instead of waiting for the whole answer, you stream the model’s output token by token so it appears live, the way the production tools do. It changes nothing about correctness but everything about how responsive the thing feels.

And better extraction, swapping the simple regex stripper for a real readability library, quietly improves everything, because cleaner source text means the model spends its attention on real content instead of menu items and cookie banners.

None of these change the three-step spine. They make each step sharper. That’s the pattern with answer engines, the architecture is simple and stable, and the quality comes from executing each of the three moves well.

Building this yourself pays off in a way that goes beyond having your own answer engine. It demystifies a category of tool that looks like magic from the outside. Once you’ve written the loop, every answer engine you use afterward is legible, you can see the search step, you can guess where the citations come from, you understand why it sometimes says it couldn’t find something. That understanding is genuinely useful whether you ever ship your own or not.

It’s also a real, working foundation. The hundred-line version is a starting point you can grow in whatever direction you need, a research tool for a specific domain, an internal assistant that searches your own documents instead of the web, a specialized engine that only trusts certain sources. The three-step architecture is the same whether you are searching the open web or a private knowledge base, so what you learn here transfers directly.

The larger lesson is that a lot of what looks like frontier AI product magic is, underneath, a small amount of well-arranged plumbing around a model, with a carefully written prompt doing the crucial work. The answer engine is a perfect example. The model is powerful, but the thing that makes it trustworthy, answering from fresh sources and citing them, is a design decision you can implement in a few lines. Build the simple version, run it on a real question, and watch it search, read, and answer. Then start making each step better. The whole field feels different once you’ve built one of its signature tools with your own hands.

Here is the complete program in one piece, so you can copy it straight out, add a search key and a model key, and run it. It is the same code from the sections above, assembled into a single runnable file.

"""A minimal answer engine: ask a question, it searches the web, reads the topresults, and writes a short answer with numbered citations back to the sources.Setup:    pip install requests    export SEARCH_API_KEY="your-search-key"    export LLM_API_KEY="your-model-key"    python answer_engine.py "what is retrieval augmented generation?""""import osimport reimport sysimport textwrapimport requestsSEARCH_API_KEY = os.environ.get("SEARCH_API_KEY", "")LLM_API_KEY = os.environ.get("LLM_API_KEY", "")NUM_SOURCES = 4          # how many search results to readPER_SOURCE_CHARS = 2000  # how much of each page to keepdef search_web(query, num_results=NUM_SOURCES):    """Call a search API, return a list of {title, url, snippet}."""    resp = requests.post(        "https://api.tavily.com/search",        json={            "api_key": SEARCH_API_KEY,            "query": query,            "max_results": num_results,            "search_depth": "basic",        },        timeout=30,    )    resp.raise_for_status()    results = []    for item in resp.json().get("results", []):        results.append({            "title": item.get("title", ""),            "url": item.get("url", ""),            "snippet": item.get("content", ""),        })    return resultsdef extract_text(html):    """Rough HTML to plain text: strip scripts/styles/tags, collapse space."""    html = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", html, flags=re.S | re.I)    text = re.sub(r"<[^>]+>", " ", html)    text = re.sub(r"&[a-z]+;", " ", text)    text = re.sub(r"\s+", " ", text)    return text.strip()def fetch_sources(results):    """Download each result, attach extracted text, fall back to snippet."""    sources = []    for i, r in enumerate(results, start=1):        text = r["snippet"]        try:            resp = requests.get(                r["url"], timeout=15,                headers={"User-Agent": "Mozilla/5.0 (answer-engine example)"},            )            if resp.ok and resp.text:                extracted = extract_text(resp.text)                if len(extracted) > 200:                    text = extracted        except requests.RequestException:            pass        sources.append({            "id": i, "title": r["title"], "url": r["url"],            "text": text[:PER_SOURCE_CHARS],        })    return sourcesdef build_prompt(question, sources):    """Hand the model numbered sources and force inline [n] citations."""    context = "\n\n".join(f"[{s['id']}] {s['title']}\n{s['text']}" for s in sources)    system = (        "You are a research assistant. Answer the user's question using ONLY the "        "numbered sources provided. Cite every claim inline using [n] that refers "        "to the source number. If the sources do not contain the answer, say so "        "plainly rather than guessing. Keep the answer concise and factual."    )    user = f"Sources:\n{context}\n\nQuestion: {question}\n\nAnswer with inline [n] citations:"    return system, userdef generate_answer(system, user):    """Send the prompt to a chat model (OpenAI-compatible shape)."""    resp = requests.post(        "https://api.openai.com/v1/chat/completions",        headers={            "Authorization": f"Bearer {LLM_API_KEY}",            "Content-Type": "application/json",        },        json={            "model": "gpt-4.1-mini",            "messages": [                {"role": "system", "content": system},                {"role": "user", "content": user},            ],            "temperature": 0.2,        },        timeout=60,    )    resp.raise_for_status()    return resp.json()["choices"][0]["message"]["content"]def answer(question):    print(f"\nQuestion: {question}\n")    results = search_web(question)    if not results:        print("No search results found.")        return    sources = fetch_sources(results)    system, user = build_prompt(question, sources)    reply = generate_answer(system, user)    print("=" * 60)    print(textwrap.fill(reply, width=60))    print("=" * 60)    print("\nSources:")    for s in sources:        print(f"  [{s['id']}] {s['title']}")        print(f"      {s['url']}")if __name__ == "__main__":    q = " ".join(sys.argv[1:]) or "What is retrieval augmented generation?"    answer(q)

That is the whole thing. Add your two keys, run it with a question, and it searches, reads, thinks, and answers with cited sources. From there, start layering in query rewriting, reranking, and streaming, and you are building your way toward the real thing.

The example is a minimal, educational build meant to make the architecture clear, not a production system. Search API and model choices, along with their pricing and terms, change often, so check current provider documentation before building on top of this. The citation prompt reduces but does not eliminate the chance of an inaccurate answer, so treat the output like any AI-generated research, useful and worth verifying against the cited sources.

Build Your Own Perplexity in About a Hundred Lines of Python was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @perplexity 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/build-your-own-perpl…] indexed:0 read:14min 2026-07-17 ·