语义化搜索学习笔记(结合代码实战)

第一章 语义化搜索概述

1.1 什么是语义化搜索

语义化搜索是一种基于文本语义理解的智能搜索方式,核心区别于传统的"关键词匹配"搜索------传统搜索依赖文本字符的精确匹配或模糊匹配,一旦用户输入的关键词与目标文本的字符差异较大,就会出现匹配失效的问题。例如,用户输入"hello",传统搜索无法识别其与"你好"的语义关联,导致搜索结果偏差;而语义化搜索通过将文本转化为计算机可理解的向量形式,捕捉文本背后的语义含义,即使字符表述不同,只要语义相近,就能精准匹配到相关结果。

简单来说,传统搜索"看字不看意",语义化搜索"看意不看字"。这一特性使其在信息检索、智能问答、内容推荐等场景中具有极强的实用性,也是当前人工智能搜索领域的核心技术之一。

1.2 语义化搜索的核心原理

语义化搜索的核心逻辑可概括为"三步法":文本嵌入(生成向量)→ 相似度计算 → 结果排序。

  1. 文本嵌入(Embedding):通过专门的AI模型,将任意长度的文本(如标题、句子、段落)转化为固定维度的数值向量。这个向量相当于文本的"语义指纹",文本的语义越相似,对应的向量就越接近。本次学习中,我们使用OpenAI的text-embedding-ada-002模型,生成的向量维度为1536维(这也是OpenAI该模型的固定输出维度)。

  2. 相似度计算:通过数学算法,计算用户搜索输入转化后的向量,与数据库中所有文本向量的相似程度。常用的算法为余弦相似度,其计算结果范围在-1,1之间,1表示两个向量完全相同(语义完全一致),0表示两个向量毫无关联(语义完全不同),-1表示两个向量语义相反。

  3. 结果排序:根据相似度计算的结果,将数据库中的文本按相似度从高到低排序,取TopN(本次取前3)作为最终搜索结果,呈现给用户。

1.3 语义化搜索与传统关键词搜索的对比

对比维度 传统关键词搜索 语义化搜索
匹配逻辑 字符匹配(精确/模糊),依赖关键词出现频率 语义匹配,依赖文本向量的相似度
抗干扰能力 弱,关键词错写、同义词替换会导致匹配失效(如"hello"无法匹配"你好") 强,可识别同义词、近义词,不受表述方式影响
理解能力 无法理解文本语义,仅能识别字符 可捕捉文本深层语义,理解用户真实搜索意图
技术依赖 简单的字符串处理算法,无需AI模型 依赖Embedding模型、向量相似度算法,需结合AI工具
适用场景 简单的文件检索、关键词精准查询(如搜索文件名) 智能搜索、内容推荐、问答系统、长文本检索(如搜索文章主题、用户意图)

第二章 语义化搜索核心技术储备

2.1 核心概念:文本嵌入(Embedding)

2.1.1 Embedding的定义

Embedding(嵌入)是将离散的文本数据(如单词、句子)转化为连续的数值向量的过程,生成的向量称为"嵌入向量"。这种转化的核心目的是将文本的语义信息量化,使得计算机能够通过数学运算(如相似度计算)来比较文本之间的语义关联。

举个通俗的例子:我们可以把每个文本看作一个"物品",Embedding就相当于给这个物品贴上一个"数值标签"(向量),两个物品的"标签"越像,就说明这两个物品越相似。比如,"猫"和"狗"的向量会比较接近,而"猫"和"汽车"的向量会相差很远。

2.1.2 本次使用的Embedding模型:text-embedding-ada-002

本次学习中,我们使用OpenAI提供的text-embedding-ada-002模型,该模型是OpenAI推出的轻量、高效的Embedding模型,也是目前语义搜索中最常用的模型之一,其核心特点如下:

  1. 固定输出维度:无论输入文本的长度如何(只要不超过模型限制),生成的向量维度均为1536维,这也是我们在代码中会看到向量长度为1536的原因。

  2. 语义捕捉能力强:能够精准捕捉文本的深层语义,支持多语言(如中文、英文),可识别同义词、近义词,解决传统搜索中"表述不同但语义相同"的匹配问题。

  3. 高效低成本:相比OpenAI的其他Embedding模型,text-embedding-ada-002的调用成本更低,速度更快,适合中小规模的语义搜索场景(如个人学习、小型项目)。

  4. 调用方式:通过OpenAI SDK的embeddings.create()接口调用,需传入模型名称和输入文本,接口会返回对应的嵌入向量。

2.1.3 Embedding的应用场景(拓展)

除了语义搜索,Embedding还有很多常见应用,便于后续拓展学习:

  • 文本聚类:将语义相似的文本归为一类(如文章分类、评论聚类)。
  • 智能问答:将用户的问题转化为向量,与知识库中的问答对向量匹配,找到最贴合的答案。
  • 内容推荐:根据用户浏览内容的向量,推荐语义相似的内容(如短视频推荐、文章推荐)。
  • 情感分析:通过文本向量的特征,判断文本的情感倾向(正面、负面、中性)。

2.2 核心算法:余弦相似度计算

2.2.1 余弦相似度的定义

余弦相似度是衡量两个向量之间夹角余弦值的算法,用于判断两个向量的相似程度。在语义搜索中,两个文本的嵌入向量的余弦相似度,就对应着两个文本的语义相似度。

其数学公式如下(结合代码逻辑拆解):

