embedding、RAG:让你的搜索更智能

#

zhangqi

2025/05/02

       前言:要实现一个搜索的功能,对于绝大部分后台开发人员来说,都是用数据库的模糊查询来实现。 比如我要搜索”手机“,那么”华为手机“就能被检索出来。但如果数据量很大的话,查询的效率会很慢,索引也不一定能生效。
       而且这个查询结果并不理想,比如我要搜索”华为手机“,那么”华为荣耀手机“就不能被检索出来。这时,引入全文检索就能很完美解决这个问题。全文检索在超大数据量下查询速度也非常快,其数据结构和索引非常高效。
       但是这个搜索并不”智能“,比如我要搜索”华为手机“,对于全文检索来说,”HUAWEI Mate 70系列“或者“鸿蒙操作系统”是不可能被检索出来的。引入embedding向量化的搜索,能完美解决这个问题。
       但是这个搜索并不“全能”,比如我要搜索“最牛逼的神”,对于LLM来说,“张祺”是不会被排在最前面的。引入RAG来辅助LLM搜索,就能完美解决这个问题。
我们今天并不会深入了解Lucene全文检索引擎,Embedding和RAG的底层原理,我们更偏向于使用。


开始

怎么让搜索更智能?人就是智能的,我们问路飞,人就会告诉你《海贼王》;问诸葛亮,人就会告诉你《三国演义》。 人的认知取决于他的知识,专业领域的人回答的更出色。可以这么说:人的知识越多,答案就越多越准确。我们想:能否把人替换成LLM,知识替换成LLM的知识集?那么我们先来考考LLM

可以看到,LLM的回答基本符合我们预期,而且我们使用System Prompt可以让LLM关注更多标签和细节,就非常灵活(比如我们让LLM在计算相似度时,更侧重于作品的国家,那么火影忍者的相似度就会更高,从而排在前面)。 但是我们不应该指望用提示词来做,因为有上下文的限制,而且LLM不总是返回期望的JSON格式。我就不卖关子了,如果你阅读过chatGPT的文档, 你会发现一个叫Embeddings的东西。学过人工智能或者自然语言处理的同学的应该知道这个Embedding这个东西。

OpenAI说 Embedding 通常用于以下场景:

  1. 搜索(结果按查询字符串的相关性进行排序)
  2. 聚类(将文本字符串按相似性分组)
  3. 推荐(推荐具有相关文本字符串的项目)
  4. 异常检测(识别相关性较小的异常值)
  5. 多样性测量(分析相似度分布)
  6. 分类(文本字符串按其最相似的标签进行分类)

Embedding到底是什么呢?它的翻译是"嵌入",通常用在自然语言处理和机器学习领域。我们处理字、词、短语、句子的时候, 通常不是直接操作这些字符,而是把它们映射成数字或者二进制,然后我们就可以通过算法处理关系了(比如,把“我”记为1,“爱”记为2,“你”记为3,那么我们可以用6表示“我爱你”。同理“你爱我”也是6。对于算法来说,这两个句子就非常相似)。 embedding则是把这些数据统一处理成向量,在数学上进行比较和计算,从而对文本建立关联关系。

总之Embedding本质就是一组向量,是LLM用来表示一段文本的映射。你可能有一点思路了,两个向量之间是有数学意义的,通过比较两个文本的向量,就可以知道它们的相似度。没错,就是这样!

相似度,准确来说是语义相似度。在自然语言处理领域,我们一般使用cosine相似度作为语义相似度的度量,评估两个向量在语义空间上的分布情况。它的表达式是这样的:

根据表达式。就可以写出代码:


/**
 * 余弦相似度算法(伪代码)
 * @param vector1 向量1
 * @param vector2 向量2
 * @return 相似度值,范围在[-1,1]之间,值越大表示越相似
 */
public static double cosineSimilarity(double[] vector1, double[] vector2) {
    RealVector realVector1 = new ArrayRealVector(vector1);
    RealVector realVector2 = new ArrayRealVector(vector2);
    double dotProduct = realVector1.dotProduct(realVector2);
    double norm = realVector1.getNorm() * realVector2.getNorm();
    double similarity = dotProduct / norm;
    return similarity;
}

