为博客构建“轻量级知识索引”(五):embedding的真正引入
文章摘要
博客推荐功能存在显式结构导致推荐内容重叠的问题,需引入embedding技术挖掘潜在关联。通过构建semantic-source.json提取核心语义内容,生成semantic-vector.json实现向量化表示,并建立semantic-path.json记录语义关联关系,最终基于向量相似度计算实现推荐。该方案有效填补推荐空白,提升推荐多样性与准确性,完成从显式结构到语义空间的推荐体系升级。
Qwen3-8B · 2026-06-17

1 为什么还需要一个“猜你所想”

在之前的文章中(参见:为博客构建“轻量级知识索引”(三):基于预计算语义索引的 WordPress 轻量推荐系统设计与实现),我在”内容结构提示”的下方新增了一个”相关文章”功能(之前叫”推荐阅读”,不过后来觉得”推荐阅读”这个说法覆盖范围太广,所以名字改成了更准确的”相关文章”)。

实际运行了一段时间之后,我对这个功能还是比较满意的——由于推荐逻辑建立在文章标题、标签、分类以及正文内容等显式结构之上,因此大多数情况下都能够推荐出与当前文章高度相关的内容。

不过也有一个问题始终让我有些介意,那就是”相关文章”右侧留下了一大片空白区域,看起来总有种没填满的感觉:

image.png

既然空着也是空着,我自然会琢磨着能不能再放点什么推荐内容进去。但问题在于,“相关文章”本身已经很好地完成了”找出相似文章”这件事情,如果继续基于分类、标签、系列文章、知识地图等显式结构去做推荐,那么最终得到的结果大概率还是和”相关文章”高度重叠,甚至出现大量重复内容。换句话说,如果新的功能只是换一种类似的算法重新推荐相同的文章,那么它存在的意义其实并不大。

既然左侧的”相关文章”负责挖掘显式关联,那么右侧的内容或许应该承担另一种职责:尝试发现那些隐藏在文章表面之下、无法通过分类和标签直接描述出来的潜在关联。而在目前比较成熟的技术方案中,最适合承担这项工作的,恰恰就是向量化之后形成的语义空间(不熟悉向量概念的朋友,可以参考我另一篇文章:向量: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 轻量推荐系统设计与实现

image.png

乍看之下,这个结果与传统的“相关文章”似乎有些相似,因为推荐出的内容同样集中在知识索引、语义检索以及推荐系统等相关主题上,但两者背后的实现逻辑实际上并不相同。

“相关文章”本质上依赖于文章结构,无论是分类、标签、系列关系还是人工整理的知识地图,它关注的都是“这篇文章属于哪个体系”,因此推荐结果通常会沿着既定知识路径继续延伸;而“猜你所想”关注的则是另一件事情:文章到底在讨论什么。

例如《为博客构建“轻量级知识索引”(三)》与系列前两篇文章虽然本身存在系列关系,但从语义层面来看,它们同样围绕语义索引构建、数据组织方式以及推荐系统设计展开讨论。在 Embedding 建立的语义空间中,这些文章天然会被聚集到相近的位置,因此即使完全不依赖分类、标签或者系列关系,也有较大概率被相互关联起来。

这也是语义推荐与传统相关文章最大的区别之一:它并不关心文章被放在哪个分类下,而是尝试理解文章实际表达的内容,然后根据语义上的接近程度建立关联。

当然,在某些情况下,这种关联也可能跨越原有的分类体系。例如两篇文章虽然主题不同,但如果都在讨论知识组织、系统设计或者认知结构等相似概念,它们仍然可能在语义空间中彼此接近,从而被推荐到一起。只是这种跨领域关联是否出现,以及出现的程度,很大程度上取决于所使用的 Embedding 模型以及语义空间的构建方式。

从这个角度来看,“相关文章”更像是在既定地图中按照预设路径继续前进,而“猜你所想”则是在语义空间中寻找距离最近的邻居。两者并不是相互替代关系,而是从不同维度建立内容之间的联系。


其实一开始,我只是想用 embedding 模型做一个“猜你所想”的功能,通过向量相似度来推荐相关内容。但由于早期在分段策略、清洗规则和语义覆盖范围上考虑不够,“猜你所想”的效果并不稳定。于是当时先退一步,基于更直观的结构信息实现了一个“相关文章”模块,用来保证基础的内容关联体验。

后来随着整体链路逐步优化,包括文本清洗、分段策略以及向量构建方式的调整,“猜你所想”的效果逐渐达到了预期。只是出现了一个很自然的现象:它和“相关文章”的推荐结果在很多情况下会比较接近。

从原理上看,这其实并不意外——两者本质上都在做“内容相关性”的判断,只是一个基于语义向量,一个基于结构或显式关系,因此在内容质量较高、主题清晰的情况下,结果收敛是正常的。

我一度考虑过是否只保留其中一个模块,但最终还是选择两者并存。原因也比较简单:虽然它们的推荐结果存在相似,但关注的侧重点并不完全一致。“猜你所想”更偏向语义空间中的潜在兴趣扩展,而“相关文章”更偏向结构层面的确定性关联。前者提供探索空间,后者提供阅读锚点,两者叠加反而让内容导航变得更稳定,也更有层次感。

只不过,为了显出主次,”猜你所想”只显示3个推荐结果。


4 总结

在过去几年里,语义检索、向量数据库、RAG 等技术逐渐成为 AI 应用的重要基础设施。但对于个人博客而言,真正需要解决的问题往往没有那么复杂。大多数情况下,我们需要的并不是一个实时更新、支持亿级数据规模的语义检索系统,而只是希望文章之间能够建立更符合内容本身的关联关系。

因此在整个实现过程中,我始终坚持一个原则:尽量把复杂度留在构建阶段,把简单性留给运行阶段。通过 semantic-source.json、semantic-vector.json 和 semantic-path.json 三层结构,所有语义计算都被提前完成,最终 WordPress 只负责读取结果并展示内容。

这种方式虽然失去了实时计算能力,却换来了极低的运行成本和维护成本。对于个人博客这样更新频率远低于访问频率的场景而言,这种取舍往往更加合理。从结果来看,“相关文章”负责维护知识结构,“猜你所想”负责发现语义关联,两者共同构成了一套兼顾可解释性与探索性的推荐机制。

不过对于我来说,这套系统最大的价值也许并不在于推荐本身。真正有意义的,其实是 Embedding 所建立起来的语义关联网络——在传统博客体系中,文章之间的连接通常来自分类、标签、系列文章以及人工维护的知识地图;而在引入语义关联之后,文章开始拥有一种基于内容本身建立起来的连接关系。

这种关系不依赖预先设计的分类体系,也不依赖人工整理,而是由文章实际表达的内容自然形成。可以说,分类、标签和知识地图描述的是作者眼中的知识结构;而 Embedding 建立的语义关联,则是在尝试发现内容自身呈现出来的关系。

某种意义上,这也算是博客从信息集合逐步演化为知识网络的一次尝试。

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

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

发送评论 编辑评论


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