为博客构建“轻量级知识索引”(七):右侧菜单——语义空间与分类体系的统一组织机制
文章摘要
为解决博客推荐内容触达率低的问题,设计了一种基于语义相似性与分类体系的右侧悬浮菜单方案。通过离线生成文章语义向量与分类语义空间,结合WordPress前端动态渲染,在不干扰阅读体验的显眼位置实现推荐内容的持续曝光。该方案利用embedding-index.json与category-space.json分层数据结构,将分类从静态标签升级为语义聚合体,使推荐逻辑从“文章→文章”扩展为“文章→分类→文章”的结构化路径,最终形成更立体的知识网络,提升用户探索深度与内容关联性。
Qwen3-14B · 2026-07-01

1 前言

在这个系列前面的文章中,我在文章页底部增加了“相关文章”(参见:为博客构建“轻量级知识索引”(三):基于预计算语义索引的 WordPress 轻量推荐系统设计与实现)和“猜你所想”两个功能,用来在访客朋友阅读完单篇内容之后,基于不同维度去做内容推荐:

image.png

其中,“相关文章”更偏向于结构化关系,比如同一主题下的内容延续或上下游关联;而“猜你所想”则更多依赖语义相似性,通过 embedding 向量计算来寻找语义空间中接近的文章。

这样设计的初衷,是希望博客系统不仅仅是一个“内容集合”,而是能够在文章之间建立横向连接,让知识以网络的方式展开,从而提升整体的可探索性,也顺带降低用户的跳出率。

不过在实际观察中,我逐渐意识到一个问题:仅仅依赖文章末尾的推荐机制,其实覆盖率是有限的。现实情况是,很多访问者并不会完整读完一篇文章,更不用说滑动到页面底部去查看推荐内容。因此,这些精心设计的关联推荐,实际上触达率并不高,在很多访问场景下更像是“存在但不被使用”的结构。

基于这个问题,我开始重新思考推荐系统的展示层级:是不是必须依赖文章末尾?有没有一个更高频、更显眼,但又不会干扰阅读体验的位置?

在观察页面布局时,我注意到了文章页右侧的空白区域——尤其是在 Argon 主题关闭侧边栏工具之后,这一块区域几乎完全闲置:

image.png

但视觉上又处于用户阅读视野的自然边界,非常适合承载一些“辅助信息”。

于是我尝试在这个位置引入一个悬浮式的关联推荐组件,它不再等待用户读完文章,而是持续存在于阅读过程中,作为一种“随时可达的知识入口”。

那么这个入口应该基于什么逻辑来组织内容?这时我回到了博客中已经存在的一个核心结构:博客知识地图中的分类体系。这些分类本身并不是简单的标签,而是经过整理的主题聚合结构,本质上已经是一个较高层级的知识组织维度。如果再结合文章与文章之间的语义相似性,那么就可以形成一个“分类 × 语义空间”的双重筛选机制。

因此,这个右侧悬浮菜单最终被定义为”基于文章语义相似性,在已有分类结构中进行聚合与排序的一种动态关联推荐层”。它的作用不再是简单的“推荐下一篇文章”,而是提供一个更结构化的入口:让用户可以从当前阅读内容出发,在分类维度中横向扩展阅读路径。

从某种意义上说,这一层结构补上了原本只存在于文章底部的推荐机制在“曝光位置”上的不足,使得整个博客的内容网络更加立体,也更接近一个可探索的知识系统,而不是线性文章列表。

2 设计

2.1 右侧菜单的设计思路

其实一开始的目标并不是右侧菜单,最早的目标是做一个基于语义向量的站内语义搜索系统——让文章之间的关联不再依赖传统的标签或分类,而是直接通过 embedding 空间中的距离来决定推荐关系。

但在实际推进过程中,很快遇到了一个关键约束:用于生成 embedding 的模型(qwen3-embedding:8b)是部署在内网环境中,本身并不适合直接暴露到公网服务中。如果坚持做“实时语义搜索”,就意味着需要把整个向量生成能力在线化,这在当前的部署结构下并不是很好的选择——就算通过cloudflare tunnel发布到公网上,国内访问速度也不会快到哪里去。当然,用付费云端API到是可以,但是花钱我可不愿意~。

这个限制反过来促使我重新思考“语义能力应该放在哪里”——既然无法做实时计算,那就只能退一步,把语义计算前置到离线阶段,也就是提前把文章的向量全部计算并固化下来,然后在前端请求时只做轻量级的相似度计算与聚合。

事实上,在之前实现“猜你所想”功能时,我已经有一套基于 embedding 的实现,并生成过一份 semantic-vector.json。但那一版数据结构更偏向单篇文章级别的语义索引,整体组织方式是“文章 → 向量”的扁平结构,这在用于跨分类聚合时会暴露出一些问题,比如数据粒度不统一、以及后续扩展分类维度时需要额外再做一次结构重组。因此在这一阶段,我选择不再直接复用旧结构,而是重新构建一份面向“分类关系计算”的文章级体系”embedding-index”,为后续的分类级语义建模留出空间。

有了文章级 embedding 之后,下一步其实就变得比较自然:既然文章之间可以通过向量计算相似度,那么分类也可以看作是“文章集合的语义聚合体”。具体来说,每一个分类不再只是一个静态的标签,而是可以由其内部代表性文章的向量计算出一个能代表该分类的”语义中心向量”。当用户访问某一篇文章时,可以先计算当前文章向量与各个分类向量之间的相似度,从而得到语义上最接近的 Top 分类集合。

在确定最相关的几个分类(综合考量之后,觉得分类数量为3个最合适)之后,再进一步在这些分类内部进行文章级别的相似度排序(拍脑袋确认4篇最合适),就可以得到一个分层结构的推荐结果:先是“当前文章最相关的 Top3 分类”,再是每个分类下“语义最接近的 Top4 文章”。这样一来,推荐不再只是单点文章之间的匹配,而是形成了一个从“文章 → 分类 → 文章”的两级语义结构。

从整体上看,这个右侧菜单的本质并不是一个简单的导航组件,而是把原本隐藏在后台的“语义组织结构”显式地暴露到了用户界面上,让分类不再只是信息归档工具,而变成了一种动态的内容关联维度。

2.2 数据结构与系统输入:三类 JSON 构成的语义组织基础

在明确右侧菜单的设计思路之后,系统落地层面实际上依赖的是一组逐层生成的数据结构。这些结构并不是孤立存在的,而是沿着“内容生成 → 语义表达 → 结构聚合”的路径逐级构建,最终形成右侧菜单的完整数据基础。

从整体来看,这套系统主要依赖三份核心 JSON 文件,它们分别处于不同的语义层级,并且存在明确的生成依赖关系。

1、embedding-index.json

首先是文章语义层的数据来源,对应 embedding-index.json。这一层的数据并不是直接从文章生成的,而是建立在上一篇关于“AI 摘要”文章输出json文件的基础之上(参见:为博客构建“轻量级知识索引”(六):AI摘要与可编辑语义层设计)。具体来说,它依赖于 summary-source.json 与 summary-index.json 两个中间产物。

其中,summary-source.json 负责提供文章内容的原始语义输入,而 summary-index.json 则是在此基础上进行结构化整理与摘要归纳。embedding-index.json 则进一步在这一语义压缩结果之上生成向量表示,使每一篇文章都进入统一的语义空间。

从数据结构上来看,一个典型的 embedding-index.json 结构如下:

