Contents
1 前言
在这个系列文章的第一篇(参见文章:为博客构建“轻量级知识索引”(一):结构设计与构建流程)的最后,我们已经将整套知识索引系统收敛为一个清晰的结构模型:从文章获取、内容整理,到关系构建与结果输出,形成了一条完整的数据处理链路。
不过,这种描述本身仍然停留在“结构层”——它解决的是“系统由哪些部分组成”,而不是“这些部分如何真正运行起来”。
因此,在这一篇中,我们要做的事情,就是把上一篇中的结构设计,进一步落地为一个可以实际运行的脚本系统。不过需要说明的是,这一步并不仅仅是“把流程翻译成代码”。更准确地说,它是一种执行视角下的重新组织:我们不再以“功能阶段”来理解系统,而是开始以“函数调用链”和“数据流转过程”来组织整个实现。
在这种视角下,上一篇中定义的数据获取、结构整理、语义生成以及关系计算等阶段,会自然对应到脚本中的多个核心步骤。它们不再只是概念上的划分,而是会以具体函数的形式,在整个执行链路中依次完成。
需要强调的是,这部分实现的目标,并不是追求复杂的工程设计或过度优化,而是优先保证一件事:整个流程能够稳定、清晰地跑通。因此,在实现方式上,会尽量采用直接、可读、易于调试的结构,而不会提前引入过多复杂机制。
某种意义上来说,这一篇真正完成的,其实是一次“从结构到执行”的转换:把一个在逻辑上成立的系统,真正变成一个可以持续运行的数据处理流程。
2 脚本运行环境与初始化
在进入脚本逻辑之前,首先需要明确一点:语义索引脚本的运行环境并不依赖复杂的框架或特殊运行时,它的全部执行基础,本质上只是一个标准的 Linux Python 环境。因此,这一步的目标并不是“搭建系统”,而是将生产环境从“可用的操作系统状态”收敛为“可执行 Python 脚本的稳定运行环境”。
这里以 Debian Linux(Debian 11)为例进行说明,这是一个较为典型的服务器环境,其特点是结构稳定、依赖明确,并且默认提供基础 Python 运行能力。
1. Python 运行环境确认
在大多数 Debian 11 的基础安装中,系统已经自带 Python 3.x 解释器。首先需要确认当前环境是否具备可用的 Python 执行能力:
python3 --version
如果返回类似 Python 3.9.x 的版本信息,说明 Python 运行时已经存在,可以直接进入下一步。
2. 包管理工具(pip)确认
Python 脚本的依赖管理依赖 pip 工具,因此需要确认 pip 是否可用:
python3 -m pip --version
如果系统提示 pip 不存在,则需要进行安装,在 Debian 系统中可以通过官方包管理器完成:
apt update
apt install python3-pip -y
安装完成后再次确认 pip 是否可用。
3. 运行依赖安装
该脚本的外部依赖非常有限,目前仅依赖 requests 用于 HTTP 请求:
python3 -m pip install requests
安装完成后,可以通过简单导入验证环境是否正常:
python3 -c "import requests; print(requests.__version__)"
如果没有报错,则说明依赖环境已经就绪。
4. 运行环境的最小验证
在进入完整脚本执行之前,需要确认两个关键能力:
- Python 可以正常执行脚本
- HTTP 请求可以访问 WordPress REST API
可以通过一个最小调用进行验证(此处假定 WordPress 本地访问地址为http://127.0.0.1/,实际使用时需根据真实部署环境替换为对应的访问地址):
python3 -c "import requests; print(requests.get('http://127.0.0.1/wp-json').status_code)"
如果返回 HTTP 状态码(如 200 或 301/302),说明本地 WordPress API 已可访问:

5. 环境状态收敛
完成以上步骤后,生产环境实际上已经从一个“通用 Linux 系统”收敛为一个具备以下能力的执行环境:
- Python 3 运行时可用
- pip 依赖管理可用
- HTTP 请求能力正常
- 本地 WordPress API 可访问
也就是说,系统已经满足语义索引脚本运行的全部前置条件。在这个阶段之后,环境初始化的任务已经完成,接下来进入的将不再是“准备工作”,而是脚本本身的分步调试与逐层构建过程。
3 语义索引的构建
3.1 脚本整体结构
在开始具体实现之前,可以先从整体上看一下这个脚本的结构。
从功能上来说,这个脚本并不复杂,它所做的事情,本质上就是把第6章中拆解出来的几个阶段,按顺序串联起来,形成一条完整的数据处理流程。不同之处在于,这一次这些步骤不再只是概念上的划分,而是被具体组织为一段可以执行的逻辑。
如果用一种更直观的方式来表达,这个脚本大致可以被理解为如下结构:
fetch_posts()
→ build_articles()
→ generate_semantic_info()
→ compute_related()
→ write_json()
这五个步骤分别对应整个流程中的不同阶段:
首先是 fetch_posts(),用于从 WordPress 获取原始文章数据。这一步的输出,是一组尚未处理的文章记录,它们仍然保持着 REST API 返回时的结构。
接下来是 build_articles(),将原始数据整理为统一的内部结构。在这个阶段中,文章的基础字段会被提取出来,并组织为后续处理所需的形式。
在此基础上,generate_semantic_info() 负责生成语义相关的信息,例如摘要与关键词。经过这一步之后,每一篇文章都具备了可以参与计算的语义字段。
随后是 compute_related(),这是整个流程中最核心的一步。在这里,脚本会基于已有的语义信息,对文章之间进行比较,并生成对应的关联关系。
最后是 write_json(),将所有处理结果汇总,并输出为最终的 JSON 文件。
从执行方式上看,这些步骤是严格顺序执行的。前一个阶段的输出,会直接作为后一个阶段的输入,直到最终结果被生成出来。整个过程不依赖任何外部状态,也不需要中间持久化,所有数据都在脚本运行期间完成处理。
如果从数据的角度来看,这个过程可以理解为一次逐步“丰富结构”的过程:最初只有原始内容,随后逐步补充基础字段、语义信息以及关联关系,最终形成完整的数据记录。
也正因为这种设计方式,脚本本身并不需要复杂的控制逻辑。它更像是一条线性的处理流水线,每一步只负责一件相对独立的事情,而整体的复杂度,则被分散到了各个阶段之中。
在接下来的几个小节中,我们将按照这个结构,从数据获取开始,逐步完成每一个步骤的具体实现。
3.2 获取文章数据(fetch_posts)
在脚本中,第一步需要完成的,是从 WordPress 中获取全部文章数据。这一过程对应的实现,是一个简单的分页请求:从第一页开始,逐页调用 REST API,直到所有文章被拉取完成。由于接口本身是分页返回的,因此必须通过循环的方式,将每一页的数据累积起来。
在代码层面,这一步可以直接实现为一个独立函数:
import requests
BASE_URL = "http://127.0.0.1"
def fetch_posts(base_url):
posts = []
page = 1
total_pages = None
while True:
url = f"{base_url}/wp-json/wp/v2/posts?page={page}&per_page=100"
resp = requests.get(url)
if resp.status_code != 200:
raise Exception(f"Request failed at page {page}, status: {resp.status_code}")
data = resp.json()
# 第一次请求时获取总页数
if total_pages is None:
total_pages = int(resp.headers.get("X-WP-TotalPages", 1))
print(f"Total pages: {total_pages}")
posts.extend(data)
print(f"Fetched page {page}, total posts: {len(posts)}")
if page >= total_pages:
break
page += 1
return posts
if __name__ == "__main__":
posts = fetch_posts(BASE_URL)
print(f"\nTotal posts fetched: {len(posts)}")
这里的逻辑比较直接:
- page 控制当前请求页数
- 每次请求返回一页文章
- 将结果追加到 posts 列表中
- 在首次请求中获取总页数(X-WP-TotalPages),并以此作为遍历的终止条件
在函数执行完成之后,posts 中就包含了当前博客的全部文章数据。每一项都是 REST API 返回的原始结构,尚未进行任何处理。
如果用一个简化的结构来表示,此时的数据大致如下:
{
“id”: 123,
“title”: { “rendered”: “文章标题” },
“link”: “http://127.0.0.1/post”,
“content”: { “rendered”: “HTML正文” },
“tags”: [1, 2, 3]
}
可以看到,这里的数据仍然保持着 WordPress REST API 的原始结构,各个字段分散在不同层级之中,并未进行任何整理或加工。
到这里为止,脚本已经完成了“数据接管”的第一步:从 WordPress 中获取到了完整的文章集合。接下来的处理,将不再依赖 WordPress API,而是在这份数据之上逐步完成结构转换与语义计算。
在实际运行中,脚本会根据返回的分页信息,逐页获取文章数据,并最终收敛为完整的文章集合。以我博客为例,最终获取到的文章数量为 237 篇:

与目前博客实际文章的数量一致:

说明分页逻辑与终止条件均已正确生效。
3.3 构建基础结构(build_articles)
在获取到原始文章数据之后,下一步需要做的,是将这些数据整理为一套统一的内部结构。
REST API 返回的内容本身是面向页面展示的,不同字段分散在不同层级之中。如果直接在此基础上进行后续处理,会增加不必要的复杂度。因此,在进入语义处理之前,需要先完成一次结构上的“收敛”:把后续会用到的字段提取出来,并组织为统一的格式。
这一过程可以通过一个简单的转换函数来完成:
def build_articles(posts):
articles = []
for post in posts:
article = {
"id": str(post.get("id", "")),
"title": post.get("title", {}).get("rendered", ""),
"url": post.get("link", ""),
"content": post.get("content", {}).get("rendered", ""),
"tags": post.get("tags", [])
}
articles.append(article)
return articles
这里的处理非常直接:遍历 posts 中的每一条记录,从中提取出后续需要使用的字段,并重新组织为一条新的数据结构。
需要注意的是,由于 WordPress REST API 返回的数据结构本身是嵌套的,因此在提取字段时,需要同时考虑默认值与结构安全性,例如 title、content 等字段都需要通过多层 get 方法进行访问,以避免数据不完整导致的异常。
完成这一转换之后,数据的形态会发生一次明显变化。如果用一个简化示例来表示,此时的结构大致如下:
{
"id": "123",
"title": "文章标题",
"url": "https://example.com/post",
"content": "<p>HTML正文</p>",
"tags": [1, 2, 3]
}
可以看到,相比原始 WordPress 返回的嵌套结构,这里的数据已经被“收敛”为一层统一的字段表达,每一篇文章对应一条独立记录。原本分散在不同层级中的信息,被提取并标准化到同一个结构中。
到这里为止,脚本完成的是一次结构层面的整理:数据来源仍然是 WordPress,但数据形态已经被转换为内部统一模型。这个结构不会改变原始内容,但会作为后续语义处理与关联计算的标准输入。
在当前包含”fetch_posts 与 build_articles”部分内容的脚本的执行完成之后,脚本会额外输出一条示例数据(sample article),用于快速验证结构转换是否正确。该输出仅用于调试与验证,不代表完整数据集,只是从 articles 列表中抽取的第一条记录(当前博客最新的一篇文章),用于检查字段是否按预期完成标准化,例如id、 title、url、content 等字段是否已经从 WordPress 的嵌套结构中正确提取出来:

上图CLI 中看到的 sample article,本质上是这一阶段处理结果的结构化快照,用于确认 build_articles 阶段是否执行成功。
3.4 生成语义信息(generate_semantic_info)
在完成基础结构的构建之后,接下来需要做的,是为每一篇文章补充可以参与计算的语义信息。在当前实现中,这一步主要包含两个内容:生成摘要,以及整理关键词。它们不会替代原始内容,而是作为一种更紧凑的表达,被附加到已有结构之中。
对应的实现可以写成一个独立的处理函数:
import re
import html
def clean_html(raw_html):
# 去标签
text = re.sub('<[^<]+?>', '', raw_html or "")
# 反转 HTML 实体( → 空格)
text = html.unescape(text)
# 压缩空白(多个空格/换行 → 一个空格)
text = re.sub(r'\s+', ' ', text)
return text.strip()
def generate_semantic_info(articles):
for article in articles:
content = article.get("content", "")
# 清洗 HTML
text = clean_html(content)
# 生成摘要(保证是干净文本)
summary = f"{article['title']}\n{text[300:600]}"
# 关键词(暂时复用 tags)
keywords = article.get("tags", [])
article["summary"] = summary
article["keywords"] = keywords
return articles
这一阶段的处理方式非常直接:在遍历每一篇文章的过程中,对原始内容进行一次基础的文本处理,并从中提取出更易参与计算的语义信息,然后将这些结果写回到同一条数据记录中。
上面代码中的 summary 并不是简单取正文开头的一段文本,由于我博客文章的开头通常用于引出问题,而具体的解决思路往往出现在后续部分,因此在生成 summary 时,会刻意避开开头区域,优先选取中段内容,以更准确地反映文章的核心语义,这是比较经济的做法。
当然,如果更关注语义表达的完整性,而不是过度在意 summary 字段带来的体积增长,也可以直接从文章开头开始截取更长的正文内容,例如:
summary = f"{article['title']}\n{text[:2000]}"
这种方式虽然会进一步增大 semantic-index.json 的体积,但由于后续功能通常不会直接将整个 JSON 文件发送到前端,而是作为后端或中间层的基础数据使用,因此在很多场景下,适当增加 summary 长度反而有助于提升语义表达的准确性。
另外,keywords 在当前实现中直接复用了 WordPress 返回的标签 ID,这种处理方式的目的,并不是追求语义表达的完整性,而是为了在实现复杂度和可用性之间做一个折中:在这一阶段,keywords 仅作为一种稳定的结构化标识存在,用于参与后续的计算过程,而不是作为主要的语义来源。
换句话说,这里的 keywords 并不承担“解释内容”的职责,而更像是一种低成本的结构信号,用来补充其他语义信息的不足。
从系统设计的角度来看,这实际上意味着:标签体系仍然被保留,但它的角色已经发生了变化——它不再是决定文章关系的唯一依据,而是作为一种辅助信号,与 summary、title 等内容共同参与相似度计算。
因此,这种方式的效果并不完全取决于标签体系本身的精细程度,但标签质量仍然会对最终结果产生影响。如果标签定义较为混乱或粒度不一致,它在整体评分中的贡献也会相应降低,但不会完全破坏整个系统的可用性。
这里的处理包含几个基础步骤:去除 HTML 标签、还原实体字符(例如 )、以及压缩多余空白。在此基础上,脚本会从处理后的文本中截取前一段内容,作为摘要(summary);同时,直接复用文章原有的标签(tags)作为关键词(keywords),用于后续计算。
处理完成之后,每一篇文章的结构会在原有基础上增加新的字段。可以用一个简化的示例来表示此时的数据形态:
{
"id": "123",
"title": "文章标题",
"url": "https://example.com/post",
"content": "<p>HTML正文</p>",
"tags": [1, 2, 3],
"summary": "这是从清洗后的正文中截取的一段文本...",
"keywords": [1, 2, 3]
}
可以看到,相比上一阶段,这里的变化并不在于结构本身,而是在原有结构之上增加了新的语义字段。这些字段并不试图“理解”文章内容,而是提供一种更紧凑、可计算的表达形式,使后续的比较过程可以脱离完整正文进行。
到这里为止,每一篇文章已经具备了参与关系计算的基本条件。接下来的步骤,将基于这些语义信息,构建文章之间的关联关系。
在当前脚本(功能包含fetch_posts”+”build_articles”+”generate_semantic_info”3个部分)执行完成之后,脚本同样会输出一条示例数据,用于验证语义信息是否按预期生成:

与前一阶段不同,这里的输出重点不再是结构本身,而是新增的语义字段是否具备可用性。例如,可以通过 CLI 中的 sample article 观察到:
- summary 字段已经不再包含 HTML 标签,而是经过清洗后的连续文本;
- 原始内容中的实体字符(如 )已被还原;
- 多余的空白与换行被压缩,使摘要成为一段可读的自然语言;
- keywords 字段已经被填充,用于后续的关联计算。
这一输出可以被理解为语义处理阶段的结果快照:它并不代表最终输出文件,而是用于确认“内容 → 语义表达”的转换是否已经完成。
在确认这一阶段输出稳定之后,文章数据就已经具备了参与关系计算的基本条件。
在完成摘要与关键词生成之后,每一篇文章已经具备了用于比较的基础语义信息。接下来要做的,是在这些结构化字段之上计算文章之间的关联关系,并将结果写入 related 字段中。
整体实现可以拆解为一个简单的计算流程:以当前文章为基准,将其与其他文章逐一进行比较;基于多个字段(如 title、summary、tags)计算相似度得分;根据得分进行排序,并选取最相关的若干篇文章作为结果。
与前一阶段不同的是,这里的相似度计算不再只依赖单一字段,而是将不同来源的信息组合在一起进行评估。每一种字段都提供了一种不同类型的“语义信号”,它们共同决定了文章之间的接近程度。
在实现上,可以先定义一些基础函数,用于处理文本与集合之间的相似度计算:
import re
def tokenize(text):
return set(re.findall(r'\w+', text.lower()))
def jaccard(set_a, set_b):
if not set_a or not set_b:
return 0.0
return len(set_a & set_b) / len(set_a | set_b)
在此基础上,可以定义一个多字段的相似度计算函数:
def compute_score(a, b):
# 标签相似度
tags_a = set(a.get("keywords", []))
tags_b = set(b.get("keywords", []))
tag_score = jaccard(tags_a, tags_b)
# summary 相似度
summary_a = tokenize(a.get("summary", ""))
summary_b = tokenize(b.get("summary", ""))
summary_score = jaccard(summary_a, summary_b)
# title 相似度
title_a = tokenize(a.get("title", ""))
title_b = tokenize(b.get("title", ""))
title_score = jaccard(title_a, title_b)
# 加权融合
score = (
0.5 * summary_score +
0.3 * tag_score +
0.2 * title_score
)
return score
在这个计算过程中,不同字段承担着不同的角色:summary 作为主要的内容表达,提供最核心的语义信息;tags 作为已有结构的补充,在标签体系较为完善时可以提供稳定信号;title 则作为高密度的文本信息,用于在某些情况下增强匹配效果。
在此基础上,可以为每一篇文章构建对应的关联列表:
def compute_related(articles, top_n=5):
for article in articles:
related = []
for other in articles:
if article["id"] == other["id"]:
continue
score = compute_score(article, other)
if score > 0:
related.append({
"id": other["id"],
"score": round(score, 3)
})
# 按 score 排序并取前 N 条
related.sort(key=lambda x: x["score"], reverse=True)
article["related"] = related[:top_n]
return articles
这里的处理逻辑依然保持在一个可控且直观的范围内:
- 对每一篇文章,遍历所有其他文章
- 基于多字段计算相似度分数
- 过滤掉无关项(score 为 0)
- 按分数排序,并保留前几项
完成这一过程之后,数据的结构会再次发生变化。每一篇文章不再只是包含自身信息,还会附带一组与之相关的内容。例如:
{
"id": "123",
"title": "文章标题",
"url": "...",
"summary": "...",
"keywords": [1, 2, 3],
"related": [
{ "id": "456", "score": 0.812 },
{ "id": "789", "score": 0.637 }
]
}
到这里为止,数据已经从“独立的内容集合”,转变为一组具有关联关系的结构化记录。每一篇文章都通过 related 字段,与其他内容建立了连接。
这一步并不追求复杂的语义理解,而是在保持实现简单与可控的前提下,通过多种信号的组合,生成一组稳定且可排序的关联结果。对于当前这一层结构来说,这样的处理已经足以支撑后续的推荐与展示逻辑。
最终输出的 sample 结果展示了一篇完整文章的结构状态,其中不仅包含基础信息(id、title、summary、keywords),还包含了基于语义计算得到的关联文章列表:

可以看到,此时每篇文章已经不再是孤立的数据单元,而是被嵌入到一个具有局部关系结构的集合中。每一条 related 记录,都表示当前文章在语义空间中的“邻近节点”。
相比只依赖标签的方式,这里引入了基于文本的相似度计算,使得文章之间的关联不再完全依赖已有结构,而是可以在内容层面形成补充。这也意味着,即使标签体系不够完善,系统仍然可以基于正文信息建立一定程度的语义关系。
到这里为止,一个基于内容而非标签体系的初步语义关联结构已经在系统中被构建出来。
需要说明的是,这一节的实现方式在计算复杂度上属于 O(n²),即文章数量增加时,计算成本会呈平方级增长。在当前博客规模下,这一开销是完全可接受的,但如果数据规模进一步扩大,可以考虑引入倒排索引或候选集筛选等方式,对计算范围进行收敛。
3.6 输出 JSON(write_json)
在完成关联关系的构建之后,每一篇文章的数据已经从“独立的结构化记录”,进一步演化为一个包含语义关系的完整单元。此时,内存中的 articles 已经不再只是临时计算结果,而是一个可以直接用于外部系统消费的数据集合。
接下来需要做的,是将这一整套结构统一固化为一份 JSON 文件,作为整个语义索引系统的最终输出。这一过程可以通过一个非常直接的序列化操作完成:
import json
import os
def write_json(articles, output_path=None):
# 默认输出到当前脚本所在目录
if output_path is None:
base_dir = os.path.dirname(os.path.abspath(__file__))
output_path = os.path.join(base_dir, "semantic-index.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(articles, f, ensure_ascii=False, indent=2)
print(f"JSON written to: {output_path}")
从实现角度来看,这一步本身并不复杂,本质上只是将内存中的数据结构持久化到磁盘。但在整个流程中,它的意义并不在“写文件”本身,而在于语义计算结果的边界被正式固定下来。
在此之前,所有步骤(fetch、build、semantic、compute_related)都发生在计算过程中,而到了这一步,系统的输出被收敛为一个稳定的数据产物。
输出的json文件结构大致如下所示:
[
{
"id": "123",
"title": "文章标题",
"url": "https://example.com/post",
"summary": "...",
"keywords": [1, 2, 3],
"related": [
{ "id": "456", "score": 0.812 },
{ "id": "789", "score": 0.635 }
]
}
]
可以看到,每一篇文章都已经包含了完整的信息层级:
- 基础信息(id / title / url)
- 语义压缩结果(summary)
- 结构信息(keywords)
- 关系信息(related)
这一文件的本质,不再是“文章列表”,而是一份基于内容计算得到的局部语义图结构的静态表达。
3.7 完整脚本示例
经过前面几个小节的拆解,我们已经掌握了:
- 抓取文章:从 WordPress 接口获取全部文章。
- 构建结构化数据:整理成包含 ID、标题、URL、内容和标签的文章列表。
- 生成语义信息:清洗 HTML,提取摘要和关键词。
- 计算关联关系:基于标题、摘要和标签生成
related字段。 - 输出JSON文件
在这个小节里,我将这些步骤整合,最终形成一份可以直接运行的完整脚本。运行后,你将得到一份结构化、带有关联关系的文章列表,并写入指定路径的 JSON 文件,可直接用于前端展示或推荐系统。
这份脚本的特点如下:
- 全流程覆盖:从抓取到处理再到输出,一条命令即可完成。
- 结构清晰:每个步骤用函数封装,便于修改和扩展。
- 可读性好:JSON 文件格式化输出,中文正常显示。
- 便于扩展:可以在摘要生成、关键词提取或相似度计算中加入更复杂的逻辑,而无需重写整个流程。
完整python脚本示例如下:
import requests
import re
import html
import json
import os
# 根据自己实际环境修改
BASE_URL = "http://127.0.0.1"
# -------------------------
# Step 1: 获取文章
# -------------------------
def fetch_posts(base_url):
posts = []
page = 1
total_pages = None
while True:
url = f"{base_url}/wp-json/wp/v2/posts?page={page}&per_page=100"
resp = requests.get(url)
if resp.status_code != 200:
raise Exception(f"Request failed at page {page}, status: {resp.status_code}")
data = resp.json()
if total_pages is None:
total_pages = int(resp.headers.get("X-WP-TotalPages", 1))
print(f"Total pages: {total_pages}")
posts.extend(data)
print(f"Fetched page {page}, total posts: {len(posts)}")
if page >= total_pages:
break
page += 1
return posts
# -------------------------
# Step 2: 构建基础结构
# -------------------------
def build_articles(posts):
articles = []
for post in posts:
article = {
"id": str(post.get("id", "")),
"title": post.get("title", {}).get("rendered", ""),
"url": post.get("link", ""),
"content": post.get("content", {}).get("rendered", ""),
"tags": post.get("tags", [])
}
articles.append(article)
return articles
# -------------------------
# Step 3: HTML 清洗
# -------------------------
def clean_html(raw_html):
text = re.sub('<[^<]+?>', '', raw_html or "")
text = html.unescape(text)
text = re.sub(r'\s+', ' ', text)
return text.strip()
# -------------------------
# Step 4: 生成语义信息
# -------------------------
def generate_semantic_info(articles):
for article in articles:
content = article.get("content", "")
text = clean_html(content)
# 使用“标题 + 正文中段片段”
summary = f"{article['title']}\n{text[300:600]}"
keywords = article.get("tags", [])
article["summary"] = summary
article["keywords"] = keywords
return articles
# -------------------------
# Step 5: 相似度工具
# -------------------------
def tokenize(text):
return set(re.findall(r'\w+', text.lower()))
def jaccard(set_a, set_b):
if not set_a or not set_b:
return 0.0
return len(set_a & set_b) / len(set_a | set_b)
def compute_score(a, b):
# tags
tags_a = set(a.get("keywords", []))
tags_b = set(b.get("keywords", []))
tag_score = jaccard(tags_a, tags_b)
# summary
summary_a = tokenize(a.get("summary", ""))
summary_b = tokenize(b.get("summary", ""))
summary_score = jaccard(summary_a, summary_b)
# title
title_a = tokenize(a.get("title", ""))
title_b = tokenize(b.get("title", ""))
title_score = jaccard(title_a, title_b)
# 加权融合
return (
0.5 * summary_score +
0.3 * tag_score +
0.2 * title_score
)
# -------------------------
# Step 6: 构建关联关系
# -------------------------
def compute_related(articles, top_n=5):
for article in articles:
related = []
for other in articles:
if article["id"] == other["id"]:
continue
score = compute_score(article, other)
if score > 0:
related.append({
"id": other["id"],
"score": round(score, 3)
})
related.sort(key=lambda x: x["score"], reverse=True)
article["related"] = related[:top_n]
return articles
# -------------------------
# Step 7: 输出 JSON
# -------------------------
def write_json(articles, output_path=None):
# 脚本会将文件写入 `output_path` 参数指定的路径,如果未指定,则默认在当前目录生成 `semantic-index.json`
if output_path is None:
base_dir = os.path.dirname(os.path.abspath(__file__))
output_path = os.path.join(base_dir, "semantic-index.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(articles, f, ensure_ascii=False, indent=2)
print(f"JSON written to: {output_path}")
# -------------------------
# 主流程
# -------------------------
if __name__ == "__main__":
# Step 1
posts = fetch_posts(BASE_URL)
print(f"\nTotal posts fetched: {len(posts)}")
# Step 2
articles = build_articles(posts)
print(f"Total articles built: {len(articles)}")
# Step 3
articles = generate_semantic_info(articles)
print("Semantic info generated.")
# Step 4
articles = compute_related(articles)
print("Related articles computed.")
# Step 5(新增):写入 JSON
write_json(articles)
# Sample check
print("\nSample article:")
print({
"id": articles[0]["id"],
"title": articles[0]["title"],
"summary": articles[0]["summary"],
"keywords": articles[0]["keywords"],
"related": articles[0]["related"]
})
为了让大家有个直观的感受,我实际生成的”semantic-index.json”文件中,文章”声音的觉醒 · 基础篇(三):音程结构的可视化与历史约定“对应的 related 内容展示一下,如下图所示:

上图显示,与该文章最相关的 5 篇文章(按相似度评分从高到低排序,ID 分别为 14026、14017、13767、14048、13751)依次是:
1. “声音的觉醒 · 基础篇(二):从音名到简谱”
2. “声音的觉醒 · 基础篇(一):音高结构与听觉稳定性”
3. “声音的觉醒(三) “高位置”的误区与科学解读”
4. “声音的觉醒(六)如何判断自己属于低音、中音还是高音?”
5. “声音的觉醒(二)被误解的声音:高音的错觉与低音的幻觉”
可以看到,推荐结果与文章主题高度相关,说明 related 功能的准确性较高。同时,这也验证了本次编写脚本的目标已经成功实现:从文章获取、结构化处理、语义生成,到关联关系构建,全流程的逻辑都能产生可靠的语义索引,为后续推荐或展示提供了可直接使用的数据基础。
3.8 JSON 文件的位置与更新
在完成 semantic-index.json 的生成之后,还需要解决一个实际问题:这份文件放在哪里以及如何在后续使用过程中保持更新。
默认情况下,脚本会将文件输出到当前目录,但在实际部署中,通常会根据运行环境调整输出位置。比较常见的做法包括:
- 上传至对象存储,通过 CDN 提供访问
- 放入网站静态资源目录,随站点一起部署
- 作为服务端本地数据文件,由后端程序按需读取
不同方式之间的差异,主要体现在访问路径、缓存策略以及更新机制上,但本质上都是为了让这份 JSON 文件能够被系统稳定访问。
需要注意的是,虽然 semantic-index.json 本身是静态文件,但它的内容并不是永久固定的。由于这份索引是基于当前博客文章计算生成的,因此只要有新文章发布,或者已有文章内容发生变化,原有索引实际上就已经失效,需要重新生成。
对于更新频率较低的个人博客来说,最简单的方式通常是在发布新文章之后手动执行一次脚本;如果希望整个流程更加自动化,也可以结合定时任务(cron)或 CI/CD 流程,在固定时间间隔内自动重新生成索引,并覆盖旧文件。从维护方式上来看,这类 JSON 文件更接近于一种“可重复构建的生成结果”:它并不需要人工修改,而是由脚本根据当前内容自动重新计算得到。
另外,在实际使用过程中,还需要关注文件体积的问题。随着文章数量增加,semantic-index.json 的大小也会逐渐增长。例如基于我当前博客内容生成的索引文件,体积已经达到约 6MB:

如果直接在前端完整加载整个文件,不仅会增加网络传输开销,还会带来额外的浏览器解析成本。
从实际运行情况来看,这套脚本本身对系统资源的要求并不高。整个过程主要由顺序请求、文本处理以及简单的相似度计算组成,在当前的数据规模下,即使是在常见的 2 核 CPU、2~3GB 内存的 VPS 上,也可以稳定运行。以我芝加哥的VPS为例,3核、4.5G内存,脚本从开始运行到结束并输出semantic-index.json文件,花费的时间才20秒左右。
因此,对于大多数个人博客场景而言,这种方案在部署成本与运行开销之间,已经能够取得一个比较理想的平衡。
4 基于 semantic-index.json 的功能扩展
在完成 semantic-index.json 的生成之后,整个博客的语义索引系统其实才刚刚进入“可使用”阶段。需要明确的一点是:这份 JSON 文件本身,并不是某一个具体功能的最终实现,它更像是一层位于“内容”与“功能”之间的中间结构,用于承接后续所有与语义相关的扩展能力。
换句话说,脚本生成 semantic-index.json 的过程,本质上是在提前完成一次“语义预处理”:原本需要在运行时动态计算的内容关系,被提前固化为一份结构化结果。而后续功能真正需要做的事情,往往只是围绕这份结果进行读取、筛选与重新组织。
因此,从系统结构上来看,整个流程其实更接近于这样一种关系:
WordPress 原始文章
↓
语义索引生成脚本
↓
semantic-index.json
↓
不同功能按需提取与加工
↓
最终功能输出
这也意味着:后续扩展功能的重点,已经不再是“重新理解文章”,而是“如何利用已有的语义结果”。
例如,对于相关文章推荐功能来说,真正需要使用的数据,可能仅仅只是 related 字段中的前几条结果;而对于内容聚合功能,则可能更关注 keywords 与文章之间的关联关系;如果是搜索、导航或 AI 摘要类功能,则可能会更多依赖 summary、title 甚至原始内容字段。不同功能,对数据的关注重点并不相同——但它们有一个共同点:都建立在同一份 semantic-index.json 之上。
从这个角度来看,这份文件真正重要的地方,其实并不在于“JSON”本身,而在于其中保存的是一套已经提前计算完成的语义关系结果。也正因为如此,后续博客的扩展功能在运行过程中,通常不需要再次遍历全文、重新计算文章相似度,或者重新进行内容分析,而只需要读取已经存在的数据即可。
这种方式的核心价值,本质上是一种“离线预计算”的思路——将复杂计算集中放在生成阶段完成,而在实际使用阶段尽可能降低运行成本。对于个人博客而言,这一点其实非常重要因为它意味着:即使没有向量数据库、没有 embedding 模型、没有 GPU 或复杂的 AI 基础设施,也依然能够构建出一套具备“语义能力”的内容系统。
虽然这种方式在理论表达能力上无法与真正的向量语义系统相比,但在个人博客这样的场景下,它却具备一个非常明显的优势:低成本、高可控、易维护,并且已经能够解决大量真实问题。
很多时候,一个真正长期可运行、可持续维护的工程化方案,其实际价值,往往比“理论上最先进”的方案更高。
而当前这套基于 semantic-index.json 的结构,本质上正是在复杂度、成本与效果之间所做的一种平衡。
5 总结与展望
回过头来看,这一整套实现,本质上并不局限于某一个具体功能的实现,而是在为博客引入一层新的能力:让原本只面向人类阅读的内容,开始具备被机器处理和理解的基础。而本文所采用的这一套实现方式,正是围绕这层能力所做的一种工程化落地。
目前,这份语义索引采用的是基于关键词、标题和摘要的加权计算方式,本质上是一种工程化的语义近似。它并不试图完整理解语言,而是在可控成本下,对内容进行压缩表达,并在此基础上建立文章之间的关系。这种方式可以看作是一种折中甚至过渡方案:在数据规模较小或标签体系相对稳定的情况下,它已经能够提供足够可用的结果,同时保持实现简单、可解释、易于调试。
某种意义上,这也是这套方案最重要的价值所在:它并不是在追求“理论上最强”的语义能力,而是在尝试寻找一种更适合个人博客场景的平衡点。在很多技术讨论中,“语义处理”几乎会天然指向 embedding、向量数据库以及大模型相关体系。但对于个人博客而言,这类方案虽然能力更强,却往往也意味着更高的复杂度:需要额外的模型运行环境、更复杂的数据存储结构,以及后续持续的维护成本。
而当前这套方案,实际上提供了另一种思路:在不引入重型系统的前提下,通过合理的结构设计与离线预计算机制,同样可以建立一层具备实际价值的语义关系网络。这种方式的核心优势,并不只是“实现简单”,而是它能够以极低成本进入“语义化内容组织”的阶段。
对于很多个人博客来说,真正缺少的往往并不是最先进的语义模型,而是一层能够让内容彼此建立关联的基础结构。而当前这套方案,恰恰解决的是这个问题:它让原本孤立的文章,开始形成关系;让博客从“按时间堆叠的内容集合”,逐渐演变为一个具有内部连接能力的内容系统。
当然,从更长远的角度来看,这并不是终点。随着文章数量增长,以及对语义精度要求的提升,这种基于规则与字段组合的方式,终究会逐渐接近上限。未来更自然的演进方向,仍然会是向量化表示(embedding):将文本映射到更高维的语义空间中,通过距离而非规则来衡量内容之间的关系,从而获得更细粒度、更具泛化能力的语义表达。
但即便如此,当前这一层结构也并不会因此失去价值。因为无论未来是否引入 embedding,文章结构、语义字段、关联关系以及索引体系本身,依然是整个系统的重要基础。换句话说,当前这套实现并不是一条“错误路线”,而更像是一种可持续演进的中间层:它既能够在当前阶段直接发挥实际作用,也为后续升级保留了足够空间。
从实现过程来看,这次实践本身其实也验证了一件事:在很多场景下,并不一定需要一开始就引入复杂模型或重型系统。通过合理的结构设计和适当的工程化手段,同样可以在较低成本下获得一套足够可用的语义近似,并随着需求增长逐步演进。
这种“从可用出发,再逐步逼近更高语义能力”的路径,对于个人项目而言,往往比一开始追求完整的大模型体系更现实,也更可持续。
而这,也正是这次尝试真正想要达到的状态。