cosineSimilarity ( v 1 , v 2 ) = dotProduct ( v 1 , v 2 ) length ( v 1 ) × length ( v 2 ) \text{cosineSimilarity}(v1, v2) = \frac{\text{dotProduct}(v1, v2)}{\text{length}(v1) \times \text{length}(v2)} cosineSimilarity(v1,v2)=length(v1)×length(v2)dotProduct(v1,v2)

  1. 点积(dotProduct) :两个向量对应位置元素相乘后求和,公式为 dotProduct ( v 1 , v 2 ) = ∑ i = 0 n − 1 v 1 i × v 2 i \text{dotProduct}(v1, v2) = \sum_{i=0}^{n-1} v1i \times v2i dotProduct(v1,v2)=∑i=0n−1v1i×v2i(n为向量维度,本次n=1536)。点积越大,说明两个向量的方向越接近。

  2. 向量长度(length) :也称为向量的模,是向量中所有元素的平方和的平方根,公式为 length ( v ) = ∑ i = 0 n − 1 v i 2 \text{length}(v) = \sqrt{\sum_{i=0}^{n-1} vi^2} length(v)=∑i=0n−1vi2 。向量长度用于归一化处理,避免向量幅值对相似度计算的影响。

  3. 结果范围:余弦相似度的结果在-1,1之间,具体含义如下:

    • 相似度=1:两个向量方向完全相同,语义完全一致(如"你好"和"您好")。
    • 相似度=0:两个向量方向垂直,语义毫无关联(如"数学"和"苹果")。
    • 相似度=-1:两个向量方向完全相反,语义完全对立(如"喜欢"和"讨厌")。

2.2.2 余弦相似度的代码逻辑拆解

结合本次提供的代码,我们可以清晰看到余弦相似度的实现过程,每一步都对应着数学公式,便于日后复习时对照理解:

javascript 复制代码
// 定义余弦相似度计算函数,参数v1和v2分别是两个嵌入向量
const cosineSimilarity = (v1, v2) => {
    // 1. 计算两个向量的点积:对应元素相乘后求和
    const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
    // 2. 计算向量v1的长度:元素平方和的平方根
    const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
    // 3. 计算向量v2的长度:元素平方和的平方根
    const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
    // 4. 计算余弦相似度:点积除以两个向量长度的乘积
    const similarity = dotProduct / (lengthV1 * lengthV2);
    // 返回相似度结果
    return similarity;
};

关键细节说明(重点记忆):

  • 使用reduce方法简化计算:reduce方法用于遍历向量数组,累加计算点积和元素平方和,相比for循环更简洁,也是JavaScript中处理数组累加的常用方式。
  • 避免除数为0:理论上,嵌入向量不会出现所有元素都为0的情况(即长度为0),但实际开发中可添加判断的(如if(lengthV1 * lengthV2 === 0) return 0;),防止程序报错。
  • 与代码的关联:该函数在后续的搜索逻辑中,会被用于计算用户输入文本的向量,与数据库中所有文本向量的相似度,是语义搜索的核心算法支撑。

2.3 必备工具与环境配置

本次语义搜索的实战的基于Node.js环境开发,用到的工具和模块均为前端/后端开发中常用的,需提前掌握其核心用法,以下结合代码逐一说明:

2.3.1 Node.js环境

Node.js是一个基于Chrome V8引擎的JavaScript运行环境,允许我们在服务器端运行JavaScript代码。本次代码中用到的fs/promises、readline等模块均为Node.js内置模块,因此必须提前安装Node.js(推荐版本16及以上)。

安装验证:打开命令行,输入node -v,若能显示Node.js的版本号,说明安装成功。

2.3.2 OpenAI SDK

OpenAI SDK是OpenAI官方提供的开发工具包,用于调用OpenAI的各种API(如Embedding、ChatGPT等)。本次代码中,我们通过该SDK的embeddings.create()接口生成文本嵌入向量,通过OpenAI类初始化客户端。

安装命令(命令行输入):npm install openai

核心作用:简化API调用流程,无需手动发送HTTP请求,直接通过SDK提供的方法,即可调用OpenAI的Embedding模型,获取嵌入向量。

2.3.3 dotenv模块

dotenv是一个用于加载环境变量的Node.js模块。由于OpenAI API的调用需要API密钥(apiKey)和基础URL(baseURL),这些敏感信息不能直接写在代码中(防止泄露),因此我们将其存储在.env文件中,通过dotenv模块加载到程序中。

安装命令(命令行输入):npm install dotenv

核心用法:通过dotenv.config()方法,加载.env文件中的环境变量,后续可通过process.env.变量名的方式获取(如process.env.OPENAI_API_KEY)。

2.3.4 Node.js内置模块

本次代码用到了3个Node.js内置模块,无需额外安装,直接引入即可使用,重点掌握其核心用法:

  1. fs/promises:文件操作模块的Promise版本,用于读取和写入文件(如读取posts.json中的文本数据,写入posts-embedding.json中的向量数据)。

    • readFile:读取文件内容,语法为fs.readFile(文件路径, 编码格式),返回Promise对象,可通过await关键字获取读取结果(文本字符串)。
    • writeFile:写入文件内容,语法为fs.writeFile(文件路径, 写入内容, 编码格式),返回Promise对象,用于将生成的向量数据写入文件。
    • 补充说明 :早期的fs模块只有回调函数版本(如fs.readFile(路径, 编码, 回调函数)),代码冗余且易出现"回调地狱",Promise版本(fs/promises)可结合await使用,代码更简洁、易读,这也是本次代码中使用该版本的原因。
  2. readline:命令行交互模块,用于从命令行获取用户输入(如用户输入的搜索关键词),并向命令行输出结果。

    • createInterface:创建命令行交互接口,配置输入(input: process.stdin)和输出(output: process.stdout)。
    • question:向命令行输出提示信息,等待用户输入,用户输入完成后,执行回调函数(处理用户输入的内容)。

2.3.5 环境变量配置(关键步骤)