{
  "14334": {
    "post_id": "14334",
    "url": "/technology/homedatacenter14334/",
    "title": "为博客构建“轻量级知识索引”(四):系列文章导航与阅读路径设计",
    "embedding": [0.008560282, -0.02900768, 0.013086175, ...],
    "control": {
      "roadmap_hint": null
    },
    "meta": {
      "summary": "针对博客系列文章阅读连续性不足的问题,通过构建轻量级知识索引实现结构化导航方案。基于article-index.json文件与标题规则提取系列标识和序号,无需新增数据库结构或插件依赖...",
      "updated": "2026-06-10 10:58:56",
      "model": "qwen3-embedding:8b",
      "content_hash": "df3b496a82d707594fd3b3433f70aebd1352d286303c1c9742b2a5e7e1e5fc42"
    }
  }
}

可以看到,这一结构不仅包含向量本身,还保留了文章的基础元信息与摘要结果。这意味着 embedding-index.json 并不是一个“纯向量文件”,而是一个融合了语义表示与内容上下文的混合索引层。


注:从工程实现的角度来看,这一层存在一个关键的设计选择:是否“利旧”已有中间产物。

当前实现中,embedding-index.json 在一定程度上复用了前面 AI 摘要体系已经生成的 summary-source.json 与 summary-index.json,这种方式的优势在于可以显著降低计算复杂度,并减少重复解析 WordPress 原始内容的成本。

但需要强调的是,这种“利旧”并不是唯一方案。从理论上讲,完全可以绕开现有中间层,直接从 WordPress 原始文章内容重新构建 embedding 体系,只不过这样会显著增加数据清洗、文本规范化以及语义切分的复杂度,同时也会使生成链路变长,对脚本的稳定性与维护成本提出更高要求。

因此当前设计选择的是一种折中方案:优先复用已有语义中间产物,在保证一致性的前提下,降低整体系统复杂度,使 embedding-index.json 成为一个稳定可重建但不必每次从零构建的语义层。


2、roadmap-source.json

在文章语义层之上,是知识地图结构层,对应 roadmap-source.json。

这一层的数据来自博客知识地图本身,它记录了系统中所有分类的结构信息,以及每个分类下所包含的文章列表。

从数据结构上来看,它的核心形式如下:

{
  "categories": [
    {
      "id": "infra",
      "name": "个人数字基础设施与博客系统构建",
      "articles": [
        {
          "title": "浅谈如何选择最适合你的NAS",
          "url": "/technology/nas602/"
        },
        {
          "title": "群晖NAS HDD数据硬盘自动休眠",
          "url": "/technology/nas139/"
        },
        {
          "title": "从零开始搭建个人博客之完整攻略(最低成本)",
          "url": "/technology/homedatacenter12540/"
        }
      ]
    }
  ]
}

与 embedding 层不同,这一层并不涉及任何语义计算,它的作用是提供一个稳定的结构边界,使语义推荐不会脱离既定的内容组织体系。换句话说,它定义的是“博客内容如何被组织”,而不是“内容之间语义上如何关联”。

从系统设计角度来看,这一层更像是一个“人为构建的知识骨架”,所有语义计算都必须在这个骨架之内完成,从而保证推荐结果不会偏离博客原有的知识结构。

3、category-space.json

第三层是分类语义空间层,对应 category-space.json。

这一层的数据是整个系统中最关键的融合产物,它同时依赖 roadmap-source.json 与 embedding-index.json。

从结构上来看,它的核心形式如下:

{
  "categories": [
    {
      "id": "infra",
      "name": "个人数字基础设施与博客系统构建",
      "embedding": [
        0.0108209606,
        -0.0248727239,
        0.0033332910,
        -0.0051341676
      ],
      "articles": [
        {
          "title": "浅谈如何选择最适合你的NAS",
          "url": "/technology/nas602"
        },
        {
          "title": "群晖NAS HDD数据硬盘自动休眠",
          "url": "/technology/nas139"
        },
        {
          "title": "从零开始搭建个人博客之完整攻略(最低成本)",
          "url": "/technology/homedatacenter12540"
        }
      ]
    }
  ]
}

其生成逻辑可以理解为:先基于 roadmap-source.json 获取每个分类下的文章集合,再从 embedding-index.json 中提取这些文章的向量表示,最后对同一分类内的所有文章向量进行聚合计算,从而得到该分类的语义中心向量。

在这一结构下,每一个分类不再只是一个静态的目录,而是一个由多个文章语义共同决定的“语义聚合体”。embedding 字段正是这一聚合结果的直接表达,它使分类从“结构单元”升级为“语义单元”。

同时,每个分类还会保留若干代表性文章(articles 字段),用于在前端展示具体可点击内容,使语义层与可读内容之间保持直接映射关系。

最终,右侧菜单的推荐逻辑就是建立在这一层之上:通过计算当前文章向量与各分类向量之间的相似度,选出最相关的分类,再进一步在分类内部选出具体文章。

2.3 系统整体架构与工程价值

从整体结构来看,这三类 JSON 并不是平行关系,而是一个明确的分层依赖体系:

summary-source.json
        ↓
summary-index.json
        ↓
embedding-index.json
        ↓
        ├───────────────┐
        ↓               ↓
roadmap-source.json   (知识地图结构)
        ↓               ↓
        └──────┬────────┘
               ↓
      category-space.json
               ↓
        右侧菜单推荐系统

从工程角度来看,这种设计的核心价值在于,它将原本需要在运行时完成的语义计算与结构聚合过程,整体前移到了离线构建阶段,并通过一组 JSON 中间产物进行串联。

在这个过程中,每一层 JSON 都只承担单一职责:要么负责语义表达,要么负责结构组织,要么负责最终聚合输出,而不会在运行时引入额外计算负担。最终 WordPress 侧只需要消费 category-space.json,即可完成推荐逻辑的呈现。

因此,这套结构的本质并不是“多阶段数据加工流程”,而是一种面向 CMS 场景的工程收敛策略:将复杂度集中在构建阶段(整个系列文章的核心思想),将确定性输出固化为静态数据,使语义推荐能力能够以低成本方式嵌入传统内容系统中。

3 工程实现:从离线构建到前端呈现

3.1 工程实现的整体流程

根据第2章的设计思路,对应到实际工程实现上,这一套右侧菜单系统可以拆分为三个明确的步骤,每一步都有对应的产物和执行位置。

第一步,是生成文章级语义向量索引文件 embedding-index.json。这一部分由脚本 build_embedding_index.py 负责完成,核心作用是将 WordPress 中的文章内容转换为统一维度的语义向量表示,并以 JSON 的形式进行持久化存储。这个文件可以理解为整个系统的“语义底座”,后续所有推荐计算都依赖它提供的文章级 embedding 数据。

第二步,是生成分类级语义空间文件 category-space.json,对应脚本为 build_category_space.py。这一阶段的输入不仅包含第一步生成的文章向量数据,还会结合博客知识地图中的分类结构信息,将同一分类下的文章向量进行聚合计算,从而生成每个分类的语义中心向量,并保留分类内的代表性文章列表。这个文件相当于在文章语义空间之上,再构建了一层“分类语义空间”。

第三步,则是在 WordPress 前端侧完成实际展示逻辑。系统运行时会读取 category-space.json 文件,并结合当前文章信息计算与各分类之间的语义相似度,最终筛选出最相关的几个分类,并在右侧菜单中渲染对应的推荐文章列表。也就是说,这一步并不再做语义计算,而只是对已有结构数据进行消费与展示。

