如何使用 Scrapeless 和 Ollama 构建本地 RAG 管道
Expert in Web Scraping Technologies
TL;DR:
- 整个循环在本地运行,无需嵌入提供者或大语言模型提供者的API密钥。 Scrapeless是此流程中的唯一付费调用——提取源页面。分块、嵌入、向量存储、检索和答案生成都在这台机器上运行。
- 检索是经过验证的,而不是假设的。 关于两个不同抓取对象的两个问题,各自从48个块中提取出正确的人的传记块,跨越5位作者——下方捕获的实际相似度距离,而不是主张。
- 生成步骤是本地的实时调用,而不是模板答案。 494M参数的Ollama模型仅读取已检索的块并正确回答,基于几分钟前抓取的文本。
- 分块和嵌入故意保持较小。 每个传记变成4-14个重叠的60字块,转化为384维向量,在一次批量本地调用中使用
sentence-transformers——不需要GPU。 - 这是两个其他Scrapeless流程的下游部分。 如果您已经拥有干净的块或OpenAI嵌入索引,请跳到下面的第3阶段或第5阶段——不同之处将在内联中指出。
- 免费开始。 在app.scrapeless.com的免费计划中创建您的Scrapeless API密钥。
流程一览
提升检索的生成,正如原始RAG论文中定义的那样,将外部语料库上的检索器与读取检索器发现内容的生成器配对。大多数对此概念的操作过程停留在其中一部分。而这个则整体构建了两个部分,从头到尾,针对真实的抓取页面:
- 抓取——通过Scrapeless通用抓取API提取固定的一组作者页面的渲染HTML。
- 提取和分块——将每个页面解析为传记文本,分割成带有来源的重叠词窗口。
- 嵌入——使用
sentence-transformers将每个块转化为本地向量。 - 存储——将向量及其源元数据持久保存到本地Chroma集合中。
- 检索——嵌入一个问题,询问Chroma最近的块,并检查是否返回了正确的人。
- 生成——将检索到的块交给本地Ollama模型,获得一个基于文本的答案,整个过程中没有任何云大语言模型的调用。
另外两篇Scrapeless的帖子已经涵盖了这个过程的部分内容。网络文本摄取指南抓取、提取并分块为corpus.jsonl,然后故意停止:嵌入是“下游的,使用您已经使用的任何堆栈。” LLM文本流程指南走得更远:发现、提取、分块,并使用OpenAI嵌入到Chroma中。但它在被嵌入的记录处停止——没有任何查询存储或生成答案的操作——并且需要付费的OPENAI_API_KEY。本指南是之前两个指南都未涵盖的部分:本地嵌入且无需嵌入提供者密钥,一个实时证明检索找到正确源的过程,以及一个实际回答问题的本地模型。(关于RAG本身的概念而非构建,请参见什么是检索增强生成。)
前提条件
- Python 3.10或更高版本。
- 一个Scrapeless API密钥,导出为
SCRAPELESS_API_KEY——抓取阶段是唯一需要它的阶段。 - Ollama已安装并运行,并拉取了一个小模型:
ollama pull qwen2.5:0.5b。 pip install sentence-transformers chromadb beautifulsoup4 requests。
安装
sentence-transformers会调用PyTorch,因此第一次安装需要几分钟和大约一个GB的磁盘空间。此后的一切——模型加载、嵌入和查询——在模型缓存后离线运行。
bash
pip install sentence-transformers chromadb beautifulsoup4 requests
ollama pull qwen2.5:0.5b
export SCRAPELESS_API_KEY="sk_your_scrapeless_key"
第1阶段——抓取源页面
以下是该文本的中文翻译:
此次演练的语料库是来自quotes.toscrape.com的五位作者传记页面,这是一个为抓取练习而建立的公共网站:阿尔伯特·爱因斯坦、简·奥斯汀、玛丽莲·梦露、J.K. 罗琳和托马斯·爱迪生。五个不同的人,具有五个真正不同的出生地、出生日期和职业,这使得第五阶段的检索检查具有意义——关于其中一位的问题只有一个正确来源,而不是五个似乎合理的来源。
每个页面向通用抓取 API 的请求返回响应中的渲染 HTML,data 字段包含该内容:
python
# fetch.py -- 通过 Scrapeless 通用抓取 API 拉取渲染的作者传记页面
import os
import pathlib
import requests
ENDPOINT = "https://api.scrapeless.com/api/v2/unlocker/request"
HEADERS = {
"Content-Type": "application/json",
"x-api-token": os.environ["SCRAPELESS_API_KEY"],
}
SOURCE_HOST = "https://quotes.toscrape.com"
AUTHORS = ["Albert-Einstein", "Jane-Austen", "Marilyn-Monroe", "J-K-Rowling", "Thomas-A-Edison"]
pathlib.Path("pages").mkdir(exist_ok=True)
for slug in AUTHORS:
url = f"{SOURCE_HOST}/author/{slug}"
resp = requests.post(
ENDPOINT,
headers=HEADERS,
json={"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "redirect": True}},
timeout=120,
)
resp.raise_for_status()
html = resp.json()["data"]
pathlib.Path("pages", f"{slug}.html").write_text(html, encoding="utf-8")
print(f"{slug}: {len(html):,} bytes")
这些页面在服务器端渲染,没有客户端 JavaScript 构建传记,因此 js_render 保持为 False——解锁器依然处理请求并返回页面,但对于已在初始 HTML 中的内容,不需要支付渲染成本。实时运行返回五个不同的字节计数,每位作者一个:
text
Albert-Einstein: 5,329 bytes
Jane-Austen: 3,413 bytes
Marilyn-Monroe: 3,663 bytes
J-K-Rowling: 5,347 bytes
Thomas-A-Edison: 2,648 bytes
在免费计划中获取您的 API 密钥:app.scrapeless.com
第二阶段 — 提取和分块
每个页面都带有相同的三个字段,固定类为:author-title、author-born-date、author-born-location 和 author-description。使用 BeautifulSoup 提取这些信息,开头的传记使用明确的出生句子,使事实单独作为一个块锚定,然后以重叠的 60 个单词窗口进行拆分,每次重叠 15 个单词:
python
# chunk_corpus.py -- pages/*.html -> corpus.jsonl (带来源的重叠单词窗口块)
import json
import pathlib
import re
from bs4 import BeautifulSoup
CHUNK_WORDS = 60
OVERLAP_WORDS = 15
def extract_author(html: str) -> dict:
soup = BeautifulSoup(html, "html.parser")
name = soup.find("h3", class_="author-title").get_text(strip=True)
born_date = soup.find("span", class_="author-born-date").get_text(strip=True)
born_loc = soup.find("span", class_="author-born-location").get_text(strip=True)
desc = soup.find("div", class_="author-description").get_text(" ", strip=True)
return {"name": name, "born_date": born_date, "born_location": born_loc,
"description": re.sub(r"\s+", " ", desc)}
def chunk(words: list[str]):
step = CHUNK_WORDS - OVERLAP_WORDS
for start in range(0, max(len(words) - OVERLAP_WORDS, 1), step):
yield start, " ".join(words[start:start + CHUNK_WORDS])
total = 0
with open("corpus.jsonl", "w", encoding="utf-8") as out:
for page in sorted(pathlib.Path("pages").glob("*.html")):
author = extract_author(page.read_text(encoding="utf-8"))
lead = f"{author['name']} was born {author['born_date']} {author['born_location']}."
words = f"{lead} {author['description']}".split()
for start, body in chunk(words):
out.write(json.dumps({"id": f"{page.stem}-{start}", "source": page.stem,
"author": author["name"], "word_offset": start,
"text": body}) + "\n")
total += 1
print(f"{total} chunks -> corpus.jsonl")
对五个获取的页面的实时运行产生了 48 个块,按每个传记实际内容的多少不均匀分配:
text
阿尔伯特·爱因斯坦: 615 words -> 14 chunks
J.K. 罗琳: 644 words -> 14 chunks
简·奥斯汀: 327 words -> 7 chunks
玛丽莲·梦露: 376 words -> 9 chunks
托马斯·爱迪生: 195 words -> 4 chunks
48 chunks -> corpus.jsonl
60 个单词和 25% 的重叠是一个小语料库设置,故意比上述链接中 220/40 字的窗口设置更紧凑——这些传记每个字数在几百个字左右,而不是指导块中的几千字文章。根据您的源长度调整窗口,而不是固定的默认值。
第三阶段 — 本地嵌入
每个块都变成一个384维的向量,使用all-MiniLM-L6-v2,这是一个紧凑的句子嵌入模型,足够小,可以在CPU上运行,并与更大模型在MTEB基准套件上进行基准测试。该模型只需下载一次(大约90 MB),之后的每次编码调用均在离线状态下运行:
python
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
vectors = model.encode(texts, show_progress_bar=False).tolist()
阶段4 — 存储在本地向量数据库中
Chroma的PersistentClient将集合写入磁盘,因此索引在运行之间得以保留,而不需要服务器进行管理。每个向量保留其作者和来源页面作为元数据,这使得检索到的块可以追溯到特定页面,而不是一个匿名的文本字符串:
python
import chromadb
client = chromadb.PersistentClient(path=".chroma")
collection = client.create_collection("author_bios")
collection.add(
ids=[c["id"] for c in chunks],
documents=[c["text"] for c in chunks],
embeddings=vectors,
metadatas=[{"author": c["author"], "source": c["source"], "word_offset": c["word_offset"]}
for c in chunks],
)
针对所有48个块的实时运行确认集合包含所有被嵌入的内容:
text
嵌入了48个块,维度384,集合数量48
阶段5 — 检索正确的块
这是大多数本地RAG演练跳过的步骤:证明检索实际上返回了正确的来源,而不仅仅是返回某些东西。以与块相同的方式嵌入一个问题,然后询问Chroma关于其默认L2距离的最近邻居,其中较小的数字意味着匹配更接近:
python
query_vector = model.encode([question]).tolist()
result = collection.query(query_embeddings=query_vector, n_results=2)
top_author = result["metadatas"][0][0]["author"]
top_distance = result["distances"][0][0]
两个关于两个不同人的问题,针对同一个48块的索引,各自拉回正确的人而没有其他人的传记:
text
问:阿尔伯特·爱因斯坦出生在哪里?
顶部匹配作者:阿尔伯特·爱因斯坦 | 距离:0.6465
块:阿尔伯特·爱因斯坦于1879年3月14日出生于德国乌尔姆。在1879年,阿尔伯特·爱因斯坦出生于德国乌尔姆。他在苏黎世大学完成了他的博士学位...
问:J.K.罗琳出生在哪里?
顶部匹配作者:J.K.罗琳 | 距离:0.4566
块:J.K.罗琳于1965年7月31日出生于英格兰南格洛斯特郡耶特,联合王国。另见:罗伯特·加尔布雷斯尽管她以笔名写作...
没有任何问题从简·奥斯汀、玛丽莲·梦露或托马斯·爱迪生那里检索到块——嵌入空间将五个无关的传记分隔得很好,以至于一个人的事实问题不会意外地浮出另一个人的信息。
阶段6 — 生成有据可依的答案
检索到的块成为本地Ollama模型唯一获得的上下文。提示要求提供一个JSON字段——仅仅是答案——因为出处已经存在于阶段5的检索元数据中;要求一个小模型还要重述引用字段,就是要求它做一个向量存储已经免费完成的工作,而这不是一个可靠的额外字段,去向一个494M参数的模型请求:
python
import json
import requests
prompt = (
'在下面的上下文中仅用一句简短的句子回答问题。 '
'只回复JSON {"answer": "..."}.\n\n'
f"上下文:\n{context}\n\n问题:{question}"
)
resp = requests.post(
"http://localhost:11434/api/generate",
json={"model": "qwen2.5:0.5b", "prompt": prompt, "stream": False,
"format": "json", "options": {"temperature": 0, "num_predict": 40}},
timeout=600,
)
answer = json.loads(resp.json()["response"])
实时调用,仅用检索到的爱因斯坦块,正确地回答问题,而从未看到其他四个传记:
text
检索块的作者(来自向量存储元数据):阿尔伯特·爱因斯坦
问题:阿尔伯特·爱因斯坦出生在哪里?是哪一年?
生成的答案:阿尔伯特·爱因斯坦于1879年3月14日出生于德国乌尔姆。
这就是完整的循环:一个问题输入,向量存储将48个候选项缩小到一个相关块,并在localhost上运行的模型将该块转换为一个句子——没有OpenAI密钥,没有抓取浏览器,初始获取后没有任何信息离开机器。
完整的管道
上述每个阶段都链式连接到一个脚本中:获取五个页面,将它们分块,嵌入并存储结果,运行两个检索检查,然后生成最终答案。这里没有任何内容是伪造的或由不同会话组装的——这是同样的六个阶段,执行一次,从上到下:
python
# full_pipeline.py -- fetch -> chunk -> embed -> store -> retrieve -> generate, end to end
import json
import logging
import os
import pathlib
import re
os.environ.setdefault("HF_HOME", "/usr/local/share/hf-cache")
os.environ.setdefault("HF_HUB_OFFLINE", "1")
import chromadb
import requests
from bs4 import BeautifulSoup
from sentence_transformers import SentenceTransformer
logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)
# 阶段 1:抓取
ENDPOINT = "https://api.scrapeless.com/api/v2/unlocker/request"
HEADERS = {"Content-Type": "application/json", "x-api-token": os.environ["SCRAPELESS_API_KEY"]}
SOURCE_HOST = "https://quotes.toscrape.com"
AUTHORS = ["Albert-Einstein", "Jane-Austen", "Marilyn-Monroe", "J-K-Rowling", "Thomas-A-Edison"]
pathlib.Path("pages").mkdir(exist_ok=True)
for slug in AUTHORS:
url = f"{SOURCE_HOST}/author/{slug}"
resp = requests.post(
ENDPOINT, headers=HEADERS,
json={"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "redirect": True}},
timeout=120,
)
resp.raise_for_status()
html = resp.json()["data"]
pathlib.Path("pages", f"{slug}.html").write_text(html, encoding="utf-8")
print(f"{slug}: {len(html):,} 字节")
# 阶段 2:提取和切分
CHUNK_WORDS, OVERLAP_WORDS = 60, 15
def extract_author(html: str) -> dict:
soup = BeautifulSoup(html, "html.parser")
name = soup.find("h3", class_="author-title").get_text(strip=True)
born_date = soup.find("span", class_="author-born-date").get_text(strip=True)
born_loc = soup.find("span", class_="author-born-location").get_text(strip=True)
desc = soup.find("div", class_="author-description").get_text(" ", strip=True)
return {"name": name, "born_date": born_date, "born_location": born_loc,
"description": re.sub(r"\s+", " ", desc)}
def chunk_words(words: list[str]):
step = CHUNK_WORDS - OVERLAP_WORDS
for start in range(0, max(len(words) - OVERLAP_WORDS, 1), step):
yield start, " ".join(words[start:start + CHUNK_WORDS])
chunks = []
with open("corpus.jsonl", "w", encoding="utf-8") as out:
for page in sorted(pathlib.Path("pages").glob("*.html")):
author = extract_author(page.read_text(encoding="utf-8"))
lead = f"{author['name']} 出生于 {author['born_date']} {author['born_location']}。"
words = f"{lead} {author['description']}".split()
n_chunks = 0
for start, body in chunk_words(words):
rec = {"id": f"{page.stem}-{start}", "source": page.stem, "author": author["name"],
"word_offset": start, "text": body}
out.write(json.dumps(rec) + "\n")
chunks.append(rec)
n_chunks += 1
print(f"{author['name']}:{len(words)} 个单词 -> {n_chunks} 个块")
print(f"{len(chunks)} 个块 -> corpus.jsonl")
# 阶段 3 和 4:嵌入和存储
model = SentenceTransformer("all-MiniLM-L6-v2")
client = chromadb.PersistentClient(path=".chroma", settings=chromadb.Settings(anonymized_telemetry=False))
if "author_bios" in [c.name for c in client.list_collections()]:
client.delete_collection("author_bios")
collection = client.create_collection("author_bios")
texts = [c["text"] for c in chunks]
vectors = model.encode(texts, show_progress_bar=False).tolist()
collection.add(
ids=[c["id"] for c in chunks],
documents=texts,
embeddings=vectors,
metadatas=[{"author": c["author"], "source": c["source"], "word_offset": c["word_offset"]} for c in chunks],
)
print(f"嵌入了 {len(chunks)} 个块,维度 {len(vectors[0])},集合数量 {collection.count()}")
# 阶段 5:检索
for question in ["阿尔伯特·爱因斯坦出生在哪里?", "J.K. 罗琳出生在哪里?"]:
query_vector = model.encode([question]).tolist()
result = collection.query(query_embeddings=query_vector, n_results=2)
top_author = result["metadatas"][0][0]["author"]
top_distance = result["distances"][0][0]
top_text = result["documents"][0][0]
print(f"问: {question}")
print(f" 最佳匹配作者: {top_author} | 距离: {top_distance:.4f}")
print(f" 块: {top_text[:160]}...")
# 阶段 6:生成
question = "阿尔伯特·爱因斯坦出生在哪里,是哪一年?"
query_vector = model.encode([question]).tolist()
result = collection.query(query_embeddings=query_vector, n_results=1)
context = result["documents"][0][0]
retrieved_author = result["metadatas"][0][0]["author"]
prompt = (
"用以下上下文仅用一短句回答问题。"
'仅回复 JSON {"answer": "..."}。\n\n'
f"上下文:\n{context}\n\n问题:{question}"
)
resp = requests.post(
"http://localhost:11434/api/generate",
json={"model": "qwen2.5:0.5b", "prompt": prompt, "stream": False,
"format": "json", "options": {"temperature": 0, "num_predict": 40}},
timeout=600,
)
resp.raise_for_status()
answer = json.loads(resp.json()["response"])
```python
print("从向量存储元数据中检索到的块作者:", retrieved_author)
print("问题:", question)
print("生成的答案:", answer["answer"])
从上到下的单次运行再现了每个阶段显示的每个数字:五个字节计数,48个块,384维的48个向量集合,两个正确的检索和一个有依据的答案。
负责任的采集
传记页面描述的是现实中的人物,甚至是在为抓取而建立的练习网站上。三种做法可以在你将此模式指向生产源时,使检索语料库具有可辩护性:仅收集公共页面,并在将域添加到抓取列表之前检查每个网站的服务条款和机器人的指令;将源网址和作者名附加到每个块上,就像该管道的元数据所做的那样,以便可以追溯到生成的答案来源;以此示例中的方式处理请求量——一次五个页面,而不是对未同意进行常驻爬行的网站进行抽取。
结论
五页进去,输出一个有依据的句子,每个阶段在初始抓取之后都在这台机器上运行。检索的部分是值得信赖的部分,而生成的部分则在此之后:48个块,两个问题,两个正确的作者,以及证明这一点的距离——只有到那时,本地模型才能进行回答。更换源页面、嵌入模型或Ollama模型,结构保持不变;当内容变化时,管道并不改变。
准备构建你的RAG管道了吗?
加入在Scrapeless上构建数据管道的社区:Discord · Telegram。
在app.scrapeless.com注册以获取免费试用积分,并将第1阶段指向你自己的检索语料库所需的页面。开发者文档涵盖了unlocker.webunlocker请求格式,当前方案在定价页面上。
常见问题
问:这个管道的任何部分需要除了Scrapeless以外的云API密钥吗?
不需要。Scrapeless是唯一的付费调用,只用于第1阶段的抓取。嵌入在本地通过sentence-transformers运行,向量存储是磁盘上的Chroma文件,生成通过本地的Ollama模型运行——没有其他内容需要提供商密钥。
问:这和Scrapeless其他与RAG相关的帖子有什么不同?
覆盖,而不是重叠。摄取指南获取、提取和块,然后故意在嵌入之前停止。LLM-text管道指南使用OpenAI进行嵌入并存储在Chroma中,然后在查询或生成之前停止。本指南是完成循环的本地、零嵌入密钥部分:检索,然后生成。
问:我需要GPU吗?
不需要。all-MiniLM-L6-v2和qwen2.5:0.5b在此演示中均在CPU上运行。GPU可以加速更大的本地模型,但构建和测试管道既不需要嵌入步骤也不需要小的生成模型。
问:我该如何选择块大小?
与单个源实际说的内容相匹配。此演示中的传记每个都运行几百个单词,所以60个单词和15个单词的重叠使块大致保持为一个想法。多千字文章需要更宽的窗口——上面的摄取指南就是出于这个原因使用220个单词和40个重叠。
问:如果检索返回错误的块怎么办?
这通常意味着语料库有近乎重复的内容,嵌入模型无法分离,或者问题的措辞与源文本构词相差甚远。扩大块重叠、使用更大的模型进行嵌入,或在顶级候选者上增加第二次重新排序都能帮助;此演示中的五个不同传记故意容易区分,这使得能够展示一个干净的检索结果成为可能,而不仅仅是声称。
问:我可以更换一个更大的本地模型进行生成吗?
可以——请求格式不会更改,只有model字符串会更改。qwen2.5:0.5b在CPU上足够快以演示循环;如果硬件有足够的内存,则3B或更大的Ollama模型在混乱的上下文中回答更可靠。
问:从传记页面抓取数据并生成答案是否合法?
抓取公开可访问的页面通常是被允许的,但网站条款和适用的法律管辖权都很重要。只收集公开页面,首先检查目标网站的服务条款和机器人指令,保持抓取量在合理范围内,并按照适用的隐私法处理语料库中的任何个人数据。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