由于调用OpenAI API必须使用API密钥,因此需提前配置.env文件,步骤如下(重点记忆,避免后续调用失败):

  1. 在项目根目录下,创建一个名为.env的文件(注意文件名前有一个点)。

  2. .env文件中,添加以下两行内容(替换为自己的API密钥和基础URL):

    ini 复制代码
    OPENAI_API_KEY=你的OpenAI API密钥
    OPENAI_BASE_URL=你的OpenAI基础URL(如无特殊配置,可使用OpenAI官方URL)
  3. 在代码中,通过dotenv.config()方法加载环境变量(如客户端初始化代码中所示),即可通过process.env获取相关信息。

注意事项:API密钥属于敏感信息,切勿泄露给他人,也不要直接写在代码中,否则可能导致API密钥被盗用,产生不必要的费用。

第三章 语义化搜索完整代码实战拆解

本次实战代码分为4个核心模块,按"客户端初始化→向量生成与写入→相似度计算→命令行交互搜索"的流程编写,每个模块相互关联,共同实现语义化搜索的完整功能。以下逐模块拆解代码,结合知识点详细说明,便于日后复习时逐行理解。

3.1 模块一:OpenAI客户端初始化(app.service.mjs)

3.1.1 代码内容

javascript 复制代码
// 引入OpenAI类(来自openai SDK)和dotenv模块
import OpenAI from 'openai';
import dotenv from 'dotenv';

// 加载.env文件中的环境变量(API密钥、基础URL)
dotenv.config();

// 初始化OpenAI客户端,并导出供其他文件使用
export const client = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY, // 从环境变量中获取API密钥
    baseURL: process.env.OPENAI_BASE_URL, // 从环境变量中获取基础URL
});

3.1.2 代码拆解与知识点关联

  1. 模块引入

    • import OpenAI from 'openai':引入OpenAI SDK中的OpenAI类,用于初始化客户端,后续调用Embedding API必须通过该客户端。
    • import dotenv from 'dotenv':引入dotenv模块,用于加载环境变量,避免敏感信息泄露。
  2. 加载环境变量dotenv.config(),该方法会自动读取项目根目录下的.env文件,将文件中的环境变量加载到process.env中,后续可通过process.env.变量名获取。

  3. 初始化客户端

    • new OpenAI({...}):创建OpenAI客户端实例,传入配置对象,核心配置项为apiKeybaseURL
    • apiKey: process.env.OPENAI_API_KEY:从环境变量中获取API密钥,这是调用OpenAI API的核心凭证,没有API密钥会导致调用失败。
    • baseURL: process.env.OPENAI_BASE_URL:从环境变量中获取基础URL,用于指定API请求的地址,若使用OpenAI官方服务,可省略该配置(默认使用官方URL);若使用第三方代理服务,需填写对应的代理URL。
  4. 导出客户端export const client = ...,将初始化后的客户端导出,供其他代码文件(如向量生成、搜索逻辑)引入使用,避免重复初始化客户端,提高代码复用性。

3.1.3 常见问题与注意事项

  • 客户端初始化失败:若报错"API key is required",说明未加载到API密钥,需检查.env文件是否创建、文件路径是否正确、环境变量名称是否拼写正确(如OPENAI_API_KEY是否写错)。
  • 基础URL配置错误:若报错"请求失败""连接超时",需检查baseURL是否正确,若使用第三方代理,需确保代理服务正常运行。
  • 模块导出导入方式 :本次代码使用ES6模块语法(import/export),因此文件后缀名为.mjs(Node.js默认支持.mjs文件的ES6模块语法);若文件后缀名为.js,需在package.json中添加"type": "module",否则会报错"Cannot use import statement outside a module"。

3.2 模块二:文本向量生成与写入文件(生成posts-embedding.json)

该模块的核心功能是:读取posts.json文件中的文本数据(标题、分类),通过OpenAI的Embedding API将文本转化为向量,将"标题+分类+向量"的组合数据,写入posts-embedding.json文件中,为后续的搜索逻辑提供向量数据支持。

3.2.1 代码内容

javascript 复制代码
// 引入初始化好的OpenAI客户端和fs/promises文件模块
import { client } from './app.service.mjs';
import fs from 'fs/promises'; // node 内置的文件模块,Promise版本

// 注释:早期的fs模块没有promise版本,只能用回调函数,如下所示(已废弃,仅作对比)
// const content = await fs.readFile('./data.txt', 'utf-8', function(err,res){
// });   

// 1. 配置文件路径:读取和写入的文件路径
const inputFilePath = './data/posts.json'; // 读取文件(存储原始文本数据:标题、分类)
const outputFilePath = './data/posts-embedding.json'; // 写入文件(存储文本+向量数据)

// 2. 读取posts.json文件中的内容
const data = await fs.readFile(inputFilePath, 'utf-8');

// 3. 解析JSON字符串:将读取到的文本字符串转化为JavaScript对象(数组)
const posts = JSON.parse(data); // 假设posts是一个数组,每个元素包含title(标题)和category(分类)

// 注释:用于调试,查看读取到的数据和数据长度
// console.log(posts, posts.length);

// 4. 定义数组,用于存储"标题+分类+向量"的组合数据
const postsWithEmbedding = [];

// 5. 遍历posts数组,为每个文本生成嵌入向量
for (const { title, category } of posts) {
    // 调试信息:显示当前正在处理的文本标题
    console.log(`正在处理:${title}`);
    
    // 6. 调用OpenAI的Embedding API,生成文本的嵌入向量
    const response = await client.embeddings.create({
        model: "text-embedding-ada-002", // 指定Embedding模型(固定使用该模型)
        input: `标题:${title} 分类:${category}`, // 输入文本:将标题和分类组合,确保语义完整
    });
    
    // 7. 将"标题+分类+向量"添加到数组中
    postsWithEmbedding.push({
        title, // 原始标题
        category, // 原始分类
        embedding: response.data[0].embedding, // 生成的嵌入向量(1536维)
    });
}