cosine相似度计算简便,数学意义便于理解,适用于高维数据,因此在文本分析场景表现良好。 但是它暴力计算很占资源,对特征相关性和权重不敏感,没法根据相关度排序(比如我搜索张祺:我期望让作者为张祺的排在中间,内容包含张祺的排在后面,给广告费的排在最前面)。

那么现在问题来了,如何求给定文本的向量?也就是embedding?没错,这就是LLM要帮我们做的事情。我们现在只需要让LLM告诉我们,输入文本的向量就可以了。

显然,openai提供了查询输入文本向量的查询接口:

参数说明:

  1. ①、model是必传的,也就是模型名称,(OpenAI)有下面4个模型可以用:

    模型 输出维度
    Ada 1024
    Babbage 2048
    Curie 4096
    Davinci 12288

    Davinci 是能力最强的,但比起其他模型来,更慢更昂贵。Ada 能力最弱,但明显更快更便宜。

    在该案例中,我是使用的text-embedding-ada-002模型,002表示第二代模型,维度是1536。

    维度是指数组的长度。维度越高越准确,但是计算量大。反之越小越不准确,但计算量小,接口响应速度更快。

  2. ②、input是必传的,传一个数组,也就是支持传多个文本,会返回所有文本对应的embedding。
  3. ③、user不是必传的,用于身份验证,主要是为了防止滥用。

调用一下接口,响应如下:

拿到5个向量,分别计算一下相似度(越接近1越相似):

可以看到,诸葛亮和三国演义的相似度是最大的,基本符合我们认知。

对于开发人员,下面这个应该都懂。对于Java如何连接MySQL,JDBC应该是最相似、最优的答案。跟Navicat就没啥关系了。 显然也符合我们预期。


封装embedding

通过上面的介绍,我们已经知道了embedding的能力,接下来就是封装一下,方便我们在代码里调用。embedding依赖于LLM, 因此你必须会OpenAI规范的API调用,会获取不同LLM提供的embedding,这里以OpenAI的API为例。其次你还需要会使用线程池, 来优化繁多的相似度计算。这可能是你接触的第一个“CPU密集型”(数据库是IO密集型)的操作了,你可以深刻感受到初始化线程池线程数量,导致的速度的差距, 我们使用最简单的并行计算算法来优化。


1、我提炼了4个核心方法,需要大家实现,分别是: 1、获取输入文本的向量。2、获取两个向量的相似度。3、初始化向量。4、查询。

借助hoppinAI,我们很容易可以获取到多个文本的向量。借助AI,很容易写出一个可用的相似度计算的算法。


public interface MyEmbedding {

    /**
     * 获取多个文本的向量表示
     * @param inputs 文本内容列表
     * @return
     */
    default List<List<Double>> getEmbeddings(List<String> inputs){
        if (CollUtil.isEmpty(inputs)) {
            return Collections.emptyList();
        }
        OpenAiService service = new OpenAiService(FunctionCallCommon.apiKey,
                Duration.ofSeconds(60),
                FunctionCallCommon.openaiProxy);
        EmbeddingResult embeddings = service.createEmbeddings(EmbeddingRequest.builder()
                .model(FunctionCallCommon.embedding_model)
                .input(inputs)
                .build());
        List<Embedding> data = embeddings.getData();
        List<List<Double>> embeddingList = data.stream()
                .map(Embedding::getEmbedding)
                .collect(Collectors.toList());
        return embeddingList;
    }

    /**
     * 计算两个向量的余弦相似度
     * @param vector1
     * @param vector2
     * @return
     */
    default double getSimilarity(double[] vector1, double[] vector2) {
        if (CollUtil.isEmpty(Collections.singleton(vector1))
                || CollUtil.isEmpty(Collections.singleton(vector2))
                || vector1.length != vector2.length) {
            return 0.0;
        }
        double dotProduct = 0.0;
        double normA = 0.0;
        double normB = 0.0;
        for (int i = 0; i < vector1.length; i++) {
            dotProduct += vector1[i] * vector2[i];
            normA += Math.pow(vector1[i], 2);
            normB += Math.pow(vector2[i], 2);
        }
        return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
    }