这种拆分方式的好处在于,每一层都有明确的职责边界:语义计算在离线阶段完成,前端只负责结果消费,从而避免了 WordPress 运行时引入复杂计算逻辑。

3.2 文章语义向量生成:build_embedding_index.py

build_embedding_index.py脚本的代码如下:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import json
import os
import time
import requests
import hashlib

# -----------------------------
# Ollama embedding 服务(需根据实际场景修改)
# -----------------------------
OLLAMA_URL = "http://127.0.0.1:11434/api/embed"
MODEL_NAME = "qwen3-embedding:8b"

# -----------------------------
# 路径配置(需根据实际场景修改)
# -----------------------------
CACHE_DIR = "/docker/wordpress/html/wp-content/themes/argon-theme-master/cache/"
SUMMARY_FILE = os.path.join(CACHE_DIR, "summary-index.json")

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SOURCE_FILE = os.path.join(SCRIPT_DIR, "summary-source.json")
OUTPUT_FILE = os.path.join(CACHE_DIR, "embedding-index.json")

# -----------------------------
# 工具函数
# -----------------------------
def load_json(path, default):
    if not os.path.exists(path):
        return default
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def make_hash(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


# -----------------------------
# 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=180
            )

            if resp.status_code == 200:
                data = resp.json()
                emb = data.get("embeddings", [])
                if emb:
                    return emb[0]

        except Exception as e:
            pass

        time.sleep(2 * (i + 1))

    return None


# -----------------------------
# 轻量规则(预留结构信息)
# -----------------------------
def infer_roadmap_hint(text):
    t = text.lower()

    if "cloudflare" in t:
        return "cloudflaremap"
    if "embedding" in t or "ai" in t or "llm" in t:
        return "aimap"
    if "music" in t or "sound" in t:
        return "singmap"

    return None


# -----------------------------
# 主流程
# -----------------------------
def main():
    source = load_json(SOURCE_FILE, [])
    summary = load_json(SUMMARY_FILE, {})
    cache = load_json(OUTPUT_FILE, {})

    for article in source:
        post_id = str(article.get("id"))
        if not post_id or post_id in cache:
            continue

        summary_item = summary.get(post_id)
        if not summary_item:
            continue

        title = summary_item.get("title", "")
        summary_text = summary_item.get("summary", "")
        url = article.get("url", "")

        embedding_input = title + "\n" + summary_text
        embedding = get_embedding(embedding_input)

        if not embedding:
            continue

        cache[post_id] = {
            "post_id": post_id,
            "url": url,
            "title": title,
            "embedding": embedding,
            "control": {
                "roadmap_hint": infer_roadmap_hint(title + " " + summary_text)
            },
            "meta": {
                "summary": summary_text,
                "model": MODEL_NAME
            }
        }

        with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
            json.dump(cache, f, ensure_ascii=False, separators=(',', ':'))


if __name__ == "__main__":
    main()

从功能上来说,这个脚本主要完成了三件事:读取文章摘要数据、调用 embedding 模型生成语义向量、输出统一结构的 JSON 索引文件

其中最关键的部分并不是 embedding 调用本身,而是整个数据结构的统一过程。在这个阶段,系统已经不再直接处理 WordPress 原始文章内容,而是基于前一阶段生成的 summary-source.jsonsummary-index.json,将文章标题与摘要拼接后作为语义输入,从而生成稳定的向量表示。

最终输出的 embedding-index.json,每一条记录都会包含以下几个核心字段:

  • post_id:文章唯一标识
  • url:文章链接路径
  • title:文章标题
  • embedding:文章语义向量(核心字段)
  • control.roadmap_hint:轻量结构提示(用于弱分类辅助)
  • meta.summary:文章摘要信息
  • meta.model:使用的 embedding 模型标识

从系统角度来看,这一步的意义在于:把原本分散在 WordPress 和摘要体系中的文本信息,统一转换成可计算的语义空间数据。后续所有的分类聚合与推荐逻辑,都是直接建立在这个 embedding 索引之上的。

3.3 分类语义空间生成:build_category_space.py

在有了文章级语义向量之后,下一步就是使用build_category_space.py脚本把这些“点状的文章语义”进一步收敛成“分类级的语义空间”,也就是最终用于右侧菜单推荐的 category-space.json,脚本代码如下:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import json
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity

# =========================
# Config
# =========================

ROADMAP_FILE = "roadmap-source.json"

CACHE_DIR = "/docker/wordpress/html/wp-content/themes/argon-theme-master/cache/"

EMBEDDING_FILE = CACHE_DIR + "embedding-index.json"
OUTPUT_FILE = CACHE_DIR + "category-space.json"

MODE = "hybrid"   # struct | semantic | hybrid
ALPHA = 0.4       # struct weight

# =========================
# Utils
# =========================

def load_json(path):
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def l2_normalize(vec):
    arr = np.array(vec, dtype=np.float32)

    if arr.ndim == 1:
        norm = np.linalg.norm(arr)
        return arr / (norm + 1e-10)

    norm = np.linalg.norm(arr, axis=1, keepdims=True)
    return arr / (norm + 1e-10)


def normalize_url(url: str):
    if not url:
        return url
    return url.rstrip("/")


def struct_weight(title: str):
    w = 1.0

    if any(x in title for x in ["(一)", "(二)", "(三)", "(四)", "Part", "系列"]):
        w += 0.2

    if any(x in title for x in ["架构", "系统", "设计", "重构", "方案"]):
        w += 0.3

    if len(title) < 18:
        w -= 0.2

    return max(w, 0.5)


def build_embedding_map(embedding_data):
    emb_map = {}

    for post_id, item in embedding_data.items():

        if not isinstance(item, dict):
            continue

        url = normalize_url(item.get("url"))
        embedding = item.get("embedding")

        if not url or not embedding:
            continue

        emb_map[url] = l2_normalize(embedding)

    return emb_map


def build_category_groups(roadmap):
    groups = {}

    for cat in roadmap["categories"]:
        groups[cat["id"]] = {
            "id": cat["id"],
            "name": cat["name"],
            "articles": cat["articles"]
        }

    return groups


def semantic_weights(embeddings):
    if len(embeddings) == 0:
        return []

    embs = np.array(embeddings)

    if len(embs) == 1:
        return [1.0]

    mean = np.mean(embs, axis=0).reshape(1, -1)
    sims = cosine_similarity(embs, mean).flatten()

    denom = sims.max() - sims.min()
    if denom < 1e-10:
        return [1.0] * len(sims)

    sims = (sims - sims.min()) / denom
    return 0.5 + sims


def compute_centroid(embeddings, weights):
    if len(embeddings) == 0:
        return None

    embeddings = np.array(embeddings)
    weights = np.array(weights).reshape(-1, 1)

    centroid = np.sum(embeddings * weights, axis=0) / (np.sum(weights) + 1e-10)

    return l2_normalize(centroid).tolist()