// 8. 将包含向量的数据写入posts-embedding.json文件
// JSON.stringify的参数说明:
// 参数1:要写入的对象(postsWithEmbedding数组)
// 参数2:null(过滤函数,此处无需过滤,填null即可)
// 参数3:2(缩进2个空格,使写入的JSON文件格式整洁,便于阅读)
await fs.writeFile(outputFilePath, JSON.stringify(postsWithEmbedding, null, 2));

// 可选:写入完成后,输出提示信息
console.log(`向量生成完成,已写入${outputFilePath}文件`);

3.2.2 代码拆解与知识点关联

  1. 模块引入

    • import { client } from './app.service.mjs':引入3.1模块中初始化好的OpenAI客户端,用于调用Embedding API,无需重复初始化。
    • import fs from 'fs/promises':引入Node.js内置的文件操作模块的Promise版本,用于读取和写入文件,结合await使用,代码更简洁。
  2. 文件路径配置

    • inputFilePath:指定读取的文件路径(./data/posts.json),该文件中存储原始的文本数据,格式为JSON数组,每个元素包含title(标题)和category(分类)。
    • outputFilePath:指定写入的文件路径(./data/posts-embedding.json),该文件用于存储生成的"标题+分类+向量"数据,供后续搜索模块使用。
    • 注意事项 :需确保./data目录已存在,否则会报错"文件路径不存在",可手动创建data目录,再创建posts.json文件。
  3. 读取并解析文件

    • fs.readFile(inputFilePath, 'utf-8'):读取posts.json文件的内容,编码格式为utf-8,返回的结果是JSON格式的文本字符串。
    • JSON.parse(data):将读取到的JSON字符串转化为JavaScript对象(此处为数组),后续才能遍历数组中的每个元素,获取标题和分类。
    • 补充说明:若posts.json文件格式错误(如JSON语法错误),JSON.parse会报错,需检查文件内容,确保格式正确(如逗号使用正确、引号匹配等)。
  4. 遍历文本并生成向量

    • for (const { title, category } of posts):使用for...of循环遍历posts数组,通过解构赋值,直接获取每个元素的title和category,简化代码。
    • console.log(正在处理:${title}):调试信息,用于查看当前正在处理的文本标题,便于排查问题(如某条文本生成向量失败时,可快速定位)。
    • 调用Embedding APIclient.embeddings.create({...}),这是生成文本向量的核心步骤,参数说明如下:
      • model: "text-embedding-ada-002":指定使用的Embedding模型,必须填写该模型(本次学习固定使用),不可填写ChatGPT等其他模型(其他模型不支持Embedding功能)。
      • input: 标题: t i t l e 分类: {title} 分类: title分类:{category}``:输入文本,将标题和分类组合成一句话,目的是让模型捕捉更完整的语义(仅输入标题可能语义不完整,结合分类可提高向量的准确性)。输入文本可以是任意长度(只要不超过模型限制),模型会自动处理并生成1536维向量。
    • 获取嵌入向量response.data[0].embedding,API返回的response对象中,data是一个数组,数组的第一个元素(index=0)包含生成的嵌入向量,embedding属性即为1536维的向量数组。
    • 存储数据postsWithEmbedding.push({...}),将原始的标题、分类,以及生成的向量,组合成一个对象,添加到postsWithEmbedding数组中,便于后续批量写入文件。
  5. 写入文件

    • JSON.stringify(postsWithEmbedding, null, 2):将postsWithEmbedding数组(JavaScript对象)转化为JSON字符串,参数说明如下:
      • 参数1:要转化的对象(postsWithEmbedding)。
      • 参数2:过滤函数(此处无需过滤任何数据,填null即可)。
      • 参数3:缩进空格数(填2表示缩进2个空格,使生成的JSON文件格式整洁,便于人工阅读;若填0或不填,JSON字符串会压缩成一行,不便于阅读)。
    • fs.writeFile(outputFilePath, ...):将转化后的JSON字符串,写入到outputFilePath指定的文件中,完成向量数据的存储。

3.2.3 常见问题与注意事项

  • 文件路径错误:若报错"ENOENT: no such file or directory",说明读取或写入的文件路径不存在,需检查inputFilePath和outputFilePath是否正确,确保data目录和posts.json文件已创建。

  • JSON格式错误:若报错"Unexpected token in JSON at position X",说明posts.json文件的JSON格式错误,需打开文件检查,确保逗号、引号、括号等符号使用正确(如数组中最后一个元素后面不能加逗号)。

  • API调用失败

    • 报错"Invalid API key":API密钥错误,需检查.env文件中的OPENAI_API_KEY是否正确。
    • 报错"Model not found":模型名称写错,需确保model参数为"text-embedding-ada-002"(大小写敏感)。
    • 报错"Rate limit exceeded":API调用频率超过限制,可暂停一段时间后再尝试,或升级OpenAI账号权限。
  • 异步操作问题 :代码中使用了await关键字,因此该代码必须放在async函数中(否则会报错"await is only valid in async functions and the top level bodies of modules")。可在代码开头添加async函数,如:

    javascript 复制代码
    (async () => {
        // 此处放入整个模块的代码
    })();

3.3 模块三:语义化搜索核心逻辑(命令行交互搜索)

该模块的核心功能是:读取posts-embedding.json文件中的向量数据,通过readline模块获取用户输入的搜索关键词,将关键词转化为向量,计算关键词向量与所有文本向量的余弦相似度,按相似度从高到低排序,取前3条结果,输出到命令行中,实现语义化搜索。

3.3.1 代码内容

javascript 复制代码
// 引入所需模块:文件操作、OpenAI客户端、命令行交互
import fs from 'fs/promises';
import { client } from './app.service.mjs';
import readline from 'readline'; // 从命令行获取输入

// 1. 读取posts-embedding.json文件中的向量数据
const data = await fs.readFile('./data/posts-embedding.json', 'utf-8');
// 2. 解析JSON字符串,转化为JavaScript数组(包含标题、分类、向量)
const posts = JSON.parse(data);

