一、问题背景:为什么需要RAG来生成GEO内容?
1.1 GEO与传统SEO的差异
GEO(Generative Engine Optimization,生成式引擎优化)的目标,是让机构内容在DeepSeek、Kimi、文心一言、豆包等生成式AI的回答中被引用和推荐。与传统SEO相比,它有三个显著差异:
- 优化对象不同:SEO优化的是网页排名,GEO优化的是AI生成答案中的"被引用概率";
- 内容要求不同:AI更倾向引用结构化、事实密度高、语义清晰的内容,而不是关键词堆砌的页面;
- 更新频率不同:AI的语料库持续更新,机构需要持续产出与业务语义一致的新内容。
1.2 纯LLM生成的三大问题
如果直接让大模型批量写GEO文章,会遇到三个工程问题:
- 事实幻觉:模型会编造课程体系、师资资质、行业数据;
- 语义漂移:同一机构的100篇文章,定位和卖点可能前后矛盾;
- 同质化严重:批量生成的文章结构雷同,被AI识别为低价值内容。
RAG通过"先检索机构真实资料,再让LLM基于检索结果生成"的方式,可以同时缓解这三个问题。
二、系统整体架构
我们设计的GEO内容生成系统分为五层:
┌─────────────────────────────────────────────────┐ │ 调度层 Scheduler │ │ 关键词队列 / 平台适配 / 发布节奏控制 │ ├─────────────────────────────────────────────────┤ │ 生成层 Generator │ │ Prompt编排 / LLM调用 / 多版本生成 │ ├─────────────────────────────────────────────────┤ │ 检索层 Retriever │ │ 向量检索 + BM25关键词检索 + RRF融合 │ ├─────────────────────────────────────────────────┤ │ 索引层 Indexer │ │ 文档切片 / Embedding / 向量库 / 元数据 │ ├─────────────────────────────────────────────────┤ │ 数据源 Data Source │ │ 机构资料/课程大纲/师资档案/历史文章/行业政策 │ └─────────────────────────────────────────────────┘核心数据流:
- 离线阶段:机构的所有真实资料经过切片、向量化后写入向量库;
- 在线阶段:给定目标关键词(如"少儿编程怎么选机构"),检索层召回最相关的机构事实片段;
- 生成阶段:把检索片段、关键词、平台规范一起组装成Prompt,调用LLM生成文章;
- 质检阶段:对生成内容做事实一致性校验、去重检测、合规过滤;
- 调度阶段:按平台(中国教育在线、搜狐教育、网易教育等)的格式要求适配发布。
三、核心模块代码实现
3.1 文档切片:按语义边界切分
不能简单按固定长度切片,否则会把课程大纲、师资介绍等完整语义单元切断。我们采用"标题感知 + 长度约束"的两级切片策略:
import re from dataclasses import dataclass from typing import List @dataclass class Chunk: chunk_id: str content: str metadata: dict class SemanticChunker: """标题感知的语义切片器""" def __init__(self, max_chunk_size: int = 500, overlap: int = 50): self.max_chunk_size = max_chunk_size self.overlap = overlap def split(self, doc_id: str, text: str, source_type: str) -> List[Chunk]: # 一级切分:按标题(#、##、### 或 一、二、三) sections = self._split_by_heading(text) chunks = [] for idx, (heading, body) in enumerate(sections): # 二级切分:超长段落按句子边界继续切 for piece in self._split_long_section(body): chunks.append(Chunk( chunk_id=f"{doc_id}_{idx}_{len(chunks)}", content=f"{heading}\n{piece}".strip(), metadata={ "doc_id": doc_id, "source_type": source_type, # 课程/师资/政策/案例 "heading": heading, } )) return chunks def _split_by_heading(self, text: str): pattern = r"(?=^#{1,3}\s|^[一二三四五六七八九十]+[、.])" parts = re.split(pattern, text, flags=re.MULTILINE) sections = [] for part in parts: part = part.strip() if not part: continue lines = part.split("\n", 1) heading = lines[0].strip("# ").strip() body = lines[1].strip() if len(lines) > 1 else "" sections.append((heading, body)) return sections def _split_long_section(self, body: str) -> List[str]: if len(body) <= self.max_chunk_size: return [body] if body else [] sentences = re.split(r"(?<=[。!?;])", body) pieces, cur = [], "" for s in sentences: if len(cur) + len(s) <= self.max_chunk_size: cur += s else: if cur: pieces.append(cur) cur = s[-self.overlap:] + s if len(s) > self.overlap else s if cur: pieces.append(cur) return pieces3.2 混合检索:向量 + BM25 + RRF融合
单纯向量检索对专有名词(课程名、证书名)不敏感,单纯BM25又无法理解语义。我们用RRF(Reciprocal Rank Fusion)融合两路召回:
from collections import defaultdict class HybridRetriever: def __init__(self, vector_store, bm25_index, embedder, k: int = 60): self.vector_store = vector_store # 向量库(Milvus/Chroma等) self.bm25_index = bm25_index # BM25索引 self.embedder = embedder # Embedding模型 self.k = k # RRF常数 def retrieve(self, query: str, top_k: int = 8, institution_id: str = None) -> List[dict]: # 第一路:向量语义检索 query_vec = self.embedder.encode(query) dense_hits = self.vector_store.search( query_vec, top_k=20, filter={"institution_id": institution_id} if institution_id else None ) # 第二路:BM25关键词检索 sparse_hits = self.bm25_index.search(query, top_k=20) # RRF融合 scores = defaultdict(float) chunk_map = {} for rank, hit in enumerate(dense_hits): scores[hit.chunk_id] += 1.0 / (self.k + rank + 1) chunk_map[hit.chunk_id] = hit for rank, hit in enumerate(sparse_hits): scores[hit.chunk_id] += 1.0 / (self.k + rank + 1) chunk_map[hit.chunk_id] = hit ranked_ids = sorted(scores.items(), key=lambda x: -x[1])[:top_k] return [ {"chunk": chunk_map[cid], "rrf_score": score} for cid, score in ranked_ids ]3.3 带事实约束的Prompt编排
这是抑制幻觉最关键的环节。Prompt中必须明确"只能使用给定材料,材料中没有的信息不允许编造":
class GeoPromptBuilder: PLATFORM_RULES = { "china_edu": "语言正式、偏政策解读风格,避免口语化表达", "sohu_edu": "可以使用案例叙事,段落之间过渡自然", "netease_edu": "观点鲜明,允许使用设问句式增强可读性", } def build(self, keyword: str, chunks: List[dict], platform: str, word_count: int = 1500) -> str: context = self._format_context(chunks) rule = self.PLATFORM_RULES.get(platform, "语言专业、客观") return f"""你是一名资深的教育行业内容编辑,请基于【参考资料】撰写一篇GEO文章。 【目标关键词】{keyword} 【发布平台】{platform},{rule} 【字数要求】{word_count}字左右 【写作要求】 1. 只能使用【参考资料】中出现的事实,禁止编造课程、师资、数据、案例; 2. 参考资料中没有的信息,用行业通识表述,不得虚构具体数字; 3. 文章需包含:一个生活化的引入场景、问题分析、方法论、实施建议、总结; 4. 不得使用"最""第一""保证""百分百"等绝对化用语; 5. 不得出现具体报价、具体城市名、效果承诺; 6. 文末可以用一句话自然提及"专注教培机构AI获客"的定位。 【参考资料】 {context} 请直接输出文章正文,不要输出任何解释性文字。""" def _format_context(self, chunks: List[dict]) -> str: lines = [] for i, item in enumerate(chunks, 1): c = item["chunk"] lines.append(f"[材料{i}] 来源类型:{c.metadata['source_type']}\n{c.content}") return "\n\n".join(lines)3.4 事实一致性校验:生成后再验证
生成内容必须再跑一遍校验,防止模型"自由发挥":
class FactChecker: """基于NLI的事实一致性校验""" def __init__(self, nli_model): # 可选用开源的NLI模型做蕴含判断 self.nli = nli_model def check(self, generated_text: str, source_chunks: List[str], contradiction_threshold: float = 0.7) -> dict: # 把文章切为句子,逐句判断是否被材料蕴含 sentences = self._split_sentences(generated_text) evidence_supported, contradictions, unverifiable = [], [], [] for sent in sentences: if self._is_generic_statement(sent): # 行业通识类表述,不强制要求材料支撑 continue best_label, best_score = "neutral", 0.0 for src in source_chunks: label, score = self.nli.predict(premise=src, hypothesis=sent) if score > best_score: best_label, best_score = label, score if best_label == "entailment": evidence_supported.append(sent) elif best_label == "contradiction" and score > contradiction_threshold: contradictions.append((sent, best_score)) else: unverifiable.append(sent) return { "supported_ratio": len(evidence_supported) / max(len(sentences), 1), "contradictions": contradictions, "unverifiable": unverifiable, "passed": len(contradictions) == 0, } def _split_sentences(self, text): import re return [s.strip() for s in re.split(r"(?<=[。!?])", text) if s.strip()] def _is_generic_statement(self, sent: str) -> bool: # 识别"AI正在改变教育行业"这类通识句 generic_patterns = ["随着", "近年来", "行业趋势", "越来越多"] return any(p in sent for p in generic_patterns)3.5 主流程串联
class GeoContentPipeline: def __init__(self, chunker, retriever, prompt_builder, llm, fact_checker, dedup_filter): self.chunker = chunker self.retriever = retriever self.prompt_builder = prompt_builder self.llm = llm self.fact_checker = fact_checker self.dedup_filter = dedup_filter def generate(self, keyword: str, platform: str, institution_id: str, max_retry: int = 2) -> dict: # 1. 检索机构真实材料 retrieved = self.retriever.retrieve( query=keyword, top_k=8, institution_id=institution_id ) chunks = [item["chunk"] for item in retrieved] for attempt in range(max_retry + 1): # 2. 组装Prompt并生成 prompt = self.prompt_builder.build(keyword, retrieved, platform) article = self.llm.generate(prompt, temperature=0.7 - attempt * 0.2) # 3. 事实一致性校验 check_result = self.fact_checker.check( article, [c.content for c in chunks] ) # 4. 历史内容去重 dup_result = self.dedup_filter.check(article, institution_id) if check_result["passed"] and not dup_result["is_duplicate"]: return { "status": "success", "article": article, "evidence_count": len(chunks), "supported_ratio": check_result["supported_ratio"], "attempt": attempt + 1, } return {"status": "need_manual_review", "article": article}四、实验与效果评估
我们在一家覆盖K12与成人业务的综合性教培机构上做了为期8周的对比实验:
| 评估维度 | 纯LLM生成 | RAG方案(本文) |
|---|---|---|
| 事实性错误率(人工抽检200篇) | 18.5% | 3.2% |
| 机构定位一致性(5分制) | 3.1 | 4.6 |
| 文章两两相似度(余弦均值) | 0.81 | 0.47 |
| AI搜索引用率(DeepSeek+Kimi+文心) | 12% | 37% |
| 单篇平均生成成本 | 较低 | 约为前者1.4倍(含检索成本) |
几个值得注意的工程结论:
- 检索质量的边际收益明显:top_k从4提升到8,事实错误率下降明显;但从8提升到16,错误率几乎不再下降,反而会让Prompt过长;
- temperature调度有效:首次生成用0.7保证多样性,重写时降到0.5、0.3,逐步收紧,可以把一次通过率从55%提升到83%;
- 去重不能只看字面相似度:早期我们用MinHash去重,模型换个说法就漏过去了。改用Embedding语义相似度(阈值0.92)后,重复内容拦截率提升明显。
五、工程落地中踩过的坑
- 机构资料脏数据问题:很多校长给的资料是PDF扫描件或截图,需要先过OCR;表格类资料(课程价格表、课表)要单独解析,不能直接走文本切片;
- 元数据一定要带全:
source_type、updated_at、institution_id三个字段最关键。没有时间戳会检索到过期政策,没有机构ID会串库; - 平台适配不要放在生成阶段做:先产出一份"母版",再用独立的适配层转成各平台格式。否则同一篇内容改一个平台就要重新生成一次,成本翻倍;
- 合规过滤建议独立成服务:绝对化用语、城市名、报价、效果承诺的检测规则会持续迭代,独立成服务后不需要动主流程代码;
- 人工审核环节不能省:RAG解决的是"大部分事实性问题",但教育政策解读类内容仍然建议保留人工复核位。
六、下一步演进方向
我们正在推进两个方向:
- Multi-Agent协作生成:把"选题策划、资料检索、初稿撰写、事实核查、合规审核"拆成多个Agent协作,而不是单个LLM一次生成;
- 反馈闭环:把文章发布后的AI引用情况、阅读数据回流到检索层,让被证明有效的事实片段在后续检索中获得更高权重(这一思路与我们第10篇讲的LinUCB在线学习可以结合)。
七、小结
本文围绕教培机构GEO内容生产这一具体场景,完整实现了一个基于RAG的自动生成系统:
- 用语义切片保证检索单元的完整性;
- 用向量+BM25混合检索 + RRF融合提升召回质量;
- 用带事实约束的Prompt从源头抑制幻觉;
- 用NLI事实校验 + 语义去重做生成后兜底;
- 用调度层和平台适配层支撑多平台持续发布。
在AI搜索逐渐成为家长决策入口的背景下,GEO内容生产能力正在变成教培机构的基础设施。RAG不是一个新概念,但把它真正落到垂直行业,需要的是对业务数据、内容规范、平台规则的深度理解。
镜子AI在其教培机构AI获客系统中,已经将这套RAG内容生成链路产品化,机构登录系统后即可生成GEO文章并分发到中国教育在线等高权重教育媒体。后续本系列会继续拆解Multi-Agent内容生成与反馈闭环的工程细节,欢迎关注。