def build():
    roadmap = load_json(ROADMAP_FILE)
    embedding_data = load_json(EMBEDDING_FILE)

    emb_map = build_embedding_map(embedding_data)
    categories = build_category_groups(roadmap)

    output = {"categories": []}

    for cid, cat in categories.items():

        embs = []
        struct_ws = []
        articles_out = []

        for a in cat["articles"]:
            url = normalize_url(a["url"])

            if url not in emb_map:
                continue

            emb = emb_map[url]

            embs.append(emb)
            struct_ws.append(struct_weight(a["title"]))

            articles_out.append({
                "title": a["title"],
                "url": url
            })

        if len(embs) == 0:
            continue

        embs_np = np.array(embs)
        sem_ws = semantic_weights(embs_np)

        n = len(embs)
        struct_ws = struct_ws[:n]
        sem_ws = sem_ws[:n]

        if MODE == "struct":
            final_ws = struct_ws
        elif MODE == "semantic":
            final_ws = sem_ws
        else:
            final_ws = [
                ALPHA * sem_ws[i] + (1 - ALPHA) * struct_ws[i]
                for i in range(n)
            ]

        centroid = compute_centroid(embs_np, final_ws)

        output["categories"].append({
            "id": cid,
            "name": cat["name"],
            "embedding": centroid,
            "articles": articles_out
        })

    with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
        json.dump(output, f, ensure_ascii=False, indent=2)


if __name__ == "__main__":
    build()

从功能上来看,这个脚本做的事情其实可以理解为一句话:把“文章语义向量”压缩成“分类语义向量”。它的输入来自两个部分:一部分是上一节生成的 embedding-index.json,里面保存了每篇文章的语义向量;另一部分是 roadmap-source.json,也就是博客知识地图,它提供了分类结构以及分类下的文章列表。

在处理过程中,脚本首先会把 embedding-index.json 转换成一个以 URL 为索引的向量映射,这一步的意义是为了让“分类里的文章”可以快速找到对应的语义向量。与此同时,roadmap-source.json 会被解析成分类结构数据,用来确定每个分类包含哪些文章。

接下来,系统会对每个分类内部的文章向量进行聚合计算。这里并不是简单做平均,而是引入了一个加权机制:既考虑语义相似性(semantic weight),也考虑文章结构特征(struct weight),例如是否属于系列文章、是否偏架构设计类内容等。

最终,每个分类会被压缩成一个“语义中心向量(centroid)”,同时保留该分类下的代表文章列表,这就是最终输出的 category-space.json 的核心内容。

从结果上看,这一步完成之后,系统中的“分类”就不再只是 WordPress 里的目录结构,而是变成了一个可以参与相似度计算的语义实体,为后续右侧菜单的推荐逻辑提供了基础。

3.4 WordPress 右侧菜单实现:前端渲染与语义推荐落地

3.4.1 前端架构拆分:PHP / JS / CSS 的职责划分

这一部分是整个系统真正“可见”的终点,也就是右侧悬浮菜单在 WordPress 页面中的实际呈现方式。相比前面几章偏数据结构和工程生成,这一部分的重点已经从“如何计算语义”转向“如何把已经生成好的语义结果稳定、低成本地呈现在前端”。

从实现结构上看,这一模块被拆分为三个部分:PHP(服务端计算与数据组织层)、JavaScript(前端交互与状态控制层)、CSS(视觉呈现层)。这种拆分并非单纯由 WordPress 的运行模型决定,而是由系统本身的职责边界所自然形成——即需要将“确定性计算”与“交互式状态管理”分离,同时保持前端的轻量化与无状态特性。

通过这种拆分方式,系统在逻辑上形成了清晰的三层职责边界,使得推荐计算、交互控制与视觉呈现能够相互解耦,从而降低整体系统的复杂度与维护成本。


在这个结构中,PHP 部分的作用是“数据入口 + 在线推荐计算层”。它负责在文章页面中注入右侧菜单的 HTML 容器,并加载 category-space.jsonembedding-index.json 等离线生成的数据文件,同时基于当前文章的 embedding 向量,在服务端完成分类内相似度计算、候选文章排序以及过滤规则应用(例如 self-similarity 过滤与 series 去重)。

可以理解为:PHP 在这一层并不是简单的数据搬运,而是承担了“基于预计算语义空间的在线确定性推荐计算”职责,最后把计算的结果传给前端 JavaScript 。

JavaScript 部分则是整个右侧菜单的交互与展示控制层。它负责读取 PHP 在页面中注入的结构化数据对象 window.SIDEBAR_DATA,该数据不仅包含 category-space.json 中的分类结构,还包含 PHP 在运行时基于当前文章 embedding 计算得到的最终推荐结果。

在渲染层面,JS 将分类列表与对应文章列表动态生成并插入 DOM,从而构建右侧菜单的完整 UI 结构。在交互层面,它通过监听鼠标 hover 与 enter/leave 行为,在分类切换与文章面板展示之间进行状态切换控制,实现一个轻量级的 UI 状态机。需要注意的是,这一层并不参与任何相似度计算或排序逻辑,JS 的职责仅限于将“已计算完成的推荐结构”转化为用户可交互的界面呈现。

CSS 部分则完全聚焦在视觉呈现层,不参与任何逻辑处理。它主要负责右侧悬浮菜单的布局结构、层级关系、动画效果以及 hover 状态下的视觉反馈,确保该组件在页面中具有足够的可见性,但又不会干扰正文阅读体验。


通过这样的三层拆分,整个右侧菜单系统在 WordPress 中被构建为一个清晰的分层结构:PHP 负责基于 embedding 的在线相似度计算、分类内排序、过滤规则应用以及最终推荐结果的组织与输出;JavaScript 负责将 PHP 输出的结构化推荐结果转化为前端可交互的 UI 状态流转;CSS 则负责最终的视觉呈现与交互反馈。

这种设计的核心价值在于,它将“语义推荐计算”完全收敛到服务端执行,使前端不再承担任何计算或决策逻辑,从而保持了 UI 层的轻量性与确定性。同时,推荐逻辑也不再依赖 WordPress 的渲染生命周期,而是以独立的计算模块形式存在,仅通过结构化数据与前端进行交互。

从系统层面来看,这种拆分并不是简单的前后端分离,而是一种“计算与表达解耦”的轻量语义架构,使得推荐能力可以独立演进,而不影响内容系统本身的稳定性。

3.4.2 WordPress 侧数据构建与推荐逻辑实现(PHP)

这一部分是右侧菜单在 WordPress 中的服务端计算与数据组装层,其职责不仅是承接前面离线生成的 embedding 与 category-space 等基础数据,更重要的是在页面请求阶段基于当前文章向量执行相似度计算,并完成分类内排序、过滤与推荐结果的生成。

换句话说,这一层既承担了“离线语义空间的承载与组织”,也承担了“基于当前上下文的在线推荐计算”,最终将计算结果封装为结构化数据(SIDEBAR_DATA)供前端 JavaScript 使用。

从整体结构来看,这段 PHP 代码主要做了三件关键事情:统一数据访问路径、构建当前文章的语义基准、以及生成最终用于前端展示的分类推荐结果。

首先在数据准备阶段,系统会从缓存目录中加载 embedding-index.jsoncategory-space.json。前者提供每篇文章的向量表达,后者提供分类空间及其文章集合。在加载过程中,PHP 会将文章 URL 进行统一归一化处理,从而确保不同来源的数据能够在同一索引体系下进行匹配。

在此基础上,系统构建了两个关键映射关系:一个是“文章 URL → embedding 向量”的映射,用于后续相似度计算;另一个是“文章 URL → 所属系列信息”的映射,该映射来源于文章索引数据中的标题解析逻辑,用于识别同一系列文章之间的结构关系。这一层设计的意义在于:不仅考虑语义相似度,同时引入了结构性约束,避免同一系列内容在推荐中重复出现。

