How to Add Web Search to an AI Workflow or RAG Pipeline (Python, 2026)
To add web search to an AI workflow or RAG pipeline in Python, fetch search results with their page content, chunk the pages, keep the best chunks and pass them to your model as numbered sources. With Serpex that's one POST request with include_content: true and content_results: 5, costing at most 3 credits per question: 24 a month at 10,000 questions and 240 at 100,000, at list rates as of October 2026. Page content arrives for roughly 70% of the results you ask for, so the script falls back to snippets for the rest.
What do you need before you start?
- Python 3.8 or newer, and
pip install requests. - A Serpex API key from app.serpex.dev. New accounts get 200 free credits with no card, enough for 66 questions at 3 credits each (66 × 3 = 198).
- The key in an environment variable:
export SERPEX_API_KEY=sk_.... - Any LLM. The model call is one function you fill in.
How do you search and get page content in one call?
"""Live web search for a RAG pipeline, using the Serpex Search API.Needs: pip install requestsRun: SERPEX_API_KEY=sk_... python rag_example.py"""import mathimport osimport reimport timefrom collections import Counterfrom datetime import dateimport requestsSERPEX_URL = "https://api.serpex.dev/api/search"API_KEY = os.environ["SERPEX_API_KEY"]RETRYABLE = {429, 500, 503} # safe to retry; errors are never chargedSTOPWORDS = {"a", "an", "and", "are", "did", "does", "for", "how", "in", "is", "of","on", "or", "the", "to", "what", "when", "which", "who", "why"}
def search_with_content(query: str, content_results: int = 5) -> dict:"""One search that also returns the top results' pages as Markdown."""headers = {"Authorization": f"Bearer {API_KEY}"}payload = {"q": query, "include_content": True, "content_results": content_results}for attempt in range(4): # the first try plus up to 3 retriesresp = requests.post(SERPEX_URL, headers=headers, json=payload, timeout=60)if resp.status_code in RETRYABLE and attempt < 3:time.sleep(2 ** attempt) # wait 1 s, 2 s, then 4 scontinueresp.raise_for_status()return resp.json()
content_results must be exactly 5 or 10; any other value gets a 400. The loop retries 429, 500 and 503 responses up to three times with backoff; errors are never charged. Every field is in the Search API reference, and the Python SDK takes the same three options.
What happens when a page can't be fetched?
Each result has title, url and snippet, plus either content (the page as Markdown) or content_error (such as blocked, timeout or robots-disallowed), never both. Content is best-effort: in practice it arrives for roughly 70% of requested results, so 5 requested pages give about 3.5 on average. Some pages are always blocked or disallowed by robots.txt. collect_chunks below keeps their snippets as short, labelled sources. metadata.content_delivered counts the pages that came back, and it's the number you're billed on.
How do you chunk the Markdown?
def chunk_markdown(markdown: str, max_chars: int = 1200) -> list:"""Split Markdown on blank lines and pack the pieces into chunks of at most max_chars."""pieces = []for block in re.split(r"\n\s*\n", markdown):block = block.strip()# Hard-split any block longer than max_chars (big tables, long lines).pieces.extend(block[i:i + max_chars] for i in range(0, len(block), max_chars))chunks, current = [], ""for piece in pieces:if current and len(current) + 2 + len(piece) > max_chars:chunks.append(current)current = pieceelse:current = f"{current}\n\n{piece}" if current else pieceif current:chunks.append(current)return chunksdef collect_chunks(results: list) -> list:"""Page content becomes Markdown chunks; a result without content keeps its snippet."""chunks = []for r in results:if r.get("content"):texts, kind = chunk_markdown(r["content"]), "page"else:# content_error, or no content requested: keep the snippet as a short source.texts, kind = [r.get("snippet") or ""], "snippet"for text in texts:if text.strip():chunks.append({"text": text,"url": r["url"],"title": r.get("title") or r["url"],"position": r.get("position", 99),"kind": kind,})return chunks
Splitting on blank lines keeps paragraphs, lists and tables whole. The 1,200-character cap stops one chunk crowding out the rest.
How do you pick the chunks that matter?
def terms(text: str) -> list:return [w for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in STOPWORDS]def score(text: str, query_terms: set) -> float:counts = Counter(terms(text))matched = [t for t in query_terms if counts[t]]# Each distinct query term found scores 1; repeats add a little (log-damped).return len(matched) + sum(math.log1p(counts[t]) for t in matched) / 10def select_chunks(question: str, chunks: list, k: int = 6, per_source: int = 2) -> list:"""Keep the k best chunks, at most per_source from any one URL."""query_terms = set(terms(question))ranked = sorted(chunks, key=lambda c: (-score(c["text"], query_terms), c["position"]))picked, used = [], Counter()for c in ranked:if used[c["url"]] < per_source:picked.append(c)used[c["url"]] += 1if len(picked) == k:breakreturn picked
This is plain keyword overlap: a chunk scores 1 for each distinct question word it contains, plus a little for repeats, and ties go to the higher-ranked result. per_source=2 stops one long page taking every slot, and six chunks cap the source text at 7,200 characters (6 × 1,200). If you run embeddings or a reranker, swap it in for score.
How do you build a prompt with numbered sources?
def build_prompt(question: str, picked: list) -> tuple:"""Number each source once and list its chunks under that number."""sources = {}for c in picked:if c["url"] not in sources:sources[c["url"]] = {"n": len(sources) + 1, "title": c["title"],"url": c["url"], "kind": c["kind"], "texts": []}sources[c["url"]]["texts"].append(c["text"])blocks = []for s in sources.values():label = " (search snippet only)" if s["kind"] == "snippet" else ""body = "\n\n[...]\n\n".join(s["texts"])blocks.append(f"[{s['n']}] {s['title']}{label}\nURL: {s['url']}\n\n{body}")prompt = (f"Today's date is {date.today().isoformat()}.\n""Answer the question using only the numbered sources below. ""Cite them inline as [1], [2] and so on. ""If the sources do not answer the question, say so.\n\n"f"Question: {question}\n\nSources:\n\n" + "\n\n---\n\n".join(blocks))return prompt, list(sources.values())
Each URL gets one number with all its chunks under it, so [2] always points to one page. The date goes in because questions like "what changed this year" depend on it.
Where does the LLM call go?
def ask_llm(prompt: str) -> str:# Your model call goes here: send `prompt` to your LLM and return its reply.# This stub returns the first 500 characters so the script runs without one.return prompt[:500]def main() -> None:question = "What changed in the EU AI Act in 2026?"data = search_with_content(question)meta = data.get("metadata", {})print(f"Pages with content: {meta.get('content_delivered')}/{meta.get('content_requested')}, "f"credits used: {meta.get('credits_used')}")results = data.get("results", [])if not results:print(data.get("message", "No results found."))returnfor r in results:if r.get("content_error"):print(f" no content for {r['url']}: {r['content_error']}")picked = select_chunks(question, collect_chunks(results))prompt, sources = build_prompt(question, picked)print("\nSources:")for s in sources:print(f"[{s['n']}] {s['title']} - {s['url']}")print("\nAnswer:")print(ask_llm(prompt))if __name__ == "__main__":main()
Replace the body of ask_llm with your model call and return the reply text. The six blocks in order are the complete rag_example.py. Run SERPEX_API_KEY=sk_... python rag_example.py to see the credits used, sources and answer. For an agent, wrap search_with_content as a tool, as in the LangChain and LlamaIndex guides.
What does it cost per question, and per month?
With include_content on, you pay for pages delivered, not pages requested: 0 pages costs 1 credit (the plain-search rate), 1 to 5 pages cost 3 credits, and 6 to 10 cost 6. At roughly 70% delivery, 5 requested pages give about 3.5 (5 × 0.7), inside the 1-to-5 band, so nearly every question costs 3 credits. Credits cost $0.50 per 1,000 at the Scale rate (top-ups totalling $500 to $1,999) and $0.80 at the Starter rate ($5 to $49).
| Questions | With 5 pages, Scale | With 5 pages, Starter | Plain search, Scale | Plain search, Starter |
|---|---|---|---|---|
| 1,000 | 3,000 credits = $1.50 | $2.40 | 1,000 credits = $0.50 | $0.80 |
| 10,000 a month | 30,000 credits = $15.00 | $24.00 | 10,000 credits = $5.00 | $8.00 |
| 100,000 a month | 300,000 credits = $150.00 | $240.00 | 100,000 credits = $50.00 | $80.00 |
The working is credits × rate ÷ 1,000: 300,000 × $0.50 ÷ 1,000 = $150, and 300,000 × $0.80 ÷ 1,000 = $240. Standard ($0.65, top-ups of $50 to $499) gives $19.50 and $195.
Pages that don't arrive aren't charged: if none comes back you pay 1 credit, and if you ask for 10 and get 5 you pay 3, not 6. With content_results: 10 the ceiling is 6 credits, $3.00 (Scale) to $4.80 (Starter) per 1,000 questions. Errors cost nothing, your organization's own repeat of a query within 5 minutes costs 0 credits, and your LLM provider bills the model call separately.
How long does a search take?
Across about 720,000 live (non-cached) searches from 12 to 19 October 2026, a median of 2.3 seconds and a 90th percentile of 6.7 seconds. That's our own measurement over that window, not a guarantee; time your calls with metadata.response_time (milliseconds).
When should you skip page content?
Snippets are often enough when the user wants a link, a check on whether something exists, or a fact in a result's first line, and when you're only routing or classifying a query. Drop include_content and a search costs 1 credit instead of 3.
You can also search plain first and fetch pages only when the snippets fall short. An escalated question costs 1 + 3 = 4 credits, because different content options make it a new search. If a share p of questions escalate, the average is 1 + 3p credits, which beats always fetching (3 credits) while p is below two thirds: 1 + 3p < 3 when p < 2/3.
FAQ
What is the best search API for a RAG pipeline?
Prices checked 1 October 2026 on Tavily's credits page and Exa's pricing page: Tavily charges $8.00 per 1,000 searches pay as you go, with optional raw page content and no extra credit charge listed. Exa charges $7 per 1,000 Fast or Auto searches plus $1 per 1,000 pages per content type, so 5 pages a question is $7 + $5 = $12 per 1,000. Serpex is $1.50 to $2.40 per 1,000 with 5 pages. Tavily also generates answers and has mature LangChain and LlamaIndex integrations.
How do I integrate SERP data into an AI-powered workflow?
Search at question time, take the page content or snippets, chunk them, keep the best chunks, and pass them to the model as numbered sources it must cite.
Do I pay for pages that fail to load?
No. A failed fetch returns content_error and isn't billed; if no page arrives, the search costs the plain 1 credit.