Return Verbatim Evidence Highlights in Web Research Answers
ArticleBuild an evidence step that picks supporting passages, checks every quote against the source page, adds the URL and retrieval time and refuses to answer when the proof is missing.
An answer with a source link is better than an answer without one. But a link alone still makes the reader do the work. They have to open the page, scroll and hunt for the sentence that supports the claim. Sometimes that sentence is not there at all.
Evidence highlights fix this. Next to each answer, you show the exact sentence from the page, word for word, with a link that jumps to it. The reader can check the answer in seconds.
The catch is the phrase "word for word." Models are good at paraphrasing, and a paraphrased quote looks just like a real one. So in this tutorial, you will build an evidence step that:
- Picks supporting passages from the page text
- Checks every quote against the source, character by character
- Attaches the URL, retrieval time and a link to the passage
- Refuses to answer when the evidence does not hold up This all happens in your application, after MrScraper returns the page text.
What the output looks like
Here is the shape you are building toward:
{
"question": "What is the default page load timeout?",
"answer": "The default page load timeout is 300 seconds.",
"abstained": false,
"evidence": [
{
"quote": "Maximum seconds to wait for the page to load.",
"url": "https://docs.example.com/api/scrape",
"retrieved_at": "2026-10-19T04:12:30+00:00",
"start": 1834,
"end": 1879,
"link": "https://docs.example.com/api/scrape#:~:text=Maximum%20seconds%20to%20wait%20for%20the%20page%20to%20load."
},
{
"quote": "The default is 300.",
"url": "https://docs.example.com/api/scrape",
"retrieved_at": "2026-10-19T04:12:30+00:00",
"start": 1880,
"end": 1899,
"link": "https://docs.example.com/api/scrape#:~:text=The%20default%20is%20300."
}
]
}
The start and end numbers mark where the quote sits in the saved page text. That is what lets your interface highlight it.
Get the page text
The MrScraper Scrape Markdown endpoint returns a page as Markdown. Save it with the URL and the time you fetched it. Every check in this guide runs against that saved copy.
import os
import requests
from datetime import datetime, timezone
MRSCRAPER_URL = "https://api.mrscraper.com/"
TOKEN = os.environ["MRSCRAPER_API_TOKEN"]
def fetch_page(url: str) -> dict:
response = requests.post(
MRSCRAPER_URL,
params={"token": TOKEN, "markdown": "true"},
headers={"x-api-token": TOKEN, "Content-Type": "application/json"},
json={"url": url, "maxRetries": 2},
timeout=330,
)
response.raise_for_status()
return {
"url": url,
"retrieved_at": datetime.now(timezone.utc).isoformat(),
"markdown": response.json().get("markdown", ""),
}
Turn Markdown into display text
Readers see text, not Markdown. A quote like "Use the x-api-token header" will not match a page that says "Use the x-api-token header" unless you strip the formatting first. So build one plain-text version of the page and use it for everything: matching, offsets and display.
import re
FENCE = "`" * 3 # A Markdown code fence
def plain_text(markdown: str) -> str:
text = re.sub(f"{FENCE}.*?{FENCE}", " ", markdown, flags=re.S) # Drop code blocks
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", text) # Drop images
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # Keep link text
text = re.sub(r"(\*\*|__|\*|_|`)(.+?)\1", r"\2", text) # Drop emphasis marks
text = re.sub(r"^\s{0,3}(#{1,6}|[-*+]|\d+\.|>)\s+", "", text, flags=re.M)
text = text.replace("|", " ")
return re.sub(r"[ \t]+", " ", text).strip()
This drops code blocks to keep the example short. If your questions are about code, keep them as their own passages instead.
How do you select supporting passages?
The safest way to get a verbatim quote is to never let the model write one. Instead, split the page into numbered passages and ask the model to pick passage IDs. Your code then pulls the exact text by ID.
Split the text into passages with offsets
def passages(text: str) -> list[dict]:
out = []
for i, m in enumerate(re.finditer(r"[^.!?\n]+[.!?]?", text)):
sentence = m.group().strip()
if len(sentence) < 12:
continue
start = text.index(sentence, m.start())
out.append({"id": f"p{i}", "text": sentence, "start": start, "end": start + len(sentence)})
return out
Each passage records its start and end position in the plain text. You will need those later for highlighting.
Narrow the list before asking the model
A long page can have hundreds of passages. Send the model a short list of likely ones. A simple word overlap score is enough for a first cut. You can swap in embeddings or a reranker later.
STOP = {"the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "is", "are", "how",
"what", "does", "do", "can", "it", "be", "by", "with", "this", "that", "long", "much"}
def words(text: str) -> set:
return {w for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in STOP}
def candidates(question: str, items: list[dict], keep: int = 8) -> list[dict]:
q = words(question)
scored = [(len(q & words(p["text"])), p) for p in items]
return [p for score, p in sorted(scored, key=lambda x: -x[0])[:keep] if score > 0]
Ask the model to answer and cite by ID
choose_passages is your own model call. Send it the question and the candidate passages with their IDs. Ask for a short answer and the IDs that support it, in JSON.
Answer the question using only the passages below.
Return JSON: {"answer": "...", "passage_ids": ["p12"]}
If no passage supports an answer, return {"answer": null, "passage_ids": []}.
Question: What is the default page load timeout?
[p12] Maximum seconds to wait for the page to load.
[p13] The default is 300.
[p40] Returns the raw HTML of the page.
Because the model returns IDs, not text, it cannot invent a quote. The worst it can do is pick the wrong passage. The next step catches that too.
How do you verify a quote against source text?
Even with IDs, you should verify. Models can return IDs that were not in the list, or answers that say more than the passages do. And if some part of your system does produce quote text, such as an extraction prompt that asks for quotes, you need a strict check for that as well.
Rule 1: The quote must appear exactly
"Exactly" means the same characters in the same order. The only thing you relax is spacing, since line breaks and double spaces often change between systems. Case and punctuation must match.
def find_exact(quote: str, text: str) -> tuple[int, int] | None:
pattern = r"\s+".join(re.escape(part) for part in quote.split())
m = re.search(pattern, text)
return (m.start(), m.end()) if m else None
This returns the quote's position in the page text, or None if it is not there. Do not fall back to fuzzy matching for display. A "close enough" quote is a paraphrase, and showing it as a verbatim highlight would mislead the reader. Fuzzy matching is fine for debugging, but never for what users see.
Rule 2: Key facts in the answer must appear in the evidence
An answer can quote a real passage and still say something the passage does not. For example, the passage says "up to 300 seconds" and the answer says "up to 30 minutes."
A simple check catches many of these: every number in the answer must appear in at least one evidence passage.
def numbers(text: str) -> set:
return set(re.findall(r"\d+(?:[.,]\d+)?", text))
def answer_is_grounded(answer: str, quotes: list[str]) -> bool:
evidence_numbers = set().union(*(numbers(q) for q in quotes)) if quotes else set()
return numbers(answer) <= evidence_numbers
This check will not catch every unsupported claim. It is a floor, not a ceiling. For important answers, a person or a second model check can review the answer against the quotes.
How do you attach URL and retrieval time?
Every piece of evidence carries three things: the URL, the time it was fetched and a link straight to the passage.
Link to the exact passage
Many browsers support text fragment links. Adding #:~:text= and some text to a URL tells the browser to scroll to that text and highlight it. Characters like -, , and & have special meaning in these links, so they must be encoded.
from urllib.parse import quote as url_quote
def fragment_link(url: str, passage: str, max_words: int = 6) -> str:
def enc(s: str) -> str:
return url_quote(s, safe="").replace("-", "%2D")
parts = passage.split()
if len(parts) <= max_words * 2:
target = enc(passage)
else:
target = enc(" ".join(parts[:max_words])) + "," + enc(" ".join(parts[-max_words:]))
return f"{url.split('#')[0]}#:~:text={target}"
For long passages, the link uses the first few and last few words, separated by a comma. The browser highlights everything between them.
Text fragments are a convenience, not a guarantee. If the page changes, or the browser does not support them, the link just opens the page. That is why you also show the quote itself and the retrieval time.
Build each evidence item
def evidence_item(page: dict, text: str, passage_text: str) -> dict | None:
span = find_exact(passage_text, text)
if span is None:
return None
start, end = span
exact = text[start:end]
return {
"quote": exact,
"url": page["url"],
"retrieved_at": page["retrieved_at"],
"start": start,
"end": end,
"link": fragment_link(page["url"], exact),
}
The quote is always cut from the saved page text using the verified positions. Whatever the model sent, the reader sees what the page actually said.
Show the retrieval time to the reader
Display it plainly, like "Retrieved October 19, 2026." It tells the reader how fresh the evidence is, and it explains any difference if the page has changed since.
When should the application abstain?
Abstaining means returning "I could not find a supported answer" instead of an answer. It feels like a failure, but it is the feature that makes your highlights trustworthy. If the system always answers, the reader cannot tell a grounded answer from a guess.
Abstain in these cases
| Situation | Why |
|---|---|
| The page could not be fetched, or came back empty | There is nothing to check against |
| No candidate passages matched the question | The page likely does not cover it |
| The model returned no passage IDs | It found no support |
| A cited ID was not in the candidate list | The citation cannot be trusted |
| A quote failed the exact-match check | It is not what the page says |
| A number in the answer is missing from the evidence | The answer goes beyond its sources |
| The evidence is older than your freshness limit | It may no longer be true |
Put it all together
from datetime import timedelta
MAX_AGE = timedelta(days=7)
def abstain(question: str, reason: str) -> dict:
return {"question": question, "answer": None, "abstained": True,
"reason": reason, "evidence": []}
def answer_with_evidence(question: str, page: dict, choose_passages) -> dict:
if not page.get("markdown"):
return abstain(question, "page could not be read")
age = datetime.now(timezone.utc) - datetime.fromisoformat(page["retrieved_at"])
if age > MAX_AGE:
return abstain(question, "evidence is too old; fetch the page again")
text = plain_text(page["markdown"])
shortlist = candidates(question, passages(text))
if not shortlist:
return abstain(question, "no passages match the question")
reply = choose_passages(question, shortlist)
by_id = {p["id"]: p for p in shortlist}
ids = reply.get("passage_ids") or []
if not reply.get("answer") or not ids:
return abstain(question, "no supporting passage found")
if any(i not in by_id for i in ids):
return abstain(question, "answer cited a passage that was not offered")
evidence = [evidence_item(page, text, by_id[i]["text"]) for i in ids]
if any(e is None for e in evidence):
return abstain(question, "a quote did not match the page text")
if not answer_is_grounded(reply["answer"], [e["quote"] for e in evidence]):
return abstain(question, "the answer includes facts not found in the evidence")
return {"question": question, "answer": reply["answer"], "abstained": False,
"evidence": evidence}
Make abstaining useful
An abstention should still help the reader. Show the reason in plain words, and link to the page you checked. "I could not find this on the API docs page (checked October 19, 2026)" is far more useful than a confident wrong answer.
Track your abstention rate. If it is very high, your page list or your passage search may be the problem. If it is close to zero, check that your rules are actually running.
Show the highlight in your interface
With start and end, you can show the quote inside its surrounding text. Escape the page text first, then wrap the quote in a <mark> tag.
from html import escape
def highlight_html(text: str, start: int, end: int, context: int = 120) -> str:
before = text[max(0, start - context):start]
after = text[end:end + context]
return f"…{escape(before)}<mark>{escape(text[start:end])}</mark>{escape(after)}…"
Escaping matters. Page text is untrusted, and inserting it into your HTML without escaping could let a page inject markup into your app.
Hand off to your application
Store each answer with its evidence list, not just the final text. When a reader reports a wrong answer, you can see exactly which page, which version and which passage it came from.
FAQs
How do you select supporting passages?
Split the page text into numbered passages with their positions. Narrow them to a short list with a simple relevance score. Then ask the model to answer and cite passage IDs, not quote text. Your code pulls the exact passage text by ID, so the model never writes the quote itself.
When should the application abstain?
Abstain when the page could not be read, when no passage supports an answer, when a citation or quote fails verification, when the answer includes numbers the evidence does not contain or when the evidence is too old. Show the reason and the page you checked.
Which parts does MrScraper provide, and which parts must my application implement?
MrScraper is a developer-focused web data API. In this guide, it fetches each page and returns clean Markdown. You can also cap retries and tokens per request, and each response reports token usage.
Your application builds the evidence step. This includes the plain-text conversion, passage splitting, the shortlist, the model call, exact-match checks, number checks, text fragment links, abstention rules and the highlight display.
Summarize this post
Open it in your assistant of choice with the prompt ready to send.
Take a Taste of Easy Scraping!
Find more insights here

Build an AI Agent That Finds Contradictions Across Website Pages
Build a workflow that pulls pricing, plan and policy claims from your own website, flags pages that…

Benchmark Heading-Aware and Semantic Chunking for Web Documentation
Run a fair test of heading-aware and semantic chunking on the same documentation pages, with a retri…

How to Build a Real-Time Amazon Price Monitor for Enterprise Intelligence
Learn how to build a real-time Amazon price monitor using a website scraping tool. Scalable retail i…