在获取当前文章向量时,系统优先尝试从 embedding 映射中直接读取。如果当前文章没有对应向量(例如索引缺失或异常情况),则会退化为使用全局向量均值作为替代基准,从而保证推荐流程始终可以运行,而不会因为单点数据缺失导致系统中断。

接下来进入核心推荐计算阶段。系统会遍历每一个分类下的文章集合,并逐一计算当前文章向量与候选文章向量之间的余弦相似度。这里使用的是标准 cosine similarity,用于衡量语义空间中的接近程度。在计算过程中,引入了两个关键过滤机制:第一是“自相似过滤”,用于剔除与当前文章几乎完全一致的条目;第二是“同系列过滤”,用于避免同一内容系列在推荐列表中重复出现。

在完成初步筛选之后,系统会基于相似度对候选文章进行排序,并进一步引入多样性控制机制,以避免推荐结果被单一主题或单一结构序列所主导。需要特别说明的是,系统会直接剔除与当前文章属于同一系列的所有内容,从而保证推荐结果在内容结构上保持“跨系列分布”。在此基础上,每个分类最多保留 4 篇具有代表性的文章,并以候选文章的平均相似度作为该分类的整体权重,用于后续排序。

在所有分类完成权重计算与排序后,系统最终选取 Top 3 分类作为右侧菜单的第一层展示内容,用于构建用户的语义入口空间。

在 WordPress 的渲染阶段,这些结果会在文章生命周期内生成,并随页面一起输出到前端:一方面通过 PHP 将预计算的语义数据(embedding 相似度结果、category-space 分类空间以及 series 规则信息)整合为最终的推荐结构,另一方面在页面中注入右侧菜单所需的 HTML 容器,并通过 window.SIDEBAR_DATA 暴露给前端 JavaScript。

前端 JavaScript 只需要读取这一结构化数据,即可完成分类展示与文章列表的交互渲染,而无需参与任何语义计算或数据生成过程。

从实现定位来看,这一层 PHP 并不是独立的推荐计算引擎,而是一个“语义结果组合层”:它负责将离线构建的 embedding 索引与分类语义空间进行轻量融合,并结合当前文章上下文完成最终的候选筛选与结构整理,从而在不引入独立后端服务的前提下,实现稳定的语义推荐能力。

具体PHP代码如下:

// ======================================================
// ① URL normalize
// ======================================================
function sidebar_normalize_url(url) {path = parse_url(url, PHP_URL_PATH);
    return rtrim(path ?? '', '/');
}

// ======================================================
// ② cosine similarity
// ======================================================
function sidebar_cosine(a,b) {
    dot = 0;na = 0;
    nb = 0;n = min(count(a), count(b));

    for (i = 0;i < n;i++) {
        dot +=a[i] *b[i];na  += a[i] * a[i];
        nb  +=b[i] *b[i];
    }

    returndot / (sqrt(na) * sqrt(nb) + 1e-10);
}

// ======================================================
// ③ series extractor(完全复用成功逻辑)
// ======================================================
function sidebar_extract_series_info(title) {title = trim(str_replace(' ', ' ', title));

    // 中文括号版本
    if (preg_match('/^(.*?)[((]([一二三四五六七八九十百零]+)[))]/u',title, matches)) {series_key = trim(matches[1]);map = [
            '一'=>1,'二'=>2,'三'=>3,'四'=>4,'五'=>5,
            '六'=>6,'七'=>7,'八'=>8,'九'=>9,'十'=>10
        ];

        index_cn =matches[2];
        index =map[index_cn] ?? 0;

        if (index > 0) {
            return [
                'series_key' => series_key,
                'series_index' =>index
            ];
        }
    }

    // Part N 版本
    if (preg_match('/^(.*?)(?:Part\s*|part\s*|\()(\d+)\)?/u', title,matches)) {

        return [
            'series_key' => trim(matches[1]),
            'series_index' => intval(matches[2])
        ];
    }

    return null;
}

// ======================================================
// ④ 从 article-index.json 构建 series map(关键)
// ======================================================
function sidebar_build_series_map_from_articles() {

    file = get_template_directory() . '/cache/article-index.json';

    if (!file_exists(file)) return [];

    articles = json_decode(file_get_contents(file), true);
    if (!articles) return [];map = [];

    foreach (articles asid => article) {

        if (empty(article['url']) || empty(article['title'])) continue;url = sidebar_normalize_url(article['url']);info = sidebar_extract_series_info(article['title']);

        if (info) {
            map[url] = info['series_key'];
        }
    }

    returnmap;
}

// ======================================================
// ⑤ 数据层(最终稳定版)
// ======================================================
function sidebar_build_data() {

    embedding_file = get_template_directory() . '/cache/embedding-index.json';category_file  = get_template_directory() . '/cache/category-space.json';

    embeddings = json_decode(file_get_contents(embedding_file), true);
    categories = json_decode(file_get_contents(category_file), true);

    if (!embeddings || !categories || !isset(categories['categories'])) {
        return null;
    }

    // embedding mapemb_map = [];

    foreach (embeddings asitem) {
        if (!isset(item['url'],item['embedding'])) continue;
        emb_map[sidebar_normalize_url(item['url'])] = item['embedding'];
    }

    // series map(来自 article-index.json ——关键修复点)series_map = sidebar_build_series_map_from_articles();

    current_url = sidebar_normalize_url(_SERVER['REQUEST_URI'] ?? '');
    current_vec =emb_map[current_url] ?? null;

    if (!current_vec) {

        all = array_values(emb_map);
        if (!all) return null;dim = count(all[0]);avg = array_fill(0, dim, 0);

        foreach (all as vec) {
            for (i = 0; i<dim; i++) {avg[i] +=vec[i];
            }
        }

        for (i = 0; i<dim; i++) {avg[i] /= count(all);
        }

        current_vec =avg;
    }

    current_series =series_map[current_url] ?? null;SELF_SIM_THRESHOLD = 0.985;

    categories_out = [];

    foreach (categories['categories'] as cat) {candidates = [];
        sum = 0;cnt = 0;

        foreach (cat['articles'] asa) {

            url = sidebar_normalize_url(a['url']);
            if (!isset(emb_map[url])) continue;

            vec =emb_map[url];score = sidebar_cosine(current_vec,vec);

            // ① 去自相似
            if (score>SELF_SIM_THRESHOLD) {
                continue;
            }

            // ② 🚨 彻底去同系列(基于 article-index.json)
            series =series_map[url] ?? null;

            if (current_series && series &&series === current_series) {
                continue;
            }candidates[] = [
                'title' => a['title'],
                'url'   =>a['url'],
                'score' => score,
                'series'=>series
            ];

            sum +=score;
            cnt++;
        }

        if (cnt === 0) continue;

        usort(candidates, fn(a,b) =>b['score'] <=> a['score']);

        // diversity(每个 series 只保留一个)final = [];
        used = [];

        foreach (candidates as item) {s = item['series'];

            if (s && isset(used[s])) continue;

            final[] = [
                'title' =>item['title'],
                'url'   => item['url'],
                'score' =>item['score']
            ];

            if (s)used[s] = true;

            if (count(final) >= 4) break;
        }

        if (!final) {final = array_slice(candidates, 0, 4);
        }categories_out[] = [
            'id' => cat['id'],
            'name' =>cat['name'],
            'score' => sum / max(cnt, 1),
            'articles' => final
        ];
    }

    usort(categories_out, fn(a,b) => b['score'] <=>a['score']);

    return array_slice(categories_out, 0, 3);
}