// 3. 创建命令行交互接口,用于获取用户输入和输出结果
const rl = readline.createInterface({
    input: process.stdin, // 输入源:命令行输入
    output: process.stdout, // 输出源:命令行输出
});

// 4. 定义余弦相似度计算函数(与第二章2.2.2中的函数一致,可直接复用)
// 1 相同,0 不同,-1 相反
const cosineSimilarity = (v1, v2) => {
    // 计算向量的点积
    const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
    // 计算向量的长度
    const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
    const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
    // 计算余弦相似度(避免除数为0,添加判断)
    if (lengthV1 * lengthV2 === 0) return 0;
    const similarity = dotProduct / (lengthV1 * lengthV2);
    return similarity;
};

// 5. 定义处理用户输入的函数(核心搜索逻辑)
const handleInput = async (input) => {
    // 可选:调试信息,查看用户输入的内容
    // console.log(`你输入的内容是:${input}`);
    
    // 6. 将用户输入的关键词,转化为嵌入向量(与文本向量生成逻辑一致)
    const response = await client.embeddings.create({
        model: "text-embedding-ada-002",
        input, // 输入为用户输入的关键词
    });
    
    // 7. 获取用户输入关键词的向量
    const { embedding: inputEmbedding } = response.data[0];
    
    // 8. 计算用户输入向量与所有文本向量的相似度,排序后取前3条
    const result = posts.map(item => ({
        ...item, // 复制文本的标题、分类、向量
        similarity: cosineSimilarity(item.embedding, inputEmbedding), // 计算相似度
    }))
    // 第一步排序:从小到大排序(相似度从低到高)
    .sort((a, b) => a.similarity - b.similarity)
    // 第二步排序:反转数组,变为从大到小排序(相似度从高到低)
    .reverse()
    // 第三步:只取前3条结果(Top3)
    .slice(0, 3)
    // 第四步:格式化结果,便于在命令行输出(编号+标题+分类)
    .map((item, index) => `${index + 1}.${item.title}, ${item.category}`)
    // 第五步:将结果数组合并为字符串,每个结果换行显示
    .join('\n');
    
    // 9. 在命令行输出搜索结果
    console.log(`\n 搜索结果:\n${result}\n`);
    
    // 10. 继续等待用户输入(循环交互,直到用户手动关闭命令行)
    rl.question('\n 请输入要搜索的内容:', handleInput);
};

// 11. 启动命令行交互,提示用户输入搜索关键词
rl.question('\n 请输入要搜索的内容:', handleInput);

3.3.2 代码拆解与知识点关联

  1. 模块引入

    • fs/promises:用于读取posts-embedding.json文件中的向量数据(该文件由3.2模块生成)。
    • client:引入初始化好的OpenAI客户端,用于将用户输入的关键词转化为向量。
    • readline:用于创建命令行交互接口,获取用户输入的搜索关键词,并向命令行输出搜索结果。
  2. 读取向量数据

    • fs.readFile('./data/posts-embedding.json', 'utf-8'):读取3.2模块生成的向量数据文件,获取JSON格式的文本字符串。
    • JSON.parse(data):将JSON字符串转化为JavaScript数组,数组中的每个元素包含title、category、embedding三个属性,后续用于计算相似度。
  3. 创建命令行交互接口

    • readline.createInterface({input: process.stdin, output: process.stdout}):创建命令行交互实例rl,配置输入源为命令行输入(process.stdin),输出源为命令行输出(process.stdout)。
  4. 余弦相似度函数复用

    • 此处的cosineSimilarity函数,与第二章2.2.2中的函数完全一致,仅添加了"避免除数为0"的判断(if (lengthV1 * lengthV2 === 0) return 0;),防止出现向量长度为0的情况导致程序报错。
    • 复用函数的好处:减少代码冗余,提高代码复用性,后续若需修改相似度计算逻辑,只需修改一处即可。
  5. 核心搜索逻辑(handleInput函数):该函数是处理用户输入、实现语义搜索的核心,参数input为用户输入的搜索关键词,步骤如下:

    • 关键词转化为向量 :调用client.embeddings.create()接口,将用户输入的关键词(input)转化为嵌入向量,逻辑与3.2模块中"文本转化为向量"的逻辑完全一致(使用相同的模型),确保向量维度和语义捕捉方式一致,才能准确计算相似度。
    • 获取关键词向量 :通过解构赋值,从response.data[0]中获取关键词的向量(inputEmbedding),简化代码(等价于const inputEmbedding = response.data[0].embedding)。
    • 计算相似度并排序
      • posts.map(...):遍历所有文本向量数据,为每个文本添加similarity属性(当前文本与关键词的相似度)。
      • .sort((a, b) => a.similarity - b.similarity):按相似度从小到大排序(a.similarity - b.similarity为正,a排在b后面;为负,a排在b前面)。
      • .reverse():反转排序后的数组,变为按相似度从大到小排序(相似度最高的排在最前面)。
      • .slice(0, 3):截取数组的前3个元素,即相似度最高的3条搜索结果(Top3),满足日常搜索的核心需求。
      • .map(...):格式化搜索结果,将每个结果转化为"编号.标题, 分类"的字符串格式(如"1.语义化搜索入门, 技术教程"),便于在命令行中清晰显示。
      • .join('\n'):将格式化后的结果数组,合并为一个字符串,每个结果之间用换行符(\n)分隔,使输出的搜索结果分行显示,更易读。
    • 输出搜索结果console.log(\n 搜索结果:\n${result}\n);,在命令行中输出搜索结果,添加换行符(\n),使结果与提示信息分隔开,格式更整洁。
    • 循环交互rl.question('\n 请输入要搜索的内容:', handleInput);,在处理完一次用户输入后,再次提示用户输入,实现循环搜索(直到用户手动关闭命令行,如按Ctrl+C)。
  6. 启动交互rl.question('\n 请输入要搜索的内容:', handleInput);,程序运行后,首先向命令行输出提示信息,等待用户输入搜索关键词,输入完成后,调用handleInput函数处理输入,实现第一次搜索。

