1 为什么还需要一个“猜你所想”
在之前的文章中(参见:为博客构建“轻量级知识索引”(三):基于预计算语义索引的 WordPress 轻量推荐系统设计与实现),我在”内容结构提示”的下方新增了一个”相关文章”功能(之前叫”推荐阅读”,不过后来觉得”推荐阅读”这个说法覆盖范围太广,所以名字改成了更准确的”相关文章”)。
实际运行了一段时间之后,我对这个功能还是比较满意的——由于推荐逻辑建立在文章标题、标签、分类以及正文内容等显式结构之上,因此大多数情况下都能够推荐出与当前文章高度相关的内容。
不过也有一个问题始终让我有些介意,那就是”相关文章”右侧留下了一大片空白区域,看起来总有种没填满的感觉:

既然空着也是空着,我自然会琢磨着能不能再放点什么推荐内容进去。但问题在于,“相关文章”本身已经很好地完成了”找出相似文章”这件事情,如果继续基于分类、标签、系列文章、知识地图等显式结构去做推荐,那么最终得到的结果大概率还是和”相关文章”高度重叠,甚至出现大量重复内容。换句话说,如果新的功能只是换一种类似的算法重新推荐相同的文章,那么它存在的意义其实并不大。
既然左侧的”相关文章”负责挖掘显式关联,那么右侧的内容或许应该承担另一种职责:尝试发现那些隐藏在文章表面之下、无法通过分类和标签直接描述出来的潜在关联。而在目前比较成熟的技术方案中,最适合承担这项工作的,恰恰就是向量化之后形成的语义空间(不熟悉向量概念的朋友,可以参考我另一篇文章:向量:AI 世界里的通用语言)。
因此,我决定在右侧新增一个全新的推荐模块——“猜你所想”。与”相关文章”依赖显式结构不同,它将完全基于文章 Embedding 生成的向量数据,通过语义相似度来寻找那些可能存在关联、但又未必属于同一分类或标签体系下的文章。
2 “猜你所想”功能的实现思路
2.1 为什么不直接使用 现成的article-index.json
按照这个系列文章前几篇的思路,无论是“相关文章”还是“系列文章卡片”,最终都是基于 WordPress 文章内容生成 semantic-index.json,再经过整理得到 article-index.json,最后由 WordPress 前端直接读取 article-index.json 来完成展示。
从直觉上看,“猜你所想”似乎也可以直接复用这套结构:article-index.json 已经包含文章标题、URL、摘要等信息,只要再补充一些字段,理论上同样可以用于 Embedding 计算。但在实际评估后,我还是放弃了这个方案。问题并不在于字段是否足够,而在于它的设计目标本身就不适合 Embedding。
article-index.json 本质上是一个面向前端展示的数据结构,它的核心价值是“可展示性”,而不是“语义完整性”。其中保留的更多是经过整理后的结果信息,例如标题、链接、摘要、分类标签、系列关系以及主题空间等。这些内容非常适合构建“相关文章”,因为它依赖的是显式结构关系。
但 Embedding 的输入要求完全不同,它依赖的是文章的原始语义表达,而不是已经被结构化处理过的结果。如果继续使用 article-index.json,会带来一个直接问题:语义在进入 Embedding 之前已经被压缩了一次。例如 HTML 标签、代码块、非结构化内容在这一层已经被清理或弱化,上下文信息也被摘要化处理。对于展示系统这是合理的,但对于语义建模,这意味着信息损失。
更关键的是,article-index.json 本身已经包含完整的显式结构体系(分类、标签、系列、知识地图等)。这套体系在“相关文章”中是优势,因为它让推荐变得稳定且可解释,但在“猜你所想”中反而会形成约束——因为这个功能的目标正是跳出显式结构,去发现那些无法通过分类或标签表达的潜在关联。如果仍然依赖这些结构字段,最终结果很可能又会收敛到同分类、同标签、同系列这一类模式。这样一来,“猜你所想”和“相关文章”的差异就会被明显削弱。
如果往上追溯到”相关文章”功能中第一步生成的 semantic-index.json,从技术上来说,它其实已经具备进行 Embedding 的条件,因为其中仍然保留了文章标题、正文内容以及摘要等信息。但 semantic-index.json 的设计目标并不是为了 Embedding,而是作为后续语义索引体系的中间层存在。除了正文内容之外,它还混合了关键词、标签、相关文章等面向索引和推荐系统的数据结构。
换句话说,semantic-index.json 虽然能够用于 Embedding,但它本身属于一份“多用途索引数据”,而不是专门为向量化准备的语义输入层。
因此,这里实际上存在两种不同的设计思路:第一种做法是直接复用 semantic-index.json,从中提取 content 或 summary 字段后生成向量;第二种做法则是单独构建一份专门面向 Embedding 的数据源。
而最终,我选择了后者——我没有在现有索引体系上继续扩展,而是重新构建了一条独立的Embedding专属数据处理链路:
WordPress 原始内容
↓
semantic-source.json
↓
semantic-vector.json
↓
semantic-path.json
选择重新构建Embedding数据链路的原因
Embedding 链路未来很可能会持续扩展,例如文章级向量、段落级向量、知识节点向量、主题聚类等能力,都可能依赖同一套语义输入源。
如果继续复用 semantic-index.json,那么向量系统将与现有索引体系产生耦合;而单独引入 semantic-source.json 之后,Embedding 链路便拥有了独立的数据入口,后续无论增加新的语义计算能力,还是调整索引体系结构,都不会相互影响。
2.2 semantic-source.json:构建适合 Embedding 的语义源
在这条新的数据链路中,第一步就是生成 semantic-source.json。它并不是 semantic-index.json 的延续,而是绕开原有索引体系,从 WordPress 原始内容重新提取的一份专门面向 Embedding 的语义输入层。
之所以需要这样一个中间层,是因为 WordPress 中保存的原始文章内容并不适合直接用于 Embedding 计算。一篇文章除了正文之外,往往还包含大量 HTML 标签、代码块、图片链接、短代码以及各种格式化信息。这些内容对于网页展示来说必不可少,但对于语义模型而言,其中相当一部分都属于噪声数据。
因此,在进入 Embedding 阶段之前,首先需要对文章内容进行一次专门面向语义计算的预处理,尽可能保留文章真正表达的内容,同时剔除那些与语义理解关系不大的部分。
经过处理之后,每篇文章最终会被整理成一个非常简单的结构:
{
"id": "14257",
"title": "为博客构建“轻量级知识索引”(三):基于预计算语义索引的 WordPress 轻量推荐系统设计与实现",
"url": "/technology/homedatacenter14257/",
"content": "..."
}
其中:
- id 用于后续关联文章;
- title 保存文章标题;
- url 用于最终跳转;
- content 则保存用于生成 Embedding 的语义文本。
整个文件的核心其实就在这个 content 字段上。
如果只是简单地将文章全文导出,虽然也能生成向量,但效果未必理想:一方面,博客文章长度差异较大,有些只有几百字,有些则可能达到上万字;另一方面,并非所有内容对于语义建模都具有相同价值。
以技术文章为例,大量配置文件、命令行输出、代码示例虽然对读者很重要,但对于判断文章主题来说,其贡献往往远不如文章标题、核心论述以及关键段落。
因此,semantic-source.json 的目标并不是保存文章全文,而是提取出一份能够代表文章核心语义的信息摘要。
在设计时,我采用了一个相对简单的策略:首先保留文章标题;然后从正文中提取最具有代表性的内容;对于过长的文章,则通过长度限制控制最终输出规模,避免单篇文章占用过多上下文空间。这样做的目的并不是追求绝对完整,而是在语义保真与处理效率之间寻找一个平衡点。
从结果来看,semantic-source.json 更像是一份专门为 Embedding 准备的“语义精简版文章库”。它保留了文章最核心的表达内容,同时又避免了大量与语义计算无关的噪声信息。
有了这份经过整理的语义源数据之后,下一步就可以将这些文本进一步转换为向量表示,从而进入真正的 Embedding 阶段。
2.3 semantic-vector.json:将文章转换为向量
当 semantic-source.json 准备完成之后,下一步要做的事情就很明确了:把这些文本内容转换成机器能够理解和计算的向量。
对于人来说,理解一篇文章依靠的是语言、经验和上下文。例如当我们看到“家庭数据中心”、“PVE虚拟化平台”、“Cloudflare Tunnel”等词汇时,会自然联想到它们之间的关系,甚至能够判断两篇文章是否在讨论相近的话题。
但计算机并不具备这种能力,在它眼中,文本本质上只是字符序列,并不存在“意义”这个概念。因此,如果希望让系统能够自动发现文章之间的潜在关联,就必须先把文本映射到一种可以进行数学计算的表示形式。
Embedding 模型所做的事情,本质上就是完成这种转换。它会读取文章内容,并将其编码成一个高维向量。对于 Ollama中的 nomic-embed-text 模型来说,每篇文章最终都会被转换成一个 768 维的浮点数数组。这个数组本身并不具备可读性,但它在向量空间中的位置,却能够反映文章所表达的语义特征。
简单来说,语义越接近的文章,在向量空间中的距离通常越近;而主题差异越大的文章,则会分布得更远。
例如:
- 关于 Cloudflare Tunnel 的两篇文章,向量位置通常会比较接近;
- 关于 PVE 虚拟化与家庭数据中心的文章,也可能形成一个相对集中的区域;
- 与此相对,佛学思考、人生感悟等非技术主题,则往往会分布在距离这些技术文章较远的位置。
这也是 Embedding 能够实现语义推荐的核心原因:系统不再依赖分类、标签或系列关系,而是直接根据文章本身的语义相似度来寻找关联内容。
经过向量化处理之后,最终会生成 semantic-vector.json。
与 semantic-source.json 相比,这个文件不再保存完整正文,而是保存文章对应的向量结果以及必要的元数据。例如:
{
"id": "14289",
"title": "AI时代的创作重构:从信息处理到认知协作",
"url": "/technology/cognition14289/",
"vector": [0.0123, -0.0841, ...]
}
其中:
- id 用于与 WordPress 文章建立对应关系;
- title 和 url 主要用于后续展示和调试;
- vector 则是 Embedding 模型生成的语义向量。
semantic-vector.json 可以理解为整个语义推荐系统中的“计算层”:如果说 semantic-source.json 保存的是文章的原始语义表达,那么 semantic-vector.json 保存的则是这些语义在向量空间中的坐标位置——它承担的职责不是展示内容,而是为后续的相似度计算提供基础数据。
不过,到了这一步,系统仍然无法直接向用户提供推荐结果。因为 semantic-vector.json 只记录了每篇文章的向量,却还没有计算文章之间的关系网络。
因此接下来还需要进行最后一步处理:根据这些向量之间的距离,提前计算出每篇文章最接近的邻居节点,并将结果保存下来。
这也就是下一节要介绍的 semantic-path.json。
2.4 semantic-path.json:基于向量的语义关联关系
当 semantic-vector.json生成之后,语义计算链路其实已经完成了最关键的一步:每篇文章都被映射到了高维向量空间中。
但如果只停留在这一层,系统仍然是不可用的。原因很简单:向量本身并不能直接用于推荐。它只能表达“这篇文章是什么”,但无法回答“它和谁更接近”。而“猜你所想”真正需要的,是文章之间的关系,而不是单篇文章的语义描述。
因此这一层要解决的问题非常明确:把向量空间中的距离关系,转化为可直接使用的结构化关联。实现方式本质上是一次批量相似度计算。系统会以每篇文章为基准,与全量文章进行向量比对,通常使用余弦相似度(cosine similarity)作为度量标准,并选出 Top-K 个最相近的文章,作为它的语义邻居。
在实际存储结构中,semantic-path.json 会把这种关系固化为非常直接的数据形式。例如,一篇文章的结构可能如下所示:
{
"id": "14289",
"links": [
{
"target_id": "14136",
"score": 0.87
},
{
"target_id": "14228",
"score": 0.82
},
{
"target_id": "14164",
"score": 0.79
}
]
}
其中:
- id 表示当前文章;
- links 表示与其语义最接近的文章集合;
- target_id 是被关联的文章;
- score 是向量相似度计算得到的匹配分数,用于排序和筛选 Top-K 结果。
这样一来,原本存在于高维空间中的“距离关系”,就被压缩成了一种结构化数据,这就是 semantic-path.json的来源。
从系统设计上看,这一层的作用是把原本需要在访问时实时计算的相似度查询,提前到构建阶段完成。运行时不再做向量计算,而是直接读取预计算好的关系结果。如果没有这一层,“猜你所想”就必须在每次请求时加载全部向量并进行全量计算与排序。在内容规模较小时问题不大,但随着文章增长,这会迅速成为性能瓶颈。
而semantic-path.json的意义就在于把这一步“前移”:用离线计算换取在线性能,让推荐逻辑变成纯数据读取。当然,这种设计也意味着一种取舍:文章之间的关系在生成时被固化。一旦构建完成,后续推荐结果不会随着模型或数据细微变化实时调整,而是依赖当时计算出的结构。
但对于个人博客这种内容规模有限、更新节奏相对稳定的系统来说,这种取舍是合理的。它换来的是更高的稳定性、更简单的运行时逻辑,以及更可控的系统复杂度。
到这一层为止,整个 Embedding 链路其实已经闭合:
WordPress 原始内容
↓
semantic-source.json (语义输入层)
↓
semantic-vector.json (向量表示层)
↓
semantic-path.json (语义关系层)
↓
猜你所想 / 语义推荐系统
如果抽象来看,这三步分别对应语言表达、数学表示和关系建模,而semantic-path.json就是从“向量空间”回到“可用结构”的那一层。
到这里,“猜你所想”的底层数据基础已经完成,后续只需要让WordPress利用这些关系结果,构建稳定、可解释的推荐呈现机制即可。
3 从语义计算到推荐展示
3.1 semantic-source.json 生成脚本
在上一章中,我们介绍了 semantic-source.json 的作用与数据结构。它是整条 Embedding 数据链路的起点,负责从 WordPress 原始内容中提取适合语义计算的输入数据。
因此第一步需要完成的工作,就是从数据库中读取文章内容,并生成 semantic-source.json。对应的脚本”build_semantic_source.py”的代码如下:
import requests
import re
import html
import json
import os
from urllib.parse import urlparse
# 根据实际环境修改
BASE_URL = "http://127.0.0.1"
# ⭐ semantic content 总长度限制
MAX_CONTENT_LENGTH = 8000
# ⭐ 单段最大长度
MAX_PARAGRAPH_LENGTH = 800
# -------------------------
# Step 1: 获取 WordPress 文章
# -------------------------
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
# -------------------------
# URL -> path
# -------------------------
def extract_path(url: str) -> str:
if not url:
return ""
return urlparse(url).path
# -------------------------
# HTML 清洗(保留章节结构)
# -------------------------
def clean_html(raw_html):
text = raw_html or ""
text = re.sub(
r"<pre.*?>.*?</pre>",
" ",
text,
flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(
r"<code>.*?</code>",
" ",
text,
flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(
r"<script.*?>.*?</script>",
" ",
text,
flags=re.DOTALL | re.IGNORECASE
)
text = re.sub(
r"<style.*?>.*?</style>",
" ",
text,
flags=re.DOTALL | re.IGNORECASE
)
# shortcode
text = re.sub(r"\[/?[^\]]+\]", " ", text)
# 标题
text = re.sub(
r"</?(h1|h2|h3|h4|h5|h6)[^>]*>",
"\n",
text,
flags=re.IGNORECASE
)
# 段落
text = re.sub(
r"</?p[^>]*>",
"\n",
text,
flags=re.IGNORECASE
)
# br
text = re.sub(
r"<br\s*/?>",
"\n",
text,
flags=re.IGNORECASE
)
# 删除剩余 HTML
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text)
# URL
text = re.sub(r"https?://\S+", " ", text)
# 特殊符号
text = re.sub(
r"[丨||•·■◆►▶●]+",
" ",
text
)
# 压缩空格(保留换行)
text = re.sub(r"[ \t]+", " ", text)
# 压缩连续换行
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# -------------------------
# 将正文分段
# -------------------------
def split_paragraphs(text):
paragraphs = [
p.strip()
for p in re.split(r"\n+", text)
if p.strip()
]
result = []
for p in paragraphs:
while len(p) > MAX_PARAGRAPH_LENGTH:
result.append(p[:MAX_PARAGRAPH_LENGTH])
p = p[MAX_PARAGRAPH_LENGTH:]
if p:
result.append(p)
return result
# -------------------------
# Step 2: semantic content 构建
# -------------------------
def build_semantic_content(title, clean_text):
paragraphs = split_paragraphs(clean_text)
if not paragraphs:
return title
filtered = []
for p in paragraphs:
p = p.strip()
# 太短
if len(p) < 80:
continue
# 纯章节标题
if re.match(r"^\d+(\.\d+)*\s+", p):
if len(p) < 40:
continue
filtered.append(p)
if not filtered:
filtered = paragraphs
total = len(filtered)
selected = []
# 开头部分
selected.extend(filtered[:3])
# 全文均匀采样
if total > 10:
sample_ratios = [
0.10,
0.20,
0.30,
0.40,
0.50,
0.60,
0.70,
0.80,
0.90
]
for ratio in sample_ratios:
pos = int(total * ratio)
if 0 <= pos < total:
selected.append(filtered[pos])
# 结尾部分
if total >= 2:
selected.extend(filtered[-2:])
elif total >= 1:
selected.append(filtered[-1])
# 去重
unique_paragraphs = []
seen = set()
for p in selected:
if p not in seen:
unique_paragraphs.append(p)
seen.add(p)
semantic_text = (
f"{title}\n"
+ "\n".join(unique_paragraphs)
)
return semantic_text[:MAX_CONTENT_LENGTH].strip()
# -------------------------
# Step 3: 构建 semantic-source
# -------------------------
def build_semantic_source(posts):
articles = []
for post in posts:
raw_content = post.get("content", {}).get("rendered", "")
clean_text = clean_html(raw_content)
title = post.get("title", {}).get("rendered", "")
semantic_content = build_semantic_content(
title,
clean_text
)
article = {
"id": str(post.get("id", "")),
"title": title,
"url": extract_path(post.get("link", "")),
"content": semantic_content
}
articles.append(article)
return articles
# -------------------------
# 输出 JSON
# -------------------------
def write_json(data, 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-source.json"
)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(
data,
f,
ensure_ascii=False,
indent=2
)
print(f"\nJSON written to: {output_path}")
# -------------------------
# Main
# -------------------------
if __name__ == "__main__":
posts = fetch_posts(BASE_URL)
print(f"\nTotal posts fetched: {len(posts)}")
semantic_source = build_semantic_source(posts)
print(
f"Semantic source built: "
f"{len(semantic_source)} articles"
)
write_json(semantic_source)
print("\nSample article:\n")
if semantic_source:
print(
json.dumps(
semantic_source[0],
ensure_ascii=False,
indent=2
)
)
这个脚本的逻辑其实并不复杂。
首先,它会通过 WordPress REST API 获取所有已发布文章,然后提取文章标题、URL 和正文内容。由于后续需要进行 Embedding 计算,因此这里关注的重点并不是页面展示效果,而是文章本身所表达的语义信息。
在获取正文之后,脚本会先进行一次内容清洗。WordPress 文章中通常包含大量与展示相关的信息,例如 HTML 标签、代码块、短代码、样式内容以及各种链接。这些内容对于页面渲染是必要的,但对于语义计算来说往往属于噪声。因此在这一阶段,会尽量保留文章的自然语言表达,同时移除与语义无关的内容,从而提高后续向量化处理的质量。
完成清洗之后,脚本会将正文按照段落进行拆分,并过滤掉过短的内容以及仅包含章节编号的标题。这样做的目的,是尽可能让后续参与 Embedding 的内容都具备相对完整的语义表达,而不是被大量零散的标题或导航信息所干扰。
随后,脚本不会直接使用整篇文章,而是从全文中抽取一组具有代表性的内容片段。除了保留文章开头部分用于体现主题背景之外,还会按照固定比例从全文不同位置进行均匀采样,并额外保留结尾部分的内容。相比单纯截取前几段或固定长度文本,这种方式能够更好地覆盖文章整体结构,使生成的语义表示不仅反映文章开头讨论的话题,也能够保留中后段的重要信息。
最后,脚本会将标题与这些经过筛选的内容片段组合成统一的语义文本,并写入 semantic-source.json。后续的 Embedding 过程便不再直接处理 WordPress 原始文章,而是基于这份经过预处理和压缩后的语义数据进行向量化计算。
3.2 semantic-vector.json 生成脚本
进行这一节操作的前提,是拥有一个可以生成 Embedding 的嵌入模型。
如果对成本和部署方式没有特别要求,直接调用 OpenAI 的 text-embedding-3-small,或者其他主流大模型服务商提供的 Embedding 接口即可。这些商业模型通常具有较好的中文理解能力和语义表达能力,实现起来也相对简单。
不过对于个人博客这类场景来说,文章数量往往会持续增长。如果每次重新生成语义索引都需要调用云端 API,长期下来既会产生额外成本,也会增加对外部服务的依赖。
由于我之前写过在家庭数据中心中通过 Ollama 部署本地嵌入模型 nomic-embed-text的文章,因此本文中直接使用nomic-embed-text完成向量生成工作(不熟悉的朋友可以参考我之前的文章:让 Embedding 成为基础设施:在 PVE + LXC 中部署独立嵌入服务。当然,如果有M系列的macmini来部署Ollama更好,embedding速度会快很多,比如实际生产环境我是直接使用Qwen3-embedding:8B的嵌入模型,具体搭建及设置方式参考我之前的文章:在 Mac mini(M4 Pro)上部署 Llama 3.2:通过 Ollama 实现高效运行与跨域访问优化全攻略)。
需要说明的是,本文的重点并不在于具体使用哪一种 Embedding 模型(例如 OpenAI 的商业模型、本地部署的 nomic-embed-text 或 Qwen 系列 embedding 模型等),而在于语义索引链路的构建方法。因此,只要模型能够提供标准的 Embedding 接口,其整体实现思路是一致的。
不过,不同模型在语义表达能力、计算资源消耗以及中文语境适配性上可能存在差异,但这些差异属于实现层面的选择问题,不影响整体架构设计。
完成 semantic-source.json 之后,下一步就是将文章转换为向量表示。因此这一阶段的任务非常明确:读取 semantic-source.json,并为每篇文章生成对应的向量数据。
对应脚本build_semantic_vector.py的代码如下:
import json
import requests
import os
import time
import numpy as np
import hashlib
# -------------------------
# Ollama embedding API
# -------------------------
# 根据实际环境进行修改
OLLAMA_URL = "http://127.0.0.1:11434/api/embed"
MODEL_NAME = "nomic-embed-text"
# -------------------------
# config
# -------------------------
MAX_SEGMENT_LENGTH = 1500
MAX_SEGMENTS = 8
# -------------------------
# load data
# -------------------------
def load_semantic_source(input_path=None):
if input_path is None:
base_dir = os.path.dirname(os.path.abspath(__file__))
input_path = os.path.join(base_dir, "semantic-source.json")
with open(input_path, "r", encoding="utf-8") as f:
data = json.load(f)
print(f"Loaded semantic source: {len(data)} articles")
return data
# -------------------------
# load cache
# -------------------------
def load_existing_vectors(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-vector.json")
if not os.path.exists(output_path):
return {}
with open(output_path, "r", encoding="utf-8") as f:
data = json.load(f)
return {item["id"]: item for item in data}
# -------------------------
# hash
# -------------------------
def make_hash(text):
return hashlib.md5(text.encode("utf-8")).hexdigest()
# -------------------------
# split text
# -------------------------
def split_text_for_embedding(text):
text = text.strip()
if not text:
return []
paragraphs = []
for line in text.split("\n"):
line = line.strip()
if not line:
continue
while len(line) > MAX_SEGMENT_LENGTH:
paragraphs.append(line[:MAX_SEGMENT_LENGTH])
line = line[MAX_SEGMENT_LENGTH:]
if line:
paragraphs.append(line)
return paragraphs[:MAX_SEGMENTS]
# -------------------------
# embedding
# -------------------------
def get_embedding(text, retries=3):
payload = {
"model": MODEL_NAME,
"input": text
}
for i in range(retries):
try:
resp = requests.post(OLLAMA_URL, json=payload, timeout=120)
if resp.status_code == 200:
data = resp.json()
emb = data.get("embeddings", [])
if emb:
return emb[0]
print(f"[Retry {i}] HTTP {resp.status_code}: {resp.text}")
except Exception as e:
print(f"[Retry {i}] Exception: {e}")
time.sleep(1.5 * (i + 1))
raise Exception("Embedding failed")
# -------------------------
# build vectors (incremental)
# -------------------------
def build_semantic_vectors(articles, existing):
vectors = []
total = len(articles)
for index, article in enumerate(articles, start=1):
title = article.get("title", "")
content = article.get("content", "")
article_id = article.get("id", "")
content_hash = make_hash(title + content)
cached = existing.get(article_id)
# -------------------------
# skip unchanged
# -------------------------
if cached and cached.get("hash") == content_hash:
print(f"[{index}/{total}] Skip: {title}")
vectors.append(cached)
continue
print(f"[{index}/{total}] Embedding: {title}")
segments = split_text_for_embedding(content)
if not segments:
print(f"[WARN] Empty content: {title}")
continue
embeddings = []
for seg in segments:
print(f"Segment length: {len(seg)}")
emb = get_embedding(seg)
if emb:
embeddings.append(np.array(emb))
time.sleep(0.1)
if not embeddings:
continue
avg_vector = np.mean(embeddings, axis=0).tolist()
vectors.append({
"id": article_id,
"title": title,
"hash": content_hash,
"vector": avg_vector
})
return vectors
# -------------------------
# write
# -------------------------
def write_json(data, 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-vector.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
print(f"\nJSON written to: {output_path}")
# -------------------------
# main
# -------------------------
if __name__ == "__main__":
articles = load_semantic_source()
existing = load_existing_vectors()
vectors = build_semantic_vectors(articles, existing)
write_json(vectors)
print("\nSample vector:\n")
if vectors:
print({
"id": vectors[0]["id"],
"title": vectors[0]["title"],
"vector_dimensions": len(vectors[0]["vector"])
})
与上一节生成 semantic-source.json 不同,这一步真正开始进入 Embedding 计算阶段。
脚本首先读取 semantic-source.json 中整理好的文章内容,并尝试加载已经存在的向量缓存(semantic-vector.json),如果没找到则新建一个。对于每篇文章,脚本会计算其标题和内容的哈希值,用于判断该文章是否已经生成过语义向量且内容未发生变化。只有内容发生更新的文章,或者全新的文章,才会被发送给本地部署的 Ollama 服务,由 nomic-embed-text 模型生成对应的语义向量;未变化的文章将直接复用缓存向量,从而节省重复计算时间。
考虑到部分文章篇幅较长,如果一次性提交全部内容,不仅会增加模型处理压力,也可能影响向量质量。因此脚本会先对内容进行分段处理,将文章拆分成多个较小的语义片段,分别进行 Embedding 计算。
当所有分段完成向量化之后,再对这些分段向量求平均值,得到最终代表整篇文章的语义向量。
这种增量计算与缓存机制的结合,并不是追求绝对精确,而是在计算成本、响应时间与语义覆盖范围之间取得一个相对合理的平衡。对于个人博客这类内容规模有限的场景来说,这种方式已经能够获得足够稳定的语义表达能力。
到了这一层,文章已经不再是传统意义上的文本,而是被转换成计算机可以进行数学运算和相似度比较的向量表示。后续 semantic-path.json 的生成,也正是建立在这些向量数据之上。
注意:
本实现最初基于调试阶段的 embedding 模型 nomic-embed-text 构建,在语义索引系统的设计中,embedding 模型本质上属于“语义空间的定义者”,因此,embedding 模型的替换并不是一个简单的“模型名称切换”,而是可能影响整个语义检索结果分布的系统级变化。
不同模型(比如调试阶段使用的nomic-embed-text 和我当前实际使用的 qwen3-embedding:8b)在以下方面存在差异:
- 输入 tokenization 方式
- 向量空间分布结构
- API 参数与返回格式
- 表达语义的压缩策略
本代码保留 nomic-embed-text 版本作为基准实现,其主要价值在于:
1. 资源消耗较低,适合本地调试与快速验证;
2. 语义表达相对稳定,便于构建初始索引体系;
3. 作为轻量级基线模型,有助于对比不同 embedding 模型的效果差异。
在生产环境中(如当前使用的 qwen3-embedding:8b),需要基于实际模型特性对 embedding 请求与索引生成逻辑进行适配调整,而不是仅替换模型名称。
3.3 semantic-path.json 生成脚本
完成 semantic-vector.json 之后,下一步就是建立文章之间的语义关联关系。
因此这一阶段的任务也非常明确:读取 semantic-vector.json 中保存的文章向量,并计算文章之间的相似度,最终生成 semantic-path.json并保存在WordPress的以下路径中:
`wp-content/themes/主题目录/cache/semantic-path.json`
因为我使用的是argon主题,所以对应的保存路径为:
wp-content/themes/argon-theme-master/cache/semantic-path.json
这一步对应的脚本为build_semantic_path.py:
import json
import os
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
# -------------------------
# 配置
# -------------------------
TOP_K = 6
SEMANTIC_THRESHOLD = 0.72
# 排除一些不需要处理的url,这里是我博客的那些地图
EXCLUDED_PATH_KEYWORDS = [
"/cloudflaremap/",
"/aimap/",
"/singingmap/",
"/roadmap/"
]
# -------------------------
# Step 1
# -------------------------
def load_semantic_source(input_path=None):
if input_path is None:
base_dir = os.path.dirname(
os.path.abspath(__file__)
)
input_path = os.path.join(
base_dir,
"semantic-source.json"
)
with open(
input_path,
"r",
encoding="utf-8"
) as f:
data = json.load(f)
print(
f"Loaded semantic source: {len(data)} articles"
)
return data
# -------------------------
# Step 2
# -------------------------
def load_semantic_vector(input_path=None):
if input_path is None:
base_dir = os.path.dirname(
os.path.abspath(__file__)
)
input_path = os.path.join(
base_dir,
"semantic-vector.json"
)
with open(
input_path,
"r",
encoding="utf-8"
) as f:
data = json.load(f)
print(
f"Loaded semantic vectors: {len(data)} articles"
)
return data
# -------------------------
# 过滤地图页
# -------------------------
def is_excluded(article):
url = article.get("url", "")
return any(
keyword in url
for keyword in EXCLUDED_PATH_KEYWORDS
)
# -------------------------
# Step 3
# -------------------------
def build_article_map(
source_data,
vector_data
):
vector_map = {
item["id"]: item
for item in vector_data
}
article_map = {}
for item in source_data:
if is_excluded(item):
continue
article_id = item["id"]
vector_item = vector_map.get(article_id)
if not vector_item:
continue
article_map[article_id] = {
"id": article_id,
"title": item.get("title", ""),
"url": item.get("url", ""),
"vector": vector_item.get(
"vector",
[]
)
}
print(
f"Merged articles: {len(article_map)}"
)
return article_map
# -------------------------
# semantic similarity
# -------------------------
def calculate_semantic_similarity(a, b):
va = np.array(
a["vector"]
).reshape(1, -1)
vb = np.array(
b["vector"]
).reshape(1, -1)
return float(
cosine_similarity(va, vb)[0][0]
)
# -------------------------
# semantic recommendation
# -------------------------
def build_semantic_recommendation(
current_article,
article_map
):
scored = []
for article_id, article in article_map.items():
if article_id == current_article["id"]:
continue
if is_excluded(article):
continue
sim = calculate_semantic_similarity(
current_article,
article
)
scored.append({
"id": article["id"],
"title": article["title"],
"url": article["url"],
"score": sim
})
scored.sort(
key=lambda x: x["score"],
reverse=True
)
filtered = [
item
for item in scored
if item["score"] >= SEMANTIC_THRESHOLD
]
if len(filtered) < TOP_K:
filtered = scored[:TOP_K]
return filtered[:TOP_K]
# -------------------------
# 主流程
# -------------------------
def build_semantic_path(article_map):
results = {}
total = len(article_map)
for idx, (
article_id,
article
) in enumerate(
article_map.items(),
start=1
):
print(
f"[{idx}/{total}] "
f"{article['title']}"
)
items = build_semantic_recommendation(
article,
article_map
)
results[article_id] = {
"article_id": article_id,
"article_title": article["title"],
"article_url": article["url"],
"items": items
}
return results
# -------------------------
# 输出 JSON,需要根据实际场景进行修改,这里我是直接输出到自用的wordpress的主题目录下。
# -------------------------
def write_json(data, output_path=None):
if output_path is None:
output_path = (
"/docker/wordpress/html/"
"wp-content/themes/"
"argon-theme-master/cache/"
"semantic-path.json"
)
output_dir = os.path.dirname(
output_path
)
if (
output_dir
and not os.path.exists(output_dir)
):
os.makedirs(
output_dir,
exist_ok=True
)
with open(
output_path,
"w",
encoding="utf-8"
) as f:
json.dump(
data,
f,
ensure_ascii=False,
indent=2
)
print(
f"\nJSON written to: {output_path}"
)
# -------------------------
# Main
# -------------------------
if __name__ == "__main__":
source_data = load_semantic_source()
vector_data = load_semantic_vector()
article_map = build_article_map(
source_data,
vector_data
)
semantic_path = build_semantic_path(
article_map
)
write_json(semantic_path)
first_key = next(
iter(semantic_path)
)
print("\nSample:\n")
print(
json.dumps(
semantic_path[first_key],
ensure_ascii=False,
indent=2
)
)
脚本首先读取 semantic-vector.json 中保存的所有文章向量,然后计算文章之间的语义相似度。这里采用余弦相似度(Cosine Similarity)作为距离度量标准,用于衡量两篇文章在语义空间中的接近程度。
对于每篇文章,系统都会遍历其它文章的向量,并按照相似度从高到低进行排序。随后筛选出最相关的若干篇文章,作为该文章未来的推荐候选结果。
考虑到博客中存在各种导航页面、学习地图等特殊页面,这些内容虽然会参与站点结构建设,但并不适合作为推荐对象,因此脚本在计算过程中会主动将这些页面排除。
最终生成的 semantic-path.json 中,每篇文章都会保存一组预计算好的语义关联关系。
3.4 WordPress 前端集成
到这里,整个语义计算链路实际上已经全部完成。而对于 WordPress 前端来说,它并不需要理解 Embedding、向量空间或者相似度算法。它唯一需要做的事情其实非常简单:根据当前文章 ID,从 semantic-path.json 中找到对应的关联文章列表,然后将结果展示出来。整个前端阶段,本质上只是一次数据读取过程。
对应的WordPress中的php代码如下(左侧是”相关文章”功能,右侧是”猜你所想”功能):
function add_recommend_list_after_content(content) {
if (!is_single() || !in_the_loop() || !is_main_query()) {
returncontent;
}
post_id = get_the_ID();
// =========================
// 地图页 / 结构页过滤
// =========================map_slugs = [
'/map/',
'/cloudflaremap/',
'/aimap/',
'/roadmap'
];
current_url =_SERVER['REQUEST_URI'] ?? '';
foreach (map_slugs asslug) {
if (strpos(current_url,slug) !== false) {
return content;
}
}
// =========================
// 读取 article-index.json
// =========================article_index_file = get_template_directory() . '/cache/article-index.json';
if (!file_exists(article_index_file)) {
returncontent;
}
article_index = json_decode(
file_get_contents(article_index_file),
true
);
if (!isset(article_index[post_id])) {
return content;
}
// =========================
// 读取 semantic-path.json
// =========================semantic_path_file = get_template_directory() . '/cache/semantic-path.json';
semantic_path = [];
if (file_exists(semantic_path_file)) {
semantic_path = json_decode(
file_get_contents(semantic_path_file),
true
);
}
current =article_index[post_id];
// =========================
// 左侧:相关文章(article-index.json)
// =========================related_ids = current['related'] ?? [];related_list = [];
foreach (related_ids asrelated_id) {
if (!isset(article_index[related_id])) {
continue;
}
related_list[] = [
'title' =>article_index[related_id]['title'],
'url' =>article_index[related_id]['url']
];
}related_count = 5;
related_list = array_slice(related_list, 0, related_count);
// =========================
// 右侧:延伸阅读(semantic-path.json)
// =========================semantic_list = [];
semantic_count = 3;
if (!empty(semantic_path) && isset(semantic_path[post_id])) {
semantic_items =semantic_path[post_id]['items'] ?? [];
foreach (semantic_items as item) {
// 去重:避免和相关文章重复duplicate = false;
foreach (related_list asrelated_item) {
if (related_item['url'] ===item['url']) {
duplicate = true;
break;
}
}
if (duplicate) {
continue;
}
semantic_list[] = [
'title' =>item['title'],
'url' => item['url']
];
if (count(semantic_list) >= semantic_count) {
break;
}
}
}
// =========================
// 最终为空
// =========================
if (empty(related_list) && empty(semantic_list)) {
returncontent;
}
// =========================
// UI
// =========================
html = '<div class="post-recommend-wrapper" style="
display:flex;
gap:0;
margin:28px 0;
padding:16px 18px;
background:#f0f7ff;
border-left:4px solid #3b82f6;
border-radius:6px;
font-size:14px;
line-height:1.7;
color:#444;
">';
// =====================================================
// 左侧:相关文章
// =====================================================html .= '<div style="
flex:1;
padding-right:16px;
min-width:0;
">';
html .= '<div style="
font-size:13px;
color:#666;
margin-bottom:8px;
font-weight:600;
">
📎 相关文章
</div>';
if (!empty(related_list)) {
foreach (related_list asitem) {
html .= '<div style="margin:6px 0;">
<a href="' . esc_url(item['url']) . '" target="_blank" style="
color:#1d4ed8;
text-decoration:none;
font-weight:500;
">
' . esc_html(item['title']) . '
</a>
</div>';
}
} else {html .= '<div style="color:#999;">
暂无相关文章
</div>';
}
html .= '</div>';
// =====================================================
// 中间分割线
// =====================================================html .= '<div style="
width:1px;
background:#dbeafe;
margin:0 10px;
"></div>';
// =====================================================
// 右侧:猜你所想
// =====================================================
html .= '<div style="
flex:1;
padding-left:16px;
min-width:0;
">';html .= '<div style="
font-size:13px;
color:#666;
margin-bottom:8px;
font-weight:600;
">
🔗 猜你所想
</div>';
if (!empty(semantic_list)) {
foreach (semantic_list as item) {html .= '<div style="margin:6px 0;">
<a href="' . esc_url(item['url']) . '" target="_blank" style="
color:#2563eb;
text-decoration:none;
font-weight:500;
">
' . esc_html(item['title']) . '
</a>
</div>';
}
} else {
html .= '<div style="color:#999;">
暂无猜你所想
</div>';
}html .= '</div>';
html .= '</div>';
returncontent . $html;
}
add_filter('the_content', 'add_recommend_list_after_content', 20);
以上这段php代码可以通过Code Snippets插件或者在functions.php中进行添加。
通过整体逻辑来看,这段代码主要完成了三个步骤。
首先,系统会判断当前页面是否为文章页,并过滤知识地图、学习地图等特殊页面。这些页面本身承担的是导航和索引功能,不适合作为推荐系统的参与对象,因此直接排除在推荐逻辑之外。
随后程序会分别读取”wordpress/html/wp-content/themes/argon-theme-master/cache/”路径下的article-index.json 与 semantic-path.json 两份数据文件。其中 article-index.json 保存的是结构化关联关系,用于生成左侧的“相关文章”;而 semantic-path.json 保存的是基于 Embedding 计算得到的语义关联关系,用于生成右侧的“猜你所想”。
当获取到当前文章 ID 后,系统会分别从两份数据中提取对应的推荐结果,并组装成最终展示内容。为了避免重复推荐,同一篇文章如果已经出现在“相关文章”列表中,则不会再次出现在“猜你所想”区域。
从实现角度来看,WordPress 在这里承担的角色其实非常简单。它既不负责向量生成,也不负责相似度计算,而只是读取已经生成好的 JSON 数据,并按照预设样式输出到文章页面。
因此,无论后台采用什么 Embedding 模型,也无论语义关联关系如何生成,前端代码都不需要发生变化。对于 WordPress 来说,semantic-path.json 本质上只是一份普通的数据文件。
至此,“猜你所想”功能的整个实现链路便完成了闭环。从原始文章内容出发,经过语义整理、向量化处理和相似度计算,最终形成文章之间的语义关联关系,并在前端以推荐列表的形式呈现给读者。
3.5 “猜你所想”效果展示与推荐分析
为了更直观地观察两种推荐机制的差异,这里以文章《从零理解 RAG(一):原理与完整流程解析》为例进行说明。在这篇文章下方,传统“相关文章”区域给出的推荐结果如下:
- 从零理解 RAG(二):在 Mac mini 上跑本地 RAG demo——最小架构实战指南
- 使用Ollama自建嵌入模型 + Chatbox 知识库实战
- 最便捷的 AI App 前端:Chatbox 全面介绍 + 使用指南
- 向量:AI 世界里的通用语言
- 开启AI之旅:本地大语言模型UI与大语言模型API供应商的入门详解
而“猜你所想”区域给出的推荐结果则是:
- 为博客构建“轻量级知识索引”(二):JSON 结构与生成脚本的实现
- 为博客构建“轻量级知识索引”(一):结构设计与构建流程
- 为博客构建“轻量级知识索引”(三):基于预计算语义索引的 WordPress 轻量推荐系统设计与实现

乍看之下,这个结果与传统的“相关文章”似乎有些相似,因为推荐出的内容同样集中在知识索引、语义检索以及推荐系统等相关主题上,但两者背后的实现逻辑实际上并不相同。
“相关文章”本质上依赖于文章结构,无论是分类、标签、系列关系还是人工整理的知识地图,它关注的都是“这篇文章属于哪个体系”,因此推荐结果通常会沿着既定知识路径继续延伸;而“猜你所想”关注的则是另一件事情:文章到底在讨论什么。
例如《为博客构建“轻量级知识索引”(三)》与系列前两篇文章虽然本身存在系列关系,但从语义层面来看,它们同样围绕语义索引构建、数据组织方式以及推荐系统设计展开讨论。在 Embedding 建立的语义空间中,这些文章天然会被聚集到相近的位置,因此即使完全不依赖分类、标签或者系列关系,也有较大概率被相互关联起来。
这也是语义推荐与传统相关文章最大的区别之一:它并不关心文章被放在哪个分类下,而是尝试理解文章实际表达的内容,然后根据语义上的接近程度建立关联。
当然,在某些情况下,这种关联也可能跨越原有的分类体系。例如两篇文章虽然主题不同,但如果都在讨论知识组织、系统设计或者认知结构等相似概念,它们仍然可能在语义空间中彼此接近,从而被推荐到一起。只是这种跨领域关联是否出现,以及出现的程度,很大程度上取决于所使用的 Embedding 模型以及语义空间的构建方式。
从这个角度来看,“相关文章”更像是在既定地图中按照预设路径继续前进,而“猜你所想”则是在语义空间中寻找距离最近的邻居。两者并不是相互替代关系,而是从不同维度建立内容之间的联系。
其实一开始,我只是想用 embedding 模型做一个“猜你所想”的功能,通过向量相似度来推荐相关内容。但由于早期在分段策略、清洗规则和语义覆盖范围上考虑不够,“猜你所想”的效果并不稳定。于是当时先退一步,基于更直观的结构信息实现了一个“相关文章”模块,用来保证基础的内容关联体验。
后来随着整体链路逐步优化,包括文本清洗、分段策略以及向量构建方式的调整,“猜你所想”的效果逐渐达到了预期。只是出现了一个很自然的现象:它和“相关文章”的推荐结果在很多情况下会比较接近。
从原理上看,这其实并不意外——两者本质上都在做“内容相关性”的判断,只是一个基于语义向量,一个基于结构或显式关系,因此在内容质量较高、主题清晰的情况下,结果收敛是正常的。
我一度考虑过是否只保留其中一个模块,但最终还是选择两者并存。原因也比较简单:虽然它们的推荐结果存在相似,但关注的侧重点并不完全一致。“猜你所想”更偏向语义空间中的潜在兴趣扩展,而“相关文章”更偏向结构层面的确定性关联。前者提供探索空间,后者提供阅读锚点,两者叠加反而让内容导航变得更稳定,也更有层次感。
只不过,为了显出主次,”猜你所想”只显示3个推荐结果。
4 总结
在过去几年里,语义检索、向量数据库、RAG 等技术逐渐成为 AI 应用的重要基础设施。但对于个人博客而言,真正需要解决的问题往往没有那么复杂。大多数情况下,我们需要的并不是一个实时更新、支持亿级数据规模的语义检索系统,而只是希望文章之间能够建立更符合内容本身的关联关系。
因此在整个实现过程中,我始终坚持一个原则:尽量把复杂度留在构建阶段,把简单性留给运行阶段。通过 semantic-source.json、semantic-vector.json 和 semantic-path.json 三层结构,所有语义计算都被提前完成,最终 WordPress 只负责读取结果并展示内容。
这种方式虽然失去了实时计算能力,却换来了极低的运行成本和维护成本。对于个人博客这样更新频率远低于访问频率的场景而言,这种取舍往往更加合理。从结果来看,“相关文章”负责维护知识结构,“猜你所想”负责发现语义关联,两者共同构成了一套兼顾可解释性与探索性的推荐机制。
不过对于我来说,这套系统最大的价值也许并不在于推荐本身。真正有意义的,其实是 Embedding 所建立起来的语义关联网络——在传统博客体系中,文章之间的连接通常来自分类、标签、系列文章以及人工维护的知识地图;而在引入语义关联之后,文章开始拥有一种基于内容本身建立起来的连接关系。
这种关系不依赖预先设计的分类体系,也不依赖人工整理,而是由文章实际表达的内容自然形成。可以说,分类、标签和知识地图描述的是作者眼中的知识结构;而 Embedding 建立的语义关联,则是在尝试发现内容自身呈现出来的关系。
某种意义上,这也算是博客从信息集合逐步演化为知识网络的一次尝试。