// ======================================================
// ⑥ UI
// ======================================================
function render_sidebar_html() {
?>
<div id="semantic-sidebar">
    <div class="ssb-panel">
        <div class="ssb-trigger">查看相关分类·3个匹配</div>
        <div class="ssb-level ssb-level-1"></div>
        <div class="ssb-level ssb-level-2" id="ssb-articles"></div>
    </div>
</div>
<?php
}

// ======================================================
// ⑦ WP Hook(必须保留)
// ======================================================
add_filter('the_content', function (content) {
    if (!is_single()) return content;data = sidebar_build_data();

    ob_start();

    if (data) {
        echo '<script>';
        echo 'window.SIDEBAR_DATA=' . json_encode(
            ['categories' =>data],
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
        );
        echo '</script>';
    }

    render_sidebar_html();
    sidebar = ob_get_clean();
    returncontent . $sidebar;
});

3.4.3 右侧菜单交互逻辑实现(sidebar.js)

这一部分是右侧悬浮菜单在前端运行时的“交互控制层”,对应的文件是 sidebar.js。它的作用并不是生成数据,也不参与任何语义计算,而是负责把 PHP 注入到页面中的结构化数据(window.SIDEBAR_DATA)转化为可交互的 UI 行为,从而完成“数据 → 视觉 → 行为反馈”的最后一环。

从整体实现来看,这段 JS 的逻辑可以拆解为三个核心步骤:初始化渲染、分类切换控制,以及悬浮状态管理。

首先在初始化阶段,脚本会从全局变量 window.SIDEBAR_DATA 中读取由 PHP 在文章生命周期中生成并注入的数据结构,并定位右侧菜单的两个关键 DOM 容器:一级分类容器(.ssb-level-1)以及文章展示面板(#ssb-articles)。随后,脚本会根据分类数据动态生成 HTML,将所有分类渲染为可悬停的入口节点。这一步本质上是一个典型的数据驱动 DOM 构建过程,并且仅在初始化阶段执行一次,从而避免重复渲染带来的状态污染。

在完成基础渲染之后,系统进入核心交互逻辑:分类切换与文章展示。当用户将鼠标悬停在某个分类节点上时,JS 会通过事件委托机制捕获目标节点,并根据其 data-id 从数据结构中定位对应分类,然后调用 showCategory() 将该分类下的文章列表动态渲染到右侧浮动面板中。该过程通过直接重写 DOM 的方式完成,从而实现分类之间的即时切换。

与此同时,系统还维护了一个轻量级的运行时状态,包括当前激活分类 ID(activeCatId)以及用于延迟关闭的定时器(hideTimer)。这些状态的存在并不是为了实现复杂逻辑,而是为了保证 hover 交互的稳定性,避免在鼠标快速移动时出现闪烁或误触关闭的问题。

在交互控制层面,这一版本采用的是“hover 触发 + 状态驱动”的混合模型。当鼠标进入 sidebar 区域时,会触发 openSidebar(),使一级分类容器进入激活状态(active class),从而显示分类列表;当鼠标离开 sidebar 区域时,则会启动一个短暂延迟计时器执行关闭操作,以减少边界移动时的误触和闪烁问题。

在分类交互部分,当用户将鼠标悬停在某个分类节点上时,系统会基于事件委托机制识别目标节点,并通过 data-id 匹配对应分类数据,再调用 showCategory() 更新右侧文章面板内容。由于文章面板采用的是动态 DOM 重写策略,因此每次分类切换都会直接替换当前展示内容。

同时,为了保证交互连续性,系统对文章面板区域增加了“关闭保护机制”:当鼠标进入文章面板时会清除延迟关闭计时器,避免面板在阅读过程中被意外关闭;当鼠标离开文章区域时,则恢复关闭逻辑并重置内部状态。

需要特别注意的是,这一版本的初始化机制已经从传统的 DOMContentLoaded 模式扩展为“Argon PJAX 生命周期驱动”。通过 window.pjaxLoaded 钩子,系统能够在每次 PJAX 页面切换完成后重新执行初始化逻辑,从而确保 SIDEBAR_DATA 与当前文章上下文保持同步,并避免因局部页面替换导致的状态残留问题。

需要强调的是,这一层仍然不涉及任何推荐计算逻辑,也不参与分类排序或相似度计算。所有语义计算与推荐结果生成已经在 PHP 端完成,JavaScript 的职责仅限于展示控制与交互响应。

整体来看,sidebar.js 更像是一个基于 PJAX 生命周期的轻量级 UI 状态控制器,它通过极少量的状态变量与事件监听机制,实现了一个稳定的悬浮菜单交互模型,使右侧菜单在无前端框架依赖的情况下,依然具备清晰且可预测的行为语义。

sidebar.js的代码如下:

(function () {

    let initialized = false;

    function init() {

        const root = document.getElementById("semantic-sidebar");
        if (!root) return;

        const data = window.SIDEBAR_DATA;
        if (!data || !data.categories) return;

        const level1 = root.querySelector(".ssb-level-1");
        const panel  = root.querySelector("#ssb-articles");

        let activeCatId = null;
        let sidebarOpen = false;
        let hideTimer = null;

        // =========================
        // 防止重复初始化(同一 PJAX 生命周期内)
        // =========================
        if (initialized) return;
        initialized = true;

        // =========================
        // render categories
        // =========================
        level1.innerHTML = data.categories.map(cat =>
            `<div class="ssb-cat" data-id="{cat.id}">{cat.name}
            </div>`
        ).join("");

        // =========================
        // open sidebar
        // =========================
        function openSidebar() {
            sidebarOpen = true;
            level1.classList.add("active");
        }

        // =========================
        // close sidebar
        // =========================
        function closeSidebar() {
            sidebarOpen = false;
            level1.classList.remove("active");
            hidePanel();
        }

        // =========================
        // show articles
        // =========================
        function showCategory(cat) {

            activeCatId = cat.id;

            panel.innerHTML = cat.articles.map(a =>
                `<a href="{a.url}">{a.title}</a>`
            ).join("");

            panel.classList.add("active");
        }

        // =========================
        // hide articles
        // =========================
        function hidePanel() {
            panel.classList.remove("active");
            panel.innerHTML = "";
            activeCatId = null;
        }

        // =========================
        // hover entry
        // =========================
        root.addEventListener("mouseenter", () => {
            openSidebar();
        });

        // =========================
        // hover leave
        // =========================
        root.addEventListener("mouseleave", () => {

            hideTimer = setTimeout(() => {
                closeSidebar();
            }, 120);
        });

        // =========================
        // category hover
        // =========================
        level1.addEventListener("mouseenter", (e) => {

            const el = e.target.closest(".ssb-cat");
            if (!el) return;

            const catId = el.dataset.id;
            const cat = data.categories.find(c => c.id === catId);

            if (!cat) return;

            showCategory(cat);
        }, true);

        // =========================
        // panel protection
        // =========================
        panel.addEventListener("mouseenter", () => {
            clearTimeout(hideTimer);
        });

        panel.addEventListener("mouseleave", () => {
            hidePanel();
        });

    }

    // =========================
    // ⭐ 核心修复:Argon PJAX 生命周期入口
    // =========================
    window.pjaxLoaded = function () {

        // 每次 PJAX 页面切换都会执行这里
        initialized = false;
        init();
    };

    // =========================
    // 首次加载(非 PJAX)
    // =========================
    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
    } else {
        init();
    }

})();