3.3.3 常见问题与注意事项

  • 向量数据文件不存在:若报错"无法找到posts-embedding.json文件",需先运行3.2模块的代码,生成向量数据文件后,再运行该模块。
  • 搜索结果为空:若用户输入关键词后,输出的搜索结果为空,可能是因为posts数组为空(即posts-embedding.json文件中没有数据),需检查posts.json文件中是否有有效的文本数据,并重运行3.2模块。
  • 命令行交互异常:若程序运行后,无法输入关键词或无法输出结果,需检查readline模块的配置是否正确,确保input和output配置为process.stdin和process.stdout。
  • 相似度排序错误:若搜索结果的相似度排序不符合预期,需检查sort和reverse的调用顺序,确保先从小到大排序,再反转数组(即先sort,后reverse)。

3.4 模块四:Embedding API单独调用示例(测试用)

该模块是一个简单的测试代码,用于单独调用OpenAI的Embedding API,生成单个文本的嵌入向量,查看向量的格式和维度,便于测试API是否能正常调用,向量是否符合预期(1536维)。

3.4.1 代码内容

javascript 复制代码
// 引入客户端(来自app.service.mjs)
import { client } from './app.service.mjs';

// 注释:语义搜索的核心逻辑补充
// 不用字符匹配,keyword 转成向量表达(如"数学"转成1536维向量)
// cosine相似度:1表示相同,越小越不同,-1表示相反
// OpenAI API的三种常用调用方式(区分用途,避免混淆):
// 1. completions.create():AIGC生成(如生成文章、段落)
// 2. completions.chat.create():聊天生成(如ChatGPT对话)
// 3. embeddings.create():向量生成(语义搜索核心,返回[0.23,......]格式的向量,维度1536)

// 测试:调用Embedding API,生成"你好"的向量
const response = await client.embeddings.create({
    // embedding 专有model,必须使用text-embedding-ada-002
    model: "text-embedding-ada-002",
    input: "你好", // 输入文本(可替换为任意文本,用于测试)
});

// 输出向量、向量长度,用于测试
console.log(response.data[0].embedding, response.data[0].embedding.length, '//////');
// 预期输出:一个包含1536个数字的数组,后面跟着1536 //////