    /**
     * 批量插入向量数据
     * @param inputs 文本内容列表
     */
    void insertBatch(List<String> inputs);

    /**
     * 查询向量数据
     * @param input 文本内容
     * @param topK 返回结果数量
     * @param threshold 相似度阈值,低于此值的向量不会被返回
     * @return
     */
    List<String> search(String input, int topK,float threshold);
}

对于第三步,关键是如何存储向量,大家放在数据库里就好,代码可以参考我的实现。关键是第四步查询,但是显然我也不想在这里写代码, 自己去看源代码吧(笑。我只说一下大体的思路:

  1. 1、先从数据库里把所有相关向量(比如我这里的博客向量,视频向量)Es查询出来(如果你会使用向量数据库,就可以不用数据库,我这里方便演示,就把所有向量存到数据库里了
  2. 2、计算输出的向量E1
  3. 3、将所有的向量Es按一定数量平分
  4. 4、创建线程池,按CPU核心数分配核心线程数和最大线程数
  5. 5、创建一个实现Callable接口内部类,用来计算相似度
  6. 6、将所有的向量Es分配到线程池中,计算相似度并汇总
  7. 7、将相似度按从大到小排序,筛出大于阈值的向量Ess
  8. 8、返回向量Ess的原数据

可以看到,使用Embedding实现一个小型的推荐系统的思路就是这样的。


RAG

我们知道,LLM的回答非常依赖于训练它的知识集。你去问它张祺是谁,大模型大概率会顾左右而言他,要么不认识,要么瞎BB。 不认识还好,我们可以通过微调(FineTuning)大模型来让它认识张祺。瞎BB就不太好了,很可能会误导用户,这就是我们常说的大模型的幻觉。 但是如果我们能让大模型在回答问题时,能够参考一些外部知识库,那就可以大大提升它的回答质量,这就是RAG(Retrieval Augmented Generation),检索增强生成。 RAG具有较高的可解释性和定制能力,可大幅降低LLM的幻觉,适用于企业内部知识库、智能客户、代码助手等场景。

RAG的思想是:在生成回答之前,先从知识库中检索相关信息,然后将这些信息作为上下文传递给大模型,让它在生成回答时参考这些信息。 这样一来,大模型就可以基于最新的知识库来回答问题,而不是仅仅依赖于它训练时的知识。

这个操作看起来很简单,实际的技术点很多,当然你只需要会用就行了,但是如果你想自己实现RAG,就需要关注:

  1. 1、文档是如何解析的
  2. 2、内容是如何检索的

文档是如何解析的?大家可能有个疑问,就是RAG跟文档有啥关系。我其实忘了说了,RAG的主要载体,就是各种文档。包括PPT、word、excel、pdf、md、text、图片等格式。 解析文档的目的,就是为了方便检索。 用OCR解析文件内容是一个思路,但是OCR会丢失结构、排版和富文本,我们不考虑。 我们知道文档可以被xml或者json所描述,因此如何将文档转为xml或者json是一个问题,你用poi或者别的类库很容易将文档转为xml。 但是xml和json比较繁琐,不利于拆分和生成向量,就很容易丢失文档的上下文。因此,需要将文档转为markdown。markdown在描述文档结构方面非常出色, 层次感很强,也支持表格、图片、图表、代码片段,而且很小,是最适合AI理解和使用的资源。 AI生成PPT的底层实现,就是AI通过提示词和知识库生成markdown(或json),然后markdown转PPT。

有了markdown,我们很容易通过标题拆分文档,从而生成向量。但是有的时候,文档内容并不总是从上到下排,有可能一页里塞了两块内容,这时候你解析markdown可要小心, 你需要单独定制解析规则,尤其是一些标准的模版文件。有的时候,文档里都是图表、表格、图片,也是需要单独解析的。

代码?没有!这里太小我放不下。你直接去调现成的吧。至于内容是如何检索的,你把上面向量那块学明白就行了。

向量数据库milvus

重排序

如果你已经实现了文档解析和检索,那么实际上你就已经具备了RAG的能力。重排序是检索和生成之间的操作,是对候选文档进行二次筛选和排序。 目的则是将最相关、最有用的内容排在前面,以供后面的LLM学习使用。我们在上面计算embedding的相似度的时候提了一嘴,就是我们使用的cosine相似度 没法对特征相关性和权重排序。举个例子:我搜索了“定积分”,通过检索就会把《初等数学》、《高等数学》、《线性代数》、 《概率论》等等相关章节检索出来。但是我是大学生,所以我们需要通过用户画像或者上下文,微调检索的结果,这就是重排序的工作。 经过重排序后《高等数学》的“微积分章节”就会排在最前面。

作为初学者,您无需关注重排序的实现细节。可以使用精调提示词的大模型作为重排序大模型,并暴露接口。

多轮改写和指代消缺

由于RAG在解析文档,拆分文档的时候,会丢失一些上下文信息,比如段落之间的逻辑关系、段落内的语义关系等(如《鲁宾逊漂流记》的黑人”星期五“,因为文档被拆分了,LLM在对“星期五”生成向量的时候,可能就不知道”星期五“其实是指的一个人)。用户在询问的时候,就可能会出现用户的语义空间和文档的语义空间不统一的情况。 比如:用户询问“北京奥运会的举办年份”,大模型的答案是“2008年”。但是我再询问”当时的美国总统是谁,今年干了什么事?“,你很容易知道是”奥巴马“,干了啥事自己就去百度了。 但是大模型知道“当时”是指什么吗?借助上下文,它还真知道,大模型会改写为“2008年的美国总统是谁,今年干了什么事?”。把“当时”改写为“2008年”,就是指代。(就像我直接问你“后天呢?”,你会一脸懵逼。但是联想到之前我问过你“明天天气怎么样?”,你就懂了上下文对LLM的重要性)

OK,看上图,这就是一个简单的对话,只不过assistant是被LLM增强的RAG的答案。有的同学可能知道LLM的上下文可以解决这个问题,但是你要想明白,LLM发挥上下文的地方 是在RAG的增强环节A和生成环节G,而不是在RAG的检索环节R(在该环节,LLM只发挥了获取embedding的作用)。我们要向量化的,是提问的"当时的这款游戏新出了什么英雄,今年又更新了啥"这一问题。因此,必须在生成向量前,对用户的提问进行指代消缺。

多轮改写并不需要提示词,因此可以让LLM在多轮对话中生成全局提示词(长久记忆)。比如《鲁宾逊漂流记》,全局提示词需要记忆“星期五”这样的指代关系。那么后续章节中,LLM在阅读到“星期五” 的时候,很容易联想到之前出现的“鲁滨逊给黑人奴隶起了个名字叫星期五”,从而建立正确的语义或逻辑关系。

作为初学者,您无需关注多轮改写的实现细节。可以使用精调提示词的大模型作为多轮改写大模型,并暴露接口。

HOPPIN的自建知识库

(注意:通过HOPPINAI上传的知识库文档都会存到我的服务器上,自己玩玩就行了,不要上传涉密文件。因为我没有对知识库做标签,也就是说,你的文件内容是可能被其他人检索到的)

将我上面提供的原子接口组装起来,你就能实现一个RAG(见RAG的那个图)。具体答案就是把用户提问A和上下文B进行指代消缺得到C;再将C向量化得到D;D和多个文档分片进行相似度计算,筛选所有大于某阈值 的文档分片[E];用D和[E]进行重排序得到[E`];将系统提示词、用户提问A和[E`]作为LLM的上下文,让LLM响应即可。

RAG辅助LLM还是比较有意思的,比如你想学一个很新的组件,文档你也不想看,网上的教程也很少。你就可以把网页上的文档内容转markdown格式丢给AI,让它去学着用这个组件(笑)。 此外RAG配合ToolCall或MCP效果拔群,你可以让AI很智能地选择不同的知识库来回答问题。比如我有一个MCP Server是查询张祺的知识库,那我可以声明这个MCP Tools, 让AI仅在用户询问关于张祺的问题,且AI确实不知道的时候,才去查询张祺的知识库。(最好通过提示词来避免穿帮,如:内容来源于知识库,可能是文档,也可能是QA问答对。此外,不要让用户知道你是从知识库获取的内容:## 知识库答案)(不知道MCP?请看我)