同时,需要单独添加一段php代码来实现sidebar.js的调用:

add_action('wp_enqueue_scripts', function () {

    wp_enqueue_script(
        'semantic-sidebar',
        get_template_directory_uri() . '/cache/sidebar.js',
        array(),
        null,
        true
    );

});

注:我是将sidebar.js和其他需要被wordpress直接调用的json文件都统一放在WordPress的”/wp-content/themes/argon-theme-master/cache/“路径下。

3.4.4 右侧菜单样式与视觉呈现(CSS)

这一部分是右侧悬浮菜单在 WordPress 页面中的视觉表现层,对应的实现文件为 CSS 样式表。它的作用不是参与任何交互逻辑,也不涉及数据处理,而是负责将 JS 生成的结构化 DOM 转化为具有层级感和空间感的悬浮 UI 组件,从而完成整个系统的最终“可视化呈现”。

从整体结构来看,这一部分 CSS 主要围绕三个层级进行组织:整体悬浮容器(Sidebar Root)、一级分类区域(Level 1 Categories)、以及二级文章面板(Article Panel)。每一层分别承担不同的视觉职责,从而形成一个清晰的空间层级关系。

首先是最外层容器 #semantic-sidebar,它使用 position: fixed 固定在页面右侧中部,并通过 transform: translateY(-52%) 实现垂直居中偏移,使其在不同屏幕高度下都保持稳定的视觉位置。整个组件在视觉上属于“浮层 UI”,因此设置了较高的 z-index,确保不会被正文内容覆盖。

在容器内部,.ssb-panel 承担的是整体背景与视觉承载作用。它通过半透明背景色与 backdrop-filter 实现毛玻璃效果,同时配合阴影与边框,形成一个轻量级但具有层次感的卡片式容器。这一层的设计目标是“存在但不干扰”,即在保证可见性的同时尽量降低对正文阅读的视觉侵入。

交互入口部分 .ssb-trigger 作为组件的初始提示区域,仅提供一个低干扰的视觉入口提示。它本身不参与展开逻辑,而只是提供轻量的视觉引导,并通过伪元素添加一个状态指示点,使用户能够快速识别该组件的功能存在。

在结构控制上,一级分类容器 .ssb-level 的显示逻辑被完全交给 JS 控制,因此 CSS 仅负责基础状态定义:默认隐藏(display: none),以及在 JS 添加 .active 后进行显示切换。这种设计避免了 CSS hover 与 JS 状态之间的冲突,使展开行为完全可预测。

分类项 .ssb-cat 则主要负责提供可交互的视觉反馈,包括 hover 高亮、文本截断以及轻量的过渡效果。这一层的设计目标是保证在分类数量较多时仍然保持清晰的可读性,同时避免视觉拥挤。

二级文章面板 #ssb-articles 是整个组件中视觉复杂度最高的部分。它被设计为一个相对定位于分类列表左侧的浮动面板,通过 position: absolute 进行空间锚定,使其在展开时始终与当前分类保持空间关联。其显示与隐藏同样完全依赖 JS 添加或移除 .active 类,从而避免 hover 触发导致的抖动问题。

在文章条目 .ssb-articles a 的实现上,采用了“双行截断 + 渐隐遮罩”的组合方案,通过 -webkit-line-clamp 控制文本长度,同时使用 mask 渐变避免文本截断产生突兀边界。这种方式在保证信息密度的同时,也维持了整体视觉的整洁性。

整体来看,这一版 CSS 的核心设计原则是“弱交互、强结构”。所有交互行为均由 JS 控制,CSS 仅负责表达状态变化后的视觉结果,从而避免了 hover 与脚本逻辑之间的耦合问题,使整个右侧菜单系统在行为上更加稳定,在视觉上更加统一。

CSS部分的代码如下(用于WordPress当前主题的”额外CSS”部分):

/* =========================
   Root(保持不变)
========================= */
#semantic-sidebar {
    position: fixed;
    right: 16px;
    top: 50%;
    transform: translateY(-52%);
    z-index: 99999;
    width: 200px;
    font-size: 13px;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

/* =========================
   Panel container(稳定视觉层)
========================= */
.ssb-panel {
    background: rgba(28, 30, 34, 0.72);
    color: #e8e8e8;
    border-radius: 12px;
    padding: 10px;

    box-shadow:
        0 10px 30px rgba(0,0,0,0.35),
        0 0 0 1px rgba(255,255,255,0.04);

    backdrop-filter: blur(12px) saturate(140%);
    -webkit-backdrop-filter: blur(12px) saturate(140%);

    border: 1px solid rgba(255,255,255,0.06);

    position: relative;
}

/* =========================
   Trigger(入口按钮)
========================= */
.ssb-trigger {
    padding: 10px 8px;
    background: rgba(255,255,255,0.06);
    border-radius: 10px;
    cursor: default;
    user-select: none;
    font-weight: 500;
    color: #ddd;

    display: flex;
    align-items: center;
    justify-content: center;

    position: relative;
}

.ssb-trigger::after {
    content: "";
    position: absolute;
    right: 10px;
    top: 50%;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: rgba(120, 180, 255, 0.6);
    transform: translateY(-50%);
    opacity: 0.6;
}

.ssb-trigger:hover {
    background: rgba(255,255,255,0.1);
    color: #fff;
}

/* =========================
   Level container(关键修复)
   ✔ 完全交给 JS 控制
========================= */
.ssb-level {
    display: none;   /* 默认隐藏 */
    margin-top: 10px;
}

/* JS 控制展开 */
.ssb-level-1.active {
    display: block;
}

/* =========================
   Category item
========================= */
.ssb-cat {
    padding: 7px 8px;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.15s ease;
    color: #cfcfcf;

    display: block;
    text-align: left;

    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.ssb-cat:hover {
    background: rgba(255,255,255,0.08);
    color: #fff;
}

/* =========================
   Level 2 panel(完全稳定)
========================= */
#ssb-articles {
    position: absolute;
    right: 100%;
    top: 0;

    width: 220px;
    margin-right: 10px;

    background: rgba(28, 30, 34, 0.75);
    border-radius: 12px;
    padding: 10px;

    box-shadow: 0 10px 28px rgba(0,0,0,0.45);

    backdrop-filter: blur(14px) saturate(150%);
    -webkit-backdrop-filter: blur(14px) saturate(150%);

    border: 1px solid rgba(255,255,255,0.06);

    display: none;
    pointer-events: auto;
}

/* JS 控制显示 */
#ssb-articles.active {
    display: block;
}

/* =========================
   Article items(2行限制)
========================= */
#ssb-articles a {
    position: relative;

    display: block;
    padding: 6px 8px;
    border-radius: 8px;

    color: #bdbdbd;
    text-decoration: none;

    line-height: 1.35;

    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;

    overflow: hidden;

    -webkit-mask-image: linear-gradient(
        to bottom,
        black 85%,
        transparent 100%
    );
    mask-image: linear-gradient(
        to bottom,
        black 85%,
        transparent 100%
    );
}

#ssb-articles a:hover {
    background: rgba(255,255,255,0.08);
    color: #fff;
    transform: none;
}

/* =========================
   cleanup
========================= */
.ssb-level-1 {
    padding-bottom: 0;
}