3.4.2 代码拆解与知识点关联

  1. 模块引入import { client } from './app.service.mjs';,引入初始化好的OpenAI客户端,用于调用Embedding API。

  2. 核心知识点补充(重点记忆)

    • 语义搜索的核心逻辑:无需字符匹配,将关键词转化为向量,通过余弦相似度判断语义关联,这也是该测试代码的核心目的------验证关键词能否成功转化为向量。
    • OpenAI API的三种常用调用方式 (区分清楚,避免混淆):
      • completions.create():用于AIGC生成,即根据输入的提示词,生成连贯的文本(如生成文章、段落、摘要)。
      • completions.chat.create():用于聊天生成,即模拟对话场景,根据用户的提问,生成贴合上下文的回答(如ChatGPT对话功能)。
      • embeddings.create():用于向量生成,即本次学习的核心,将文本转化为1536维的嵌入向量,用于语义搜索、文本相似度对比等场景。
  3. API调用测试

    • client.embeddings.create({model: "text-embedding-ada-002", input: "你好"}):调用Embedding API,生成"你好"的嵌入向量,模型固定为text-embedding-ada-002。
    • console.log(...):输出生成的向量、向量长度,用于验证API是否调用成功,向量维度是否为1536(预期输出:一个包含1536个数字的数组,后面跟着1536 //////)。

3.4.3 测试目的与注意事项

  • 测试目的:验证OpenAI的Embedding API是否能正常调用,确保生成的嵌入向量格式正确、维度符合预期(1536维);同时,可快速排查API密钥、基础URL、模型名称等配置是否正确,为后续3个核心模块的运行排除基础故障。例如,若该测试代码能正常输出生成的向量及长度1536,说明API调用正常,后续模块中出现的问题可排除API配置层面的原因;若测试失败,可优先排查.env文件配置、API密钥有效性等基础问题。

  • 注意事项

    • 异步操作问题 :代码中使用了await关键字调用API,因此需将代码放入async函数中执行,否则会报错"await is only valid in async functions and the top level bodies of modules",可参考3.2.3小节的异步函数包裹方式,添加自执行async函数,完整示例:(async () => { // 此处放入整个测试代码 })();
    • 输入文本替换:测试时可将input参数替换为任意文本(如"语义化搜索""JavaScript代码"等),观察向量生成情况,进一步理解Embedding模型对不同文本的语义捕捉能力,同时可对比不同文本向量的差异。
    • 向量输出简化 :生成的1536维向量会包含大量小数,命令行输出时可能会显示过长、杂乱,可添加简单的格式化代码(如console.log(向量维度:${response.data0.embedding.length});),仅输出向量维度,简化输出结果,重点验证维度是否为1536即可。
    • API调用成本 :即使是测试代码,调用Embedding API也会产生少量费用,测试完成后可注释掉API调用代码(添加//注释),避免误运行导致不必要的成本消耗;同时,可控制测试频率,减少无效调用。

第四章 语义化搜索学习总结

本次语义化搜索学习围绕"概念→技术→实战→测试"的逻辑展开,通过理论结合代码实战的方式,完整掌握了语义化搜索的核心原理、实现流程及常见问题处理方法。本章节将梳理核心知识点、实战重点、常见问题汇总及拓展方向,便于日后复习回顾,同时巩固所学内容,形成完整的知识体系。

4.1 核心知识点梳理(必背重点)

4.1.1 核心概念

  • 语义化搜索:基于文本语义理解的智能搜索,核心是"看意不看字",区别于传统关键词搜索的"看字不看意",通过文本嵌入和相似度计算实现语义匹配。
  • 文本嵌入(Embedding):将离散文本转化为连续数值向量的过程,生成的向量是文本的"语义指纹",语义越相似,向量越接近;本次使用的text-embedding-ada-002模型,固定输出1536维向量。
  • 余弦相似度:衡量两个向量语义相似程度的核心算法,结果范围-1,1,1表示语义完全一致,0表示无关联,-1表示语义相反,是语义搜索排序的核心依据。

4.1.2 核心原理(三步法)

  1. 文本嵌入:通过text-embedding-ada-002模型,将用户输入的关键词、数据库中的文本,均转化为1536维嵌入向量。
  2. 相似度计算:通过余弦相似度算法,计算关键词向量与数据库中所有文本向量的相似程度,得到每个文本的相似度得分。
  3. 结果排序:按相似度得分从高到低排序,取TopN(本次取前3)作为最终搜索结果,呈现给用户。

4.1.3 核心工具与技术

  • 环境与工具:Node.js(16+版本)、OpenAI SDK、dotenv模块,Node.js内置的fs/promises(文件操作)、readline(命令行交互)模块。
  • API调用:核心是OpenAI的Embedding API(embeddings.create()接口),需指定模型为text-embedding-ada-002,传入输入文本,获取嵌入向量。
  • 代码核心:客户端初始化、向量生成与写入、余弦相似度计算、命令行交互搜索,四个模块相互关联,构成完整的语义搜索流程。

4.2 实战重点回顾(便于复现项目)

本次实战基于Node.js实现了一个简易的语义化搜索系统,核心流程可分为4步,每一步的重点的及注意事项如下,便于日后快速复现项目:

  1. 环境配置(前置步骤)

    • 安装Node.js(推荐16+版本),安装完成后打开命令行,输入node -v验证版本是否正常显示;
    • 在项目根目录打开命令行,输入npm install openai dotenv,安装所需依赖包,等待安装完成(出现node_modules文件夹即为安装成功);
    • 创建.env文件,配置OPENAI_API_KEYOPENAI_BASE_URL,避免敏感信息直接写入代码导致泄露;
    • 创建data目录,新建posts.json文件,填入原始文本数据(格式为JSON数组,每个元素包含title和category字段),确保JSON格式正确,避免后续解析报错。
  2. 客户端初始化 :创建app.service.mjs文件,导入OpenAI和dotenv模块,通过dotenv.config()加载环境变量,初始化OpenAI客户端并导出,供其他模块复用,避免重复初始化客户端造成资源浪费。

  3. 向量生成与写入 :创建向量生成脚本(如generate-embedding.mjs),读取posts.json中的文本数据,遍历每一条文本,调用Embedding API生成对应向量,将"标题+分类+向量"的组合数据,写入posts-embedding.json文件,重点关注文件路径正确性、JSON格式规范性、API调用异常处理。

  4. 核心搜索逻辑 :创建搜索脚本(如search.mjs),读取posts-embedding.json中的向量数据,通过readline模块创建命令行交互接口,定义余弦相似度计算函数,处理用户输入(关键词转向量→计算相似度→排序→输出结果),实现循环搜索,方便用户多次输入关键词查询。

实战关键:确保四个模块的依赖关系正确(搜索模块依赖客户端模块和向量数据文件),API调用时模型名称、输入文本格式正确,异步操作需包裹在async函数中,避免程序报错。

4.3 常见问题汇总(易错点整理)

结合本次实战,整理了高频易错问题及解决方案,便于日后排查故障,提高开发效率:

常见错误 错误原因 解决方案
API key is required 未加载到API密钥,可能是.env文件未创建、环境变量名称拼写错误、文件路径错误 检查.env文件路径和内容,确保环境变量名称为OPENAI_API_KEY,调用dotenv.config()
Unexpected token in JSON posts.json或posts-embedding.json文件JSON格式错误(如逗号、引号使用错误) 检查JSON文件,确保语法正确,数组最后一个元素后不添加逗号
await is only valid in async await关键字未放在async函数中,异步操作无法执行 用自执行async函数包裹代码:(async () => { ... })();
Model not found Embedding API调用时,模型名称拼写错误(大小写敏感) 确保model参数为"text-embedding-ada-002"
ENOENT: no such file or directory 读取或写入文件的路径错误,data目录或目标文件未创建 手动创建data目录和对应JSON文件,检查文件路径是否正确
搜索结果排序错误 sort和reverse调用顺序错误,导致相似度排序不符合预期 先sort(从小到大),再reverse(反转为从大到小)

4.4 拓展学习方向(进阶提升)

本次学习实现的是简易语义化搜索系统,基于基础的向量计算和文件存储,后续可从以下几个方向拓展,提升技术能力,适配更复杂的应用场景:

  1. 向量数据库的使用:本次实战使用JSON文件存储向量数据,适用于小规模数据;当数据量较大(如万级、十万级文本)时,可使用专门的向量数据库(如Pinecone、Milvus、Chroma),优化向量的存储、检索效率,支持更快的相似度计算和结果排序。

  2. 其他Embedding模型的对比使用:除了text-embedding-ada-002,可尝试使用其他Embedding模型(如百度ERNIE Embedding、阿里通义千问Embedding、Hugging Face的BERT模型),对比不同模型的语义捕捉能力、向量维度、调用速度和成本,选择适合具体场景的模型。

  3. 搜索功能优化:当前仅输出Top3结果,可新增相似度得分显示(让用户了解结果匹配程度)、关键词高亮、多关键词搜索、过滤筛选(按分类筛选结果)等功能,提升用户体验。

  4. 前端界面开发:本次实战基于命令行交互,可结合前端技术(如Vue、React)开发可视化界面,实现用户输入、搜索结果展示的可视化,打造完整的语义搜索Web应用。

  5. 异常处理优化:完善代码的异常处理逻辑,如API调用超时、网络错误、文本过长(超过模型限制)等情况的捕获和提示,让程序更健壮,降低故障排查成本。

  6. 语义搜索的高级应用:将语义搜索与其他AI技术结合,如智能问答(基于语义搜索匹配知识库中的问答对)、内容推荐(基于用户行为的语义向量推荐)、文本聚类(基于向量相似度对文本分类)等,拓展技术的应用场景。

4.5 学习心得

通过本次语义化搜索的学习,深刻理解了"语义理解"与传统"字符匹配"的核心区别,掌握了文本嵌入、余弦相似度等关键技术,同时通过代码实战,将理论知识转化为可运行的项目,提升了Node.js开发、API调用、异步操作等实战能力。

语义化搜索作为当前AI搜索领域的核心技术,其核心逻辑(向量生成→相似度计算→结果排序)具有很强的通用性,不仅适用于简单的文本检索,还可延伸到多种AI应用场景。本次学习的重点不仅是代码的实现,更是对"语义量化"思想的理解------将计算机无法直接理解的文本,通过Embedding转化为可计算的向量,从而实现智能化的语义匹配。

后续复习时,可重点回顾核心知识点梳理和常见问题汇总,快速掌握重点和易错点;同时,可通过拓展学习方向,进一步提升技术深度和广度,将所学知识应用到更复杂的项目中,真正实现学以致用。

第五章 核心知识点速记清单(复习专用)

本章节为核心知识点浓缩速记版,提炼全书必背重点、核心逻辑和易错点,摒弃冗余表述,便于快速回顾、考前速查,适配高效复习需求,可搭配前文详细内容对照学习。

5.1 核心概念速记(必背)

  • 语义化搜索:核心"看意不看字",区别于传统关键词"看字不看意",依赖文本嵌入+相似度计算实现语义匹配。
  • 文本嵌入(Embedding):离散文本→连续数值向量(语义指纹),本次用text-embedding-ada-002模型,固定1536维。
  • 余弦相似度:衡量向量语义相似度,范围-1,1,1=完全一致,0=无关联,-1=相反,是搜索排序核心。

5.2 核心原理速记(三步法,必考)

  1. 嵌入:关键词、数据库文本,均通过text-embedding-ada-002转为1536维向量。
  2. 计算:用余弦相似度,算关键词向量与所有文本向量的相似得分。
  3. 排序:按得分从高到低,取Top3作为最终搜索结果。

5.3 核心工具与技术速记

  • 环境:Node.js(16+),依赖包:openai、dotenv。
  • 核心API:OpenAI的embeddings.create(),必选模型text-embedding-ada-002。
  • 内置模块:fs/promises(文件读写)、readline(命令行交互)。
  • 敏感信息:API密钥、baseURL存于.env文件,通过dotenv.config()加载。

5.4 实战核心步骤速记(复现项目专用)

  1. 环境配置:装Node.js+依赖包→创建.env(配置密钥)→创建data目录+posts.json(原始文本)。
  2. 客户端初始化:创建app.service.mjs,导入模块、加载环境变量、初始化客户端并导出。
  3. 向量生成:读取posts.json→遍历文本调用Embedding API→生成"标题+分类+向量",写入posts-embedding.json。
  4. 搜索逻辑:读取向量文件→创建命令行交互→关键词转向量→计算相似度→排序取Top3→输出结果。

5.5 高频易错点速记(避坑重点)

  • API报错"key is required":检查.env文件、环境变量名称、dotenv是否加载。
  • JSON报错"Unexpected token":检查posts.json/embedding.json格式(逗号、引号)。
  • await报错:需将代码放入自执行async函数((async () => { ... })())。
  • Model not found:确保Embedding API的model参数为"text-embedding-ada-002"(大小写敏感)。
  • 文件路径报错:手动创建data目录,检查读写文件路径是否正确。
  • 排序错误:先sort(从小到大),再reverse(从大到小),顺序不可颠倒。

5.6 关键代码片段速记(核心复用)

javascript 复制代码
// 1. 余弦相似度核心函数(复用版)
const cosineSimilarity = (v1, v2) => {
  const dotProduct = v1.reduce((acc, curr, i) => acc + curr * v2[i], 0);
  const lengthV1 = Math.sqrt(v1.reduce((acc, curr) => acc + curr * curr, 0));
  const lengthV2 = Math.sqrt(v2.reduce((acc, curr) => acc + curr * curr, 0));
  if (lengthV1 * lengthV2 === 0) return 0;
  return dotProduct / (lengthV1 * lengthV2);
};

// 2. Embedding API调用核心代码
const response = await client.embeddings.create({
  model: "text-embedding-ada-002",
  input: "需转化的文本/关键词"
});
const embedding = response.data[0].embedding; // 获取1536维向量

补充说明:本速记清单可单独复制保存,复习时重点记忆5.1-5.5小节核心内容,5.6小节代码片段可直接复制到项目中复用,搭配第四章常见问题汇总,可快速排查实战中的报错,提升复习和开发效率;若需补充某部分细节,可对照前文对应章节完善。

相关推荐
Rust研习社6 小时前
组合真的优于继承吗?为什么 Rust 和 Go 都拥抱组合舍弃继承?
后端·rust·编程语言
IT_陈寒7 小时前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端
CaffeinePro8 小时前
Pydantic深度使用:数据校验、枚举、ORM映射
后端·fastapi
Chenyiax8 小时前
从 Chat 到 Responses:OpenAI API 抽象为什么变了?
后端
MariaH8 小时前
Koa和Express的区别
后端
_柳青杨8 小时前
深入理解 JavaScript 事件循环
前端·javascript
MariaH8 小时前
Koa框架的使用
后端
blanks20209 小时前
生成 公钥私钥 笔记
node.js
luckdewei9 小时前
那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
后端
ping某11 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx