一、开篇:内容写了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 检索:

准备用户问题库

读取Markdown内容库

按段落切分内容块

计算问题与内容块相似度

召回Top K内容块

判断问题是否被覆盖

生成GEO覆盖率报告

这个流程虽然简单,但非常适合做 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或内容块

重新检测

形成内容更新循环

建议每次内容更新都做三件事:

  1. 新增问题时,先加入 questions.txt
  2. 发布文章前,先跑一次召回检测
  3. 对未覆盖问题,优先补 FAQ、对比表和判断标准

这会让内容优化从“凭感觉写”变成“按问题缺口补”。

在这里插入图片描述

十六、总结:GEO不是写更多,而是回答得更准

最后总结一下。

GEO 不只是写文章,也不只是做 SEO 的新名字。

从工程视角看,它至少包含三个问题:

用户会问什么?
现有内容能不能被召回?
召回内容能不能回答问题?

本文用 Python 实现了一个简化版 GEO 召回检测器,完成了:

  • 读取用户问题库
  • 读取 Markdown 内容库
  • 内容切块
  • TF-IDF 向量化
  • 相似度召回
  • 覆盖率报告输出
  • 未覆盖问题识别

它不能替代真正的搜索引擎或大模型,但能帮你发现一个非常现实的问题:

你的内容库到底能不能回答用户问题?

SEO 时代,我们经常问:

这个关键词有没有排名?

GEO 时代,还要多问一句:

这个问题有没有答案片段可以被AI召回?

如果没有,那就不是 AI 不引用你。

是你的内容库里,压根没有它能用的答案。

更多推荐