/* =========================
   移动终端上禁用右侧菜单
========================= */
@media (max-width: 768px) {
    #semantic-sidebar {
        display: none !important;
    }
}

关于 PJAX 环境下的初始化问题

需要注意的是,上述实现默认基于传统页面加载模型,即页面切换时会重新执行 JavaScript 初始化逻辑。

但如果网站启用了 PJAX(PushState + AJAX)等无刷新加载技术,则页面切换过程中通常只会替换部分 DOM 内容,而不会重新加载整个页面。因此,依赖 DOMContentLoaded 或脚本首次执行完成初始化的逻辑,往往只会在第一次访问页面时运行一次,后续页面切换将不会再次触发。

以 Argon 主题为例,其官方文档中明确说明:

window.pjaxLoaded = function () {
    // 页面每次切换完成后执行
};

因此在使用 Argon 等 PJAX 主题时,需要将组件初始化逻辑挂载到对应的 PJAX 生命周期钩子中,使其在每次页面切换后重新执行。例如本文最终版本的 sidebar.js 就采用了这种方式:

window.pjaxLoaded = function () {
    initialized = false;
    init();
};

这样无论是首次加载页面,还是通过 PJAX 进行文章切换,右侧菜单都能够正确读取当前页面对应的 SIDEBAR_DATA 并完成重新初始化。

从工程角度来看,这实际上并不是右侧菜单自身的问题,而是 PJAX 环境下前端组件生命周期管理的问题。对于任何依赖页面初始化的自定义模块,都需要根据主题或框架提供的生命周期钩子进行适配。


3.4.5 右侧菜单运行效果与交互表现

在整个系统加载完成后,页面右侧仅会出现一个轻量级的悬浮入口组件。该组件在初始状态下不展开分类列表,也不展示任何文章内容,其视觉存在感被刻意压缩到最低,仅保留一个稳定的入口区域,从而避免对正文阅读产生干扰:

image.png

当用户将鼠标移动到右侧悬浮区域时,系统进入交互激活状态。此时一级分类列表会被动态展开,并以垂直列表的形式呈现在悬浮框内部。整个展开过程并不依赖复杂动画,而是以稳定的状态切换方式呈现,使得界面行为保持可预测性:

image.png

在该状态下,用户可以直接通过鼠标悬停在不同分类上触发内容切换。每个分类对应一个语义聚类结果,其下方展示的是该分类内部的top4推荐文章列表:

image.png

当用户继续在分类之间移动时,右侧文章面板会即时更新内容,但不会出现整体布局的抖动或页面位移。文章列表始终保持固定宽度与固定位置,仅内容发生替换,从而保证视觉稳定性。这种设计使得交互更接近“信息预览切换”,而不是传统意义上的菜单展开:

image.png

同时,当鼠标从侧边栏区域移出时,系统会触发延迟关闭机制,避免因轻微误触导致面板频繁闪烁或突然消失。在用户完全离开交互区域后,分类列表与文章面板会整体恢复到初始收起状态。


4 系列回顾与演进展望

如果把“为博客构建轻量级知识索引”这一系列放在更长的时间尺度上回看,它实际上并不是围绕某一个单点功能展开的实现集合,而是逐步在回答一个更底层的问题:如何让博客内容从“独立文章集合”转变为“可被机器持续再组织的信息结构”。

在这个过程中,每一篇文章看起来都在解决一个相对独立的问题——相关文章推荐、系列卡片、猜你所想、AI摘要以及右侧语义菜单——但如果将其抽象到同一层,可以看到它们共享的是同一条演进路径:从显式结构,到隐式语义,再到可计算的内容组织方式。

从整体结构上看,这一系列可以被归纳为三个逐步增强的层级:

内容层:
WordPress 原始文章

结构层(信息组织系统):
相关文章 / 系列卡片 / 右侧菜单 / 分类体系 / series 关系

语义增强层:
Embedding 相似度计算 / AI 摘要 / 猜你所想(已删除)

其中,变化的核心并不在于功能数量的增加,而在于结构层与语义层之间边界的逐渐清晰化。早期系统更多依赖显式规则(标签、分类、手动关联),而在引入 embedding 之后,内容之间的关系开始逐步从“人为定义”转向“语义计算驱动”。

在这一体系中,右侧语义菜单处于一个相对特殊的位置:它并没有引入新的语义能力,而是将已有的 embedding 相似度结果与分类空间重新组织为一个贴近用户浏览路径的交互入口。从系统角度来看,它更像是一次“语义结构到用户入口层的映射”,而不是独立的计算模块。

如果从功能维度拆解:

  • “相关文章”解决局部语义邻域问题(embedding 相似度)
  • “系列卡片”解决强结构序列问题(显式顺序)
  • “猜你所想”提供弱语义推断能力(模糊匹配,不过右侧菜单已包含其功能,固已经删除)
  • “AI摘要”提供内容压缩与可读表达
  • “右侧菜单”则是在已有结构之上构建持续可访问的语义入口与分类导航

从这个角度看,这一系列并不是功能叠加,而是围绕同一份内容的不同组织与访问方式逐步展开的结构演进。

与此同时,系统始终保持一个明确的工程约束:语义计算被拆分为离线构建与运行时轻量处理两部分,WordPress 前台仅承担数据加载与展示逻辑。这种模式使得系统在保持扩展能力的同时,仍然具备较低的运行成本与稳定性。

但也正是在这一阶段之后,一个边界开始变得清晰:当前系统已经完成了“语义表达与结构组织”的基础建设,但尚未进入“语义驱动内容流转”的阶段。

换句话说,现有模块已经能够较好表达内容之间的关系,但这些关系大多仍然来源于预计算与规则组合,而尚未形成持续演化的语义生成机制。

如果将这一系列放在更长的演进路径中,它当前所完成的工作可以被理解为一个基础底座的构建过程:让博客内容从孤立文本,转变为具备稳定语义结构的可计算对象。

在这一底座之上,仍然存在一些自然的演进方向,例如:

  • 从文章级语义走向更细粒度的内容单元组织
  • 从静态 embedding 相似度走向基于行为反馈的动态排序
  • 从预计算索引走向局部在线更新机制
  • 从展示型推荐走向路径式内容发现

但这些已经超出当前阶段的目标范围。因此,从系列整体来看,这七篇文章真正完成的,并不是一个“功能集合”,而是一个相对完整的中间层设计:在内容与展示之间引入了一层稳定、可扩展、低成本的语义结构层。这一层的意义也不仅在于功能增强,而在于让内容第一次具备了“可被重新组织”的能力。

至此,“为博客构建轻量级知识索引”系列在结构层面已经完成了一个阶段性的闭环。

📚 系列文章:为博客构建“轻量级知识索引”(7 / 7)

📌 内容结构提示:
这篇内容属于「AI 学习地图」的一部分,你可以从这里查看完整内容路径: AI 学习地图
查看相关分类·3个匹配
📎 相关文章
分享这篇文章
博客内容均系原创,转载请注明出处!博客的RSS地址为:https://blog.tangwudi.com/feed,欢迎订阅;如有需要,可以加入Telegram群一起讨论问题。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
       

👋 欢迎来到“无敌的个人博客”

这里主要围绕以下方向展开长期探索:

🧱 个人数字基础设施与博客系统构建
☁️ Cloudflare 与网络架构实践
🧠 AI 与知识系统探索
🛡️ 网络安全与访问优化
🎵 音乐与声音认知
👁️ 认知视角与世界观