【踩坑实录】我以为GEO是写文章,直到用Python模拟了一次AI检索,结果页面全军覆没
一、开篇:内容写了100篇,AI一问三不知
很多人做内容优化时,心里都有一个朴素愿望:
我都写这么多文章了,AI 总该引用我了吧?
现实可能是:
用户问:GEO和SEO有什么区别?
AI:我找到了答案。
用户问:如何判断一篇内容是否适合被AI引用?
AI:我找到了答案。
用户问:这个网站的内容能不能覆盖真实用户问题?
AI:沉默。
问题出在哪?
不是内容数量不够,而是内容和用户问题之间没有建立稳定映射。
这就是 GEO 经常被误解的地方。
GEO,Generative Engine Optimization,生成式引擎优化。它不是简单“写更多文章”,而是让内容更容易被 AI 在问答场景中检索、理解、引用和组织成答案。
如果用工程语言描述,GEO 的核心问题其实是:
给定一组用户问题,现有内容库能不能召回足够相关的答案片段?
这句话是不是突然就不玄学了?
本文就用 Python 写一个小工具,模拟一个简化版 AI 检索流程:
用户问题 → 文档切块 → 相似度计算 → 召回候选内容 → 输出覆盖率报告
最后你会得到一个可运行脚本,用来检测:
- 哪些用户问题已有内容覆盖
- 哪些问题没有合适内容
- 哪些页面看似很多,实际回答不了问题
- 哪些内容块最容易被召回
这就是本文的实战目标。
二、问题现场:页面很多,但为什么AI还是找不到答案?
先看一个常见内容库结构:
articles/
├── what-is-geo.md
├── seo-vs-geo.md
├── ai-search-content.md
├── content-strategy.md
└── faq.md
看起来挺丰富。
但真实用户问题可能是这样的:
GEO适合什么类型的网站?
为什么SEO做了却没有AI流量?
AI更容易引用什么样的内容?
FAQ对GEO有什么作用?
如何评估内容是否覆盖用户问题?
问题来了:
你的页面标题里有 GEO,不代表它能回答具体问题。
你的文章里出现了关键词,不代表 AI 能召回正确片段。
你的内容库很大,不代表用户问题覆盖率高。
这就像你建了一个接口文档库,里面有 200 个 Markdown 文件。
结果新人问一句:
订单状态枚举有哪些?
你翻了半天,只找到一句:
本系统支持完善的订单管理能力。
这不是文档,这是情绪价值。
三、解决思路:把GEO问题转成一个检索问题
我们先不讨论复杂的大模型,也不接 API。
用最朴素的方式模拟 AI 检索:
这个流程虽然简单,但非常适合做 GEO 基础体检。
因为 AI 在生成答案之前,通常也要经历类似逻辑:
理解问题 → 检索相关资料 → 判断可信度 → 组织答案
本文实现的是其中最基础的一步:
检查内容库有没有足够相关的答案候选片段。
四、项目结构
新建项目目录:
geo-retrieval-audit/
├── audit_retrieval.py
├── questions.txt
└── articles/
├── geo_intro.md
├── seo_vs_geo.md
└── faq_for_geo.md
说明:
| 文件 | 作用 |
|---|---|
| audit_retrieval.py | 主程序 |
| questions.txt | 用户问题库 |
| articles/ | Markdown内容库 |
五、准备用户问题库
创建 questions.txt:
GEO和SEO有什么区别?
为什么SEO做了却没有AI流量?
什么样的内容更容易被AI引用?
FAQ对GEO有什么作用?
如何判断内容是否覆盖用户真实问题?
GEO适合哪些类型的内容?
为什么只堆关键词不适合GEO?
这里的每一行都代表一个真实用户问题。
注意,GEO 优化不是从“我想写什么”开始,而是从“用户会问什么”开始。
六、准备测试文章
创建目录:
mkdir articles
创建 articles/geo_intro.md:
# 什么是GEO
GEO是Generative Engine Optimization,中文可以理解为生成式引擎优化。
它关注的不是传统搜索结果页里的排名,而是内容是否能被生成式AI理解、引用并整合进答案。
GEO更适合知识解释型、方案对比型、技术教程型、采购决策型内容。
一篇适合GEO的内容,通常需要具备清晰主题、问题导向、结构化表达、事实依据和总结结论。
创建 articles/seo_vs_geo.md:
# SEO和GEO的区别
SEO主要关注搜索引擎结果页,包括页面收录、关键词排名、点击率和自然流量。
GEO关注生成式AI问答场景,包括AI是否能理解内容、是否能引用内容、是否能将内容整合进答案。
SEO更像是在搜索结果中争取更高位置,GEO更像是在AI答案中争取被引用机会。
两者不是替代关系。GEO可以理解为AI搜索时代对SEO的一种扩展。
创建 articles/faq_for_geo.md:
# GEO常见问题
## FAQ对GEO有什么作用?
FAQ可以把用户真实问题直接结构化表达出来,方便AI识别问题和答案之间的对应关系。
## 什么样的内容更容易被AI引用?
结构清晰、结论明确、包含事实依据、步骤说明、对比分析和边界条件的内容,更容易成为AI答案的候选材料。
## 为什么只堆关键词不适合GEO?
关键词只能提供主题信号,但不能直接回答用户问题。GEO更关注内容是否能解决具体问题。
七、核心代码:实现一个简化版GEO召回检测器
创建 audit_retrieval.py:
import math
import re
from pathlib import Path
from collections import Counter, defaultdict
def load_questions(file_path):
"""
加载用户问题库
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"问题文件不存在: {file_path}")
questions = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
questions.append(line)
return questions
def load_markdown_documents(folder):
"""
加载Markdown内容库
"""
folder_path = Path(folder)
if not folder_path.exists():
raise FileNotFoundError(f"文章目录不存在: {folder}")
documents = []
for file_path in folder_path.glob("*.md"):
text = file_path.read_text(encoding="utf-8")
documents.append({
"file": file_path.name,
"text": text
})
return documents
def split_into_chunks(text, max_chars=220):
"""
将文章切分成内容块。
简化处理:按空行切分,再控制最大长度。
"""
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks = []
for paragraph in paragraphs:
if len(paragraph) <= max_chars:
chunks.append(paragraph)
else:
sentences = re.split(r"(?<=[。!?!?])", paragraph)
buffer = ""
for sentence in sentences:
if len(buffer) + len(sentence) <= max_chars:
buffer += sentence
else:
if buffer.strip():
chunks.append(buffer.strip())
buffer = sentence
if buffer.strip():
chunks.append(buffer.strip())
return chunks
def tokenize(text):
"""
简单中文分词:按中文、英文、数字连续片段切分。
这不是专业分词器,但足够演示召回逻辑。
"""
tokens = re.findall(r"[\u4e00-\u9fa5]{2,}|[a-zA-Z0-9]+", text.lower())
return tokens
def build_idf(corpus_tokens):
"""
计算IDF
"""
total_docs = len(corpus_tokens)
doc_freq = defaultdict(int)
for tokens in corpus_tokens:
for token in set(tokens):
doc_freq[token] += 1
idf = {}
for token, freq in doc_freq.items():
idf[token] = math.log((total_docs + 1) / (freq + 1)) + 1
return idf
def vectorize(tokens, idf):
"""
生成TF-IDF向量
"""
tf = Counter(tokens)
total = sum(tf.values()) or 1
vector = {}
for token, count in tf.items():
vector[token] = (count / total) * idf.get(token, 1.0)
return vector
def cosine_similarity(vec_a, vec_b):
"""
计算余弦相似度
"""
common_tokens = set(vec_a.keys()) & set(vec_b.keys())
dot = sum(vec_a[token] * vec_b[token] for token in common_tokens)
norm_a = math.sqrt(sum(value * value for value in vec_a.values()))
norm_b = math.sqrt(sum(value * value for value in vec_b.values()))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
def build_chunk_index(documents):
"""
构建内容块索引
"""
chunks = []
for doc in documents:
for index, chunk_text in enumerate(split_into_chunks(doc["text"])):
chunks.append({
"file": doc["file"],
"chunk_id": index + 1,
"text": chunk_text
})
chunk_tokens = [tokenize(chunk["text"]) for chunk in chunks]
idf = build_idf(chunk_tokens)
for chunk, tokens in zip(chunks, chunk_tokens):
chunk["tokens"] = tokens
chunk["vector"] = vectorize(tokens, idf)
return chunks, idf
def search_top_k(question, chunks, idf, top_k=3):
"""
根据问题召回Top K内容块
"""
question_tokens = tokenize(question)
question_vector = vectorize(question_tokens, idf)
results = []
for chunk in chunks:
score = cosine_similarity(question_vector, chunk["vector"])
results.append({
"score": score,
"file": chunk["file"],
"chunk_id": chunk["chunk_id"],
"text": chunk["text"]
})
results.sort(key=lambda item: item["score"], reverse=True)
return results[:top_k]
def audit_coverage(questions, chunks, idf, threshold=0.12):
"""
检测每个问题是否有足够相关的内容块
"""
report = []
for question in questions:
top_results = search_top_k(question, chunks, idf, top_k=3)
best_score = top_results[0]["score"] if top_results else 0
if best_score >= threshold:
status = "已覆盖"
else:
status = "未覆盖"
report.append({
"question": question,
"status": status,
"best_score": best_score,
"top_results": top_results
})
return report
def print_report(report):
"""
打印GEO问题覆盖率报告
"""
total = len(report)
covered = sum(1 for item in report if item["status"] == "已覆盖")
coverage_rate = covered / total * 100 if total else 0
print("=" * 70)
print("GEO问题覆盖率报告")
print("=" * 70)
print(f"问题总数: {total}")
print(f"已覆盖: {covered}")
print(f"未覆盖: {total - covered}")
print(f"覆盖率: {coverage_rate:.2f}%")
print("=" * 70)
for item in report:
print()
print(f"问题: {item['question']}")
print(f"状态: {item['status']}")
print(f"最高相似度: {item['best_score']:.4f}")
print("-" * 70)
for result in item["top_results"]:
snippet = result["text"].replace("\n", " ")
if len(snippet) > 120:
snippet = snippet[:120] + "..."
print(
f"[{result['score']:.4f}] "
f"{result['file']}#chunk-{result['chunk_id']} | {snippet}"
)
def main():
questions = load_questions("questions.txt")
documents = load_markdown_documents("articles")
if not documents:
print("articles目录下没有找到Markdown文件。")
return
chunks, idf = build_chunk_index(documents)
report = audit_coverage(questions, chunks, idf, threshold=0.12)
print_report(report)
if __name__ == "__main__":
main()
八、运行脚本
在项目根目录执行:
python audit_retrieval.py
你会看到类似输出:
======================================================================
GEO问题覆盖率报告
======================================================================
问题总数: 7
已覆盖: 6
未覆盖: 1
覆盖率: 85.71%
======================================================================
问题: GEO和SEO有什么区别?
状态: 已覆盖
最高相似度: 0.4281
----------------------------------------------------------------------
[0.4281] seo_vs_geo.md#chunk-1 | # SEO和GEO的区别
[0.3275] seo_vs_geo.md#chunk-2 | SEO主要关注搜索引擎结果页,包括页面收录、关键词排名、点击率和自然流量。
[0.3018] seo_vs_geo.md#chunk-3 | GEO关注生成式AI问答场景,包括AI是否能理解内容、是否能引用内容...
问题: 如何判断内容是否覆盖用户真实问题?
状态: 未覆盖
最高相似度: 0.0833
----------------------------------------------------------------------
[0.0833] geo_intro.md#chunk-4 | 一篇适合GEO的内容,通常需要具备清晰主题、问题导向...
这个结果非常关键。
它告诉我们:
内容库不是没有文章,而是某些用户问题没有被明确回答。
这就是 GEO 优化中最容易被忽视的部分。
九、结果怎么看?别只看覆盖率,要看“召回片段”
如果脚本显示:
覆盖率: 85.71%
这是不是说明内容已经不错?
不一定。
还要看每个问题召回的片段质量。
情况1:召回片段正好回答问题
例如问题是:
GEO和SEO有什么区别?
召回内容是:
SEO主要关注搜索引擎结果页,包括页面收录、关键词排名、点击率和自然流量。
GEO关注生成式AI问答场景,包括AI是否能理解内容、是否能引用内容、是否能将内容整合进答案。
这就属于有效覆盖。
情况2:召回片段只有关键词,没有答案
例如问题是:
为什么SEO做了却没有AI流量?
召回内容是:
SEO和GEO都是内容优化方法。
这就不够。
它有关键词,但没有解释原因。
这种内容对于 AI 来说,只能算“路过”,不能算“可引用”。
十、踩坑实录:阈值调太低,所有内容都“看起来很美”
代码里有一行:
report = audit_coverage(questions, chunks, idf, threshold=0.12)
这个 threshold 是判断问题是否被覆盖的阈值。
如果你把它改成:
threshold=0.01
结果可能会变成:
覆盖率: 100.00%
是不是很开心?
先别急着截图发周报。
阈值太低会导致一个问题:
只要内容里沾点边,就算覆盖。
这就像代码测试里只测了一个 print("hello"),然后宣布系统稳定性达到 100%。
不建议。
推荐做法
可以按下面方式粗略判断:
| 最高相似度 | 判断 |
|---|---|
| 0.30 以上 | 大概率相关 |
| 0.15 - 0.30 | 需要人工复核 |
| 0.15 以下 | 大概率覆盖不足 |
但这不是固定标准。
不同语料、分词方式、内容长度都会影响结果。
十一、解决方案:针对“未覆盖问题”反向补内容
如果检测发现:
问题: 如何判断内容是否覆盖用户真实问题?
状态: 未覆盖
不要第一反应就是“再写一篇大文章”。
更好的做法是补一个精准内容块。
比如在 faq_for_geo.md 中增加:
## 如何判断内容是否覆盖用户真实问题?
可以从三个维度判断:第一,文章标题和小标题是否对应用户会直接提出的问题;第二,正文是否给出明确答案、步骤或判断标准;第三,内容是否包含FAQ、对比表、案例或边界说明。
如果一个问题只能在文章中找到关键词,但找不到明确答案,就不能算真正覆盖。
然后重新运行脚本。
你会发现这个问题的相似度明显提高。
这就是 GEO 内容优化的一个重要思路:
不要只看文章数量,要看问题覆盖率。
不要只补关键词,要补可回答问题的内容块。
十二、进阶优化:输出CSV,方便做内容缺口表
如果要把结果交给编辑、运营或开发同学继续处理,可以输出 CSV。
在代码中增加:
import csv
再增加一个函数:
def export_csv(report, output_file="geo_coverage_report.csv"):
"""
导出CSV报告
"""
with open(output_file, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"question",
"status",
"best_score",
"top_file",
"top_chunk_id",
"top_text"
])
for item in report:
top = item["top_results"][0] if item["top_results"] else {}
writer.writerow([
item["question"],
item["status"],
f"{item['best_score']:.4f}",
top.get("file", ""),
top.get("chunk_id", ""),
top.get("text", "").replace("\n", " ")
])
在 main() 里调用:
export_csv(report)
print("CSV报告已生成: geo_coverage_report.csv")
这样你会得到一个文件:
geo_coverage_report.csv
里面大概长这样:
| question | status | best_score | top_file | top_chunk_id |
|---|---|---|---|---|
| GEO和SEO有什么区别? | 已覆盖 | 0.4281 | seo_vs_geo.md | 1 |
| 如何判断内容是否覆盖用户真实问题? | 未覆盖 | 0.0833 | geo_intro.md | 4 |
这个表非常适合做内容排期。
未覆盖的问题,就是下一轮内容优化的优先级来源。
十三、完整系统可以怎么扩展?
现在这个脚本是一个最小可运行版本。
如果继续扩展,可以变成一个简化版 GEO 内容诊断系统。
可以继续增加这些模块:
| 模块 | 作用 |
|---|---|
| 网页抓取模块 | 从 URL 自动提取正文 |
| 中文分词模块 | 使用 jieba 提高中文召回效果 |
| 向量模型模块 | 使用 sentence-transformers 做语义匹配 |
| FAQ生成模块 | 根据未覆盖问题生成内容草稿 |
| Schema检测模块 | 检查网页是否有 FAQPage 结构化数据 |
| 可视化看板 | 展示问题覆盖率、内容缺口、页面表现 |
如果引入向量模型,流程会更接近真实 AI 检索:
问题文本 → Embedding → 向量检索 → Top K片段 → 人工复核 → 内容补全
但本文先不引入重型依赖。
因为工程落地的第一步,不是上来就堆模型,而是先把问题库和内容库跑通。
十四、避坑指南:做GEO内容覆盖检测时,别踩这几个坑
1. 不要只用关键词当问题库
错误示例:
GEO
SEO
AI搜索
内容优化
这不是问题库,这是关键词清单。
更好的问题库应该是:
GEO和SEO有什么区别?
GEO为什么需要FAQ?
什么样的内容更容易被AI引用?
如何判断页面是否覆盖用户真实问题?
AI问答场景里,“问题”比“词”更接近真实需求。
2. 不要把整篇文章当成一个块
如果一篇文章 5000 字,直接整体计算相似度,结果会很粗糙。
更推荐按段落、小标题或固定长度切块。
原因很简单:
AI 引用的通常不是整篇文章,而是其中某个答案片段。
就像你查文档时,也不是想看完整项目介绍,只想知道:
这个参数到底传 string 还是 int?
3. 不要迷信分数
相似度分数只是辅助判断。
有时候分数高,是因为关键词重合多;但内容可能没有真正回答问题。
所以关键问题必须人工复核。
可以把问题分成三类:
| 类型 | 处理方式 |
|---|---|
| 明确覆盖 | 保留,必要时增强结构 |
| 模糊覆盖 | 补充答案、案例或判断标准 |
| 未覆盖 | 新增FAQ或独立内容块 |
4. 不要为了覆盖率而写废话
有些人看到“未覆盖问题”,就开始机械生成内容:
关于这个问题,我们需要综合考虑多个方面,并结合实际情况进行分析。
这类内容看似回答了,实际没有信息量。
更好的回答应该包含:
- 明确定义
- 判断维度
- 操作步骤
- 示例
- 适用边界
- 常见误区
AI 更容易使用信息密度高的内容。
5. 不要忽略“反向问题”
用户不只会问正向问题:
GEO怎么做?
也会问反向问题:
为什么做了SEO还没有AI流量?
为什么我的内容不被AI引用?
为什么FAQ很多但没有效果?
这些痛点型问题往往更接近真实场景。
内容库如果只覆盖“是什么”,不覆盖“为什么失败”,GEO效果通常会打折。
十五、下一步行动:用问题覆盖率驱动内容更新
如果你要真正把这套方法用起来,可以按下面流程执行:
建议每次内容更新都做三件事:
- 新增问题时,先加入
questions.txt - 发布文章前,先跑一次召回检测
- 对未覆盖问题,优先补 FAQ、对比表和判断标准
这会让内容优化从“凭感觉写”变成“按问题缺口补”。
—
十六、总结:GEO不是写更多,而是回答得更准
最后总结一下。
GEO 不只是写文章,也不只是做 SEO 的新名字。
从工程视角看,它至少包含三个问题:
用户会问什么?
现有内容能不能被召回?
召回内容能不能回答问题?
本文用 Python 实现了一个简化版 GEO 召回检测器,完成了:
- 读取用户问题库
- 读取 Markdown 内容库
- 内容切块
- TF-IDF 向量化
- 相似度召回
- 覆盖率报告输出
- 未覆盖问题识别
它不能替代真正的搜索引擎或大模型,但能帮你发现一个非常现实的问题:
你的内容库到底能不能回答用户问题?
SEO 时代,我们经常问:
这个关键词有没有排名?
GEO 时代,还要多问一句:
这个问题有没有答案片段可以被AI召回?
如果没有,那就不是 AI 不引用你。
是你的内容库里,压根没有它能用的答案。
更多推荐




所有评论(0)