详细解析如何用Cheerio解析网页、chunk分块、Gemini生成向量、Supabase pgvector存储,实现网站SEO审计的RAG管道,并处理SSRF、robots.txt、节流等工程挑战。
大多数 RAG 示例都是从 PDF、文档或文本文件集合开始的。
网站是另一个问题。
网站是一个相互连接的页面系统,包含 HTML、元数据、标题、链接、结构化数据、规范 URL 以及随时间变化的内容。如果 AI SEO 审计器只收到被分析页面的 HTML,它的上下文将非常有限。
在构建 AuditMe 时,我需要一个爬虫和检索层,能够将网站转换为结构化的、可搜索的上下文,供 AI 审计使用。
最终的流水线是:
URL
↓
HTTP 获取
↓
Cheerio 解析
↓
分块
↓
Gemini Embeddings
↓
Supabase pgvector
↓
余弦检索
↓
Grounded LLM Prompt
↓
结构化 JSON 审计
有趣的部分不仅仅是 RAG 组件。
爬虫还需要处理 SSRF、重定向、robots.txt、限流、JavaScript 渲染应用、重复 URL、增量刷新,以及确定性 SEO 检查与 AI 生成分析之间的差异。
本文解释该系统是如何工作的。
传统的 SEO 爬虫可以确定性检测许多问题:
这些检查不需要 LLM。
但其他问题是语义层面的:
这正是检索变得有用的地方。
不是向 LLM 提供任意数量的 HTML,而是从页面中检索最相关的块,并将它们注入审计提示中。
给模型它所需的证据,而不是让它凭空编造上下文。
当前的流水线有意做得非常直接:
+----------------+
| URL |
+-------+--------+
|
v
+----------------+
| HTTP Fetch |
+-------+--------+
|
v
+----------------+
| Cheerio Parse |
+-------+--------+
|
v
+----------------+
| Chunking |
| 1400 / 150 |
+-------+--------+
|
v
+----------------+
| Gemini |
| Embeddings |
| 768 dimensions |
+-------+--------+
|
v
+----------------+
| Supabase |
| pgvector |
| HNSW |
+-------+--------+
|
v
+----------------+
| Top-K Retrieval|
| in Node |
+-------+--------+
|
v
+----------------+
| Grounded LLM |
| Prompt |
+-------+--------+
|
v
+----------------+
| Zod Validation |
| Structured JSON |
+----------------+
这里有一个重要的实现细节:
Supabase 存储 embeddings 并提供 HNSW 索引,但当前的审计检索路径在 Node 中对页面自身的块执行暴力计算余弦相似度。
这听起来可能多余。
数据库层为向量检索做好了准备,而当前的审计路径可以从属于一个页面的相对较少数量的块中进行廉价检索。
第一阶段是 HTTP 获取。
获取的 HTML 用 Cheerio 解析。
爬虫提取的内容远超可见文本:
title
meta description
canonical
Open Graph tags
Twitter cards
H1-H3
images
internal links
external links
nofollow links
word count
JSON-LD types
hreflang
redirect chains
这产生了两种不同类型的信息。
title
canonical
status code
links
JSON-LD
hreflang
redirects
这些作为结构化数据存储和分析。
paragraphs
headings
article content
product descriptions
documentation
这些内容适合分块和 embeddings。
这种区分很重要。
我不想把每个 SEO 事实都变成向量。
Does this page have a canonical URL?
爬虫已经知道答案。
没有理由去问 embedding 模型。
爬虫从用户那里接收 URL。
这使得 SSRF 保护成为一等需求。
isSafeURL()
resolveAndValidateIP()
并验证每个重定向跳。
这很重要,因为只检查初始 URL 是不够的。
https://example.com
↓
302 redirect
↓
http://internal-host
如果爬虫只验证第一个 URL,重定向可以绕过保护。
因此,爬虫在每个重定向跳解析并验证目标。
永远不要仅仅因为原始 URL 被认为是安全的就信任重定向。
生产环境爬虫还需要考虑私有地址、回环地址、链路本地地址以及其他非公共地址范围。
爬虫遵守 robots.txt。
实现按域缓存 robots.txt 一小时。
它还使用最长模式匹配方法解析规则,而不是将第一个匹配规则视为权威。
Request URL
|
v
robots.txt cache
|
v
matching rules
|
v
longest matching pattern
|
v
Allow / Disallow
当站点声明了 Crawl-delay 时,爬虫也会遵守。
默认节流是 100ms。
这是有意保守的。
爬虫不应该把一个小型的 SEO 审计变成对目标服务器的流量峰值。
调度器使用广度优先搜索。
当前的限制是:
concurrency: 5
maxPages: 200
maxDepth: 5
简化的爬取如下:
Depth 0
|
+-- homepage
|
+-- Depth 1
|
+-- page A
+-- page B
+-- page C
|
+-- Depth 2
BFS 对 SEO 爬取很有用,因为它倾向于先发现靠近站点入口的页面,然后再深入。
调度器还需要 URL 去重。
没有它,同一个页面可以通过多个内部链接被多次访问并重复调度。
传统的 HTTP 获取对于每个现代网站来说是不够的。
单页应用最初可能返回:
<div id="root"></div>
<script src="/assets/app.js"></script>
而实际内容由 JavaScript 生成。
爬虫检测常见的 SPA 信号,例如:
#root
#app
JavaScript bundles
并可以选择回退到 Playwright 渲染。
重要的原则不是每个页面都在完整浏览器中渲染。
浏览器渲染更昂贵。
首选策略是:
HTTP fetch
|
+--> sufficient HTML?
| |
| yes
| |
| v
| parse
|
+--> likely SPA?
|
v
optional render
只在必要时使用昂贵的路径。
用 Cheerio 解析后,页面内容被规范化。
爬虫在进入 RAG 流水线之前将内容限制在 10,000 个字符。
这很重要,原因有二。
首先,非常大的页面会主导 embedding 和提示成本。
其次,大多数 SEO 审计问题不需要页面的每一个字节。
提取阶段尝试保留有意义的文本,同时避免常见的 HTML 噪音。
结果大致是:
title
meta description
h1
body content
加上单独收集的结构化页面元数据。
MAX_CHARS = 1400
OVERLAP = 150
MAX_CHUNKS = 10
它使用滑动窗口,但尽可能优先使用句子边界。
边界偏好基于:
". "
而不是盲目地在字符 1400 处切割。
这是一个小的实现细节,但有很大的实际效果。
...Google recommends descriptive page titles because
they help users understand the result...
硬字符边界可以分割一个句子。
感知句子的边界通常是一个更好的语义单元。
第一个块是特殊的。
Chunk 0 包含一个合成头部:
TITLE: ...
| meta_description
| h1
这让 embedding 表示能够立即访问页面最重要的 SEO 元数据。
TITLE: Technical SEO Guide
| Learn how to audit technical SEO
| Technical SEO Checklist
body 限制在 10,000 个字符以内。
在最大 10 个分块和当前分块大小下,实际产生的分块数通常更少。
在实践中,当前实现中达到内容上限的页面通常会产生大约八个真实的 body 分块,其中分块 0 保留给合成标题。
关键是保持检索语料库小且可预测。
使用的嵌入模型是:
gemini-embedding-001
存储的向量使用 768 维。
该模型支持 Matryoshka 式降维,允许将嵌入表示截断到所需维度。
系统每次请求批量处理最多:
96 texts per request
嵌入请求可能发生临时失败,特别是在触发速率限制时。
当前的重试策略是:
maximum attempts: 3
backoff: attempt * 2000 ms
因此重试延迟大约为:
2000 ms
4000 ms
取决于正在重试的次数。
认证通过以下方式完成:
x-goog-api-key
应用程序通过以下方式暴露 Gemini 密钥:
GOOGLE_GEMINI_API_KEY
嵌入存储在 Supabase PostgreSQL 中,使用 pgvector。
create extension if not exists vector;
create table page_embeddings (
id bigint generated by default as identity primary key,
url text not null,
chunk_index integer not null,
chunk_text text not null,
embedding vector(768),
source text not null default 'gemini-embedding-001',
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (url, chunk_index)
);
有一个纠正值得专门指出。
如果你复制了旧版本的 schema,可能会看到:
source text not null default 'gemini:text-embedding-004'
这是早期嵌入设置中的过时元数据。
当前此实现的嵌入模型是:
gemini-embedding-001
数据库 schema 应该描述实际生成向量的模型。
该表使用 HNSW 索引进行余弦相似度搜索:
create index page_embeddings_hnsw_idx
on page_embeddings using hnsw (embedding vector_cosine_ops);
当向量集合大到每次查询都要扫描每个向量时,HNSW 索引就派上用场了。
此实现中的一个重要区别是 HNSW 索引存在于数据库层,但当前审计路径并不依赖它进行正常的页面本地检索。
相反,当前流程是:
page URL
|
v
retrieve its chunks
|
v
calculate cosine similarity in Node
|
v
sort
|
v
take top 5
因为单个页面限制在少量分块中,暴力相似度计算的成本很低。
这是一个不为当前不存在的问题优化的好例子。
数据库还暴露了一个向量搜索 RPC:
create or replace function search_page_chunks(
p_url text,
p_embedding vector,
p_limit integer default 5
)
returns table(
url text,
chunk_index integer,
chunk_text text,
similarity double precision
)
language sql
stable
as $$
select
e.url,
e.chunk_index,
e.chunk_text,
1 - (e.embedding <=> p_embedding) as similarity
from page_embeddings e
where e.url = p_url
order by e.embedding <=> p_embedding
limit p_limit;
$$;
<=> 操作符是 pgvector 的余弦距离操作符。
similarity = 1 - cosine_distance
该 RPC 使得在 PostgreSQL 中进行检索成为可能,当这变得更可取时。
审计流水线设计得很窄。
1. Fetch page
2. Parse with Cheerio
3. Cap content at 10K characters
4. Chunk content
5. Generate embeddings
6. Store chunks
7. Create query from page metadata
8. Retrieve top chunks
9. Inject chunks into LLM prompt
10. Generate structured audit
11. Validate JSON with Zod
索引调用的概念形式是:
indexPageChunks(
url,
{
title,
meta_description,
h1,
content_text
}
)
页面的自身元数据成为检索查询:
[title, meta_description, h1].join(". ")
这是一个重要的设计选择。
查询不是任意用户问题。
它代表页面自身声明的主题。
当前检索函数:
topKChunks()
在 Node 中执行余弦相似度计算。
top 5 chunks
最小相似度阈值为:
0.15
因此实用规则是:
similarity > 0.15
然后取得分最高的五个分块。
该阈值不应被解释为通用的语义质量边界。
嵌入分数依赖于模型和数据集。
对某个语料库有效的阈值对另一个可能很糟糕。
正确的方法是用代表性查询评估检索效果并凭经验调整。
检索到的分块在以下标记下注入到提示中:
RETRIEVED PAGE CONTENT
因此模型接收的是从实际页面检索到的证据。
SYSTEM / AUDIT INSTRUCTIONS
PAGE METADATA
TECHNICAL SEO DATA
RETRIEVED PAGE CONTENT
----------------------
[chunk 1]
[chunk 2]
[chunk 3]
[chunk 4]
[chunk 5]
Return structured JSON.
这并不能神奇地消除幻觉。
但它改变了模型的工作方式。
它不再从孤立的提示生成 SEO 评估,而是可以基于页面实际检索到的内容进行推理。
LLM 输出不会被盲目接受。
生成的审计结果使用 Zod 进行验证。
概念流程是:
LLM
|
v
JSON
|
v
Zod schema
|
+--> valid --> audit result
|
+--> invalid --> fallback/error handling
这很重要,因为 LLM 可能产生语法无效的 JSON、缺失字段、意外值或错误的数据类型。
Schema 将 LLM 从不可信的输出生成器转变为具有明确契约的组件。
AI 层并非唯一的洞察来源。
如果 LLM 失败,审计流水线可以回退到:
buildDeterministicInsights()
该规则引擎无需 AI 响应即可处理确定性发现。
这赋予了系统一个重要特性:
AI 服务中断不应使整个 SEO 审计变得无用。
例如,如果爬虫已经知道某个页面没有 canonical,这个事实不会因为 LLM 请求失败而消失。
某些检查更适合用代码实现。
title exists?
meta description exists?
canonical exists?
H1 exists?
HTTP status is 200?
internal link is broken?
JSON-LD exists?
hreflang is valid?
这些场景不需要 LLM。
因此最强的架构是:
Deterministic crawler/checks
+
RAG retrieval
+
LLM reasoning
Everything → LLM
这会降低成本并使技术检查可重现。
爬虫还计算内容质量信号,例如:
Flesch reading ease
keyword stuffing
complex-word ratio
word count
关键词堆砌检测器目前标记关键词密度高于:
3%
这些指标应被视为信号,而非绝对真理。
例如,技术文章可以合理地多次重复一个领域特定的术语。
简单的密度阈值无法理解这种上下文。
有用的方法是将这些指标作为证据与实际内容一起使用。
嵌入的一个更有趣的用途是内容 cannibalization(内容蚕食)检测。
系统可以使用成对余弦相似度比较页面嵌入。
当前阈值是:
0.85
Page A ────────┐
│ cosine similarity
Page B ────────┘
|
v
similarity >= 0.85
|
v
potential semantic overlap
当前比较是 O(n²)。
对于相对较小的页面集合来说这没问题。
但随着页面数量增长,成本会变得很高。
100 pages -> 4,950 pairs
1,000 pages -> 499,500 pairs
10,000 pages -> 49,995,000 pairs
因此成对比较作为初始实现是有用的,但大规模爬虫最终需要更高效的最近邻方法。
同样,相似度并不证明 cannibalization。
它只是识别出需要进一步分析候选页面。
一个只在用户手动启动审计时才工作的爬虫不足以进行监控。
AuditMe 还有增量刷新过程。
刷新作业检查:
50 most recent scans
使用 HTTP 验证器:
If-None-Match
If-Modified-Since
如果页面未更改,就没有理由重新下载和重新嵌入。
re-scan
↓
re-extract
↓
re-chunk
↓
re-embed
↓
update audit data
↓
update score history
这比每次重新处理所有页面要便宜得多。
长时间运行的作业需要失败机制。
爬虫在以下时间后将过时作业标记为失败:
10 minutes
超过以下时间未被抓取的站点:
7 days
可以被安排再次抓取。
这创建了一个基本的监控循环:
crawl
|
v
fresh data
|
v
7 days
|
v
refresh
具体的间隔可以根据目标站点的更新频率进行调整。
RAG 表只是应用的一部分。
其他重要的表包括:
用于 24 小时缓存。
url
data JSONB
etag
last_modified
URL 为主键。
存储永久审计记录。
每次审计都有一个 UUID,包含:
rag_debug
这在调试检索质量时特别有用。
如果 AI 给出的建议有问题,你希望知道模型实际收到的上下文是什么。
存储分数变化历史。
这使得趋势分析成为可能,而不是仅仅显示当前分数。
跟踪爬虫执行状态。
旧任务使用 7 天 TTL 策略清理。
RAG 系统以难以调试著称,如果检索过程不可见的话。
假设 LLM 给出了错误的建议。
有几种可能的原因:
crawler extracted the wrong content
↓
chunking lost context
↓
embedding was poor
↓
retrieval selected wrong chunks
↓
prompt was unclear
↓
LLM reasoned incorrectly
如果没有检索诊断,这些问题看起来都是:
"The AI gave a bad answer."
这是无法采取行动的。
将检索信息保存在 rag_debug 中可以使管道变得可观测。
query
retrieved chunks
similarity scores
source URL
chunk indexes
并确定失败发生在哪里。
主要环境变量包括:
GOOGLE_GEMINI_API_KEY
SUPABASE_SERVICE_ROLE_KEY
PAGESPEED_API_KEY
CRUX_API_KEY
CRON_SECRET
它们的职责是分开的:
GOOGLE_GEMINI_API_KEY
-> Gemini embeddings + LLM
SUPABASE_SERVICE_ROLE_KEY
-> server-side database administration
PAGESPEED_API_KEY
-> PageSpeed enrichment
CRUX_API_KEY
-> Chrome UX / Core Web Vitals enrichment
CRON_SECRET
-> protected scheduled jobs
service-role 密钥不应暴露给浏览器端代码。
主要教训是:RAG 只是系统的一部分。
一个有用的网络情报管道需要多个层级。
安全地获取实际页面。
HTTP
redirects
robots
throttling
SSRF protection
将 HTML 转化为有用信息。
metadata
content
links
schema
headings
第三层:结构化分析
运行确定性检查。
status
canonical
titles
links
schema
hreflang
查找语义相关的内容。
chunk
embed
search
rank
让 LLM 解释证据。
retrieved content
+
technical facts
+
audit instructions
永远不要盲目信任生成输出。
LLM
↓
Zod
↓
structured audit
这种分离使系统更容易推理。
未来的迭代有几个明显的方向。
当前页面本地的暴力检索简单快速,但更大的语料库可以使用数据库端 HNSW 检索或混合搜索策略。
当前 1400 字符的滑动窗口有效,但标题感知和语义分块可以更有效地保留上下文。
Top-K 向量相似性不一定是最终排名。
重排器可以提高检索精度。
当前的审计查询主要针对页面。
更广泛的检索层可以回答整个站点的问题。
更快的同类相食检测
O(n²) 两两比较最终会成为瓶颈。
最近邻搜索可以减少比较次数。
更多浏览器渲染
一些 JavaScript 重度站点需要完整渲染才能准确检查其内容。
这应该保持为可选的昂贵路径,而不是每个 URL 的默认选项。
爬虫不仅仅是摄取脚本。
一旦站点被表示为:
pages
+
links
+
metadata
+
content
+
embeddings
+
crawl history
就可以构建远不止一个基本的 SEO 检查器。
semantic content clusters
↓
internal linking recommendations
similar pages
↓
cannibalization candidates
content changes
↓
historical SEO monitoring
retrieved page content
↓
grounded AI recommendations
这是我与 AuditMe 一起努力的方向。
目标不是用 LLM 取代确定性 SEO 工具。
而是将确定性分析与语义检索和 AI 推理相结合。
完整系统可以总结为:
WEBSITE
|
v
+---------------+
| Safe Crawler |
+-------+-------+
|
+--------------+--------------+
| | |
v v v
Metadata Links Content
| | |
+--------------+--------------+
|
v
Chunking 1400/150
|
v
Gemini embedding-001
|
v
768-dim vectors
|
v
+-----------------------+
| Supabase PostgreSQL |
| pgvector + HNSW |
+-----------+-----------+
|
v
Page-local Top-K
cosine retrieval
|
v
RETRIEVED PAGE CONTENT
|
v
Grounded LLM
|
v
Zod validation
|
+--------------+--------------+
| |
v v
Structured Audit Determinis