最近在做一个知识库问答项目,就是现在大模型浪潮下比较火的 RAG 应用。LangChain 可以说是 RAG 最受欢迎的工具,因此我首选 LangChain 来快速构建我的应用。坦白来讲 LangChain 本身一套对于组件的定义已经让我感觉很复杂,为什么采用 f-string
或 string.format
就能完成的事情必须要抽出一个这么复杂的对象。
当然上面种种原因可能是我不理解 LangChain 设计之禅,但是下面这个坑确实实实在在让我对 LangChain 感到失望的地方。
起因
事情起因很简单,我很快构建好了一个最简单的 RAG 应用,无非以下三步:
- 用户输入
query
。 - 将用户的
query
进行embedding
之后进行相似度检索,并按照阈值过滤相似度低的文本。 - 整合检索的文本并按照一定格式送入大模型。
但是在第二步出现了问题。我在测试的时候发现我总是会召回很多无关的文本,并且我把相似度阈值调高之后,仍然没有把这些不相干的文本过滤掉,这让我十分困惑,但是翻看 LangChain 调用代码之后我瞬间一个恍然大明白,这里 xxx 有坑!
回顾
LangChain 中对于文本检索有个类叫做 BaseRetriever
,刚刚开始我只使用向量数据库进行最简单的检索,但是考虑后续会加入多种检索方式,为了组合方便我采用了 VectorStoreRetriever
进行检索。基本代码是这样的:
1 | python复制代码# 省略加载db的过程 |
就是这样,我把 threshold
调高也不会过滤那些显然无关的文本。于是我就想看看 LangChain 是怎么调用的。
排查
首先看一下 get_relevant_documents()
这个函数调用流程,它在 BaseRetriever
是这么定义的,源码贴脸警告!!!
1 | python复制代码def get_relevant_documents( |
这个函数文档说建议使用 .invoke()
而不是直接调用这个函数,但是 .invoke()
也是间接调用这个函数。这个函数的流程还是挺清晰的,它会处理一些 callback
然后继续调用 _get_relevant_documents()
这个函数,这个函数由每个子类自己实现,我们看看 VectorStoreRetriever
对于这个函数的实现:
1 | python复制代码def _get_relevant_documents( |
这个函数本身逻辑也不难,就是按照 search_type
的不同,调用 vectorstore
的不同方法。所以这个 VectorStoreRetriever
其实就是对 vectorstore
的再一次封装,核心还是调用 vectorstore
的方法。
回到函数本身来,这里出现了一个新的变量叫 search_type
,这个其实在 VectorStoreRetriever
中给出了:
1 | python复制代码class VectorStoreRetriever(BaseRetriever): |
其实当我们调用 vectorstore.as_retriever()
时候也可以指定该参数,我们看看 as_retriever()
这个函数的实现。
1 | python复制代码def as_retriever(self, **kwargs: Any) -> VectorStoreRetriever: |
可以看到这里的 search_type
支持 similarity
、 mmr
和 similarity_score_threshold
三种,默认的是 similarity
。看到这里,第一个引起我疑惑的地方来了,这个 similarity
和 similarity_score_threshold
有什么区别呢?
下面我们分两条线进行分析,按照不同调用链看看他们到底是什么意思。
分支一:similarity
在分支一,会调用 vetorstore.similarity_search()
方法,这是 VectorStore
的一个抽象方法,需要子类自己实现,我们看看 FAISS
是怎么实现的。
1 | python复制代码def similarity_search( |
这里可以看到他是调用了 similarity_search_with_score()
方法,然后把结果中的 score
给略去了,这里不得不吐槽这个调用是不是脱裤子放屁,明明可以写在一个方法里面,传入一个 flag
标识是否要返回分数就可以解决,非要封装成两个方法。吐槽结束继续查看 similarity_search_with_score()
方法。
1 | python复制代码def similarity_search_with_score( |
这个方法就是将 query
进行 embedding
之后,根据向量进行查询,调用了 similarity_search_with_score_by_vector()
方法,我们继续跟踪。
1 | python复制代码def similarity_search_with_score_by_vector( |
这里就是调用了 FAISS
创建数据库时的索引进行相似度的检索,检索之后,会取关键词参数中是否有 score_threshold
,如果之前的调用中传入了阈值分数,则会进行相似度的过滤。因为我遇到的问题就是无法过滤无关内容,因此这里过滤引起了我的注意。
分析一下这个过滤的代码:
- 定义比较算子,如果距离策略采用最大内积或者杰卡德系数就采用大于,否则就是小于。
- 按照算子将相似度和阈值计算来进行过滤。
这里我恍然大悟,我赶紧查看了一下我自己采用了什么距离策略,翻看源码得知 FAISS
默认采用的距离策略是 DistanceStrategy.EUCLIDEAN_DISTANCE
。也就是欧式距离,所以算子应该采用小于,也就是说保留相似度低于阈值的。
这里我恍然大明白,这很好理解,如果你采用欧式距离作为相似度计算,确实应该值越小表示越相似,所以我之前调高相似度阈值反而没有过滤是正常的,因为调的越大,反而过滤力度越小!
这就很反直觉,假如我采用内积作为距离策略,则我之前的行为就是正确的。LangChain 并没有对这个情况进行合理的处理,甚至没有看到 LangChain 对此有一个提示。
分支一就此结束,虽然已经解决了我最开始的问题,但是我们还是继续看看分支二。
分支二:similarity_score_threshold
在分支二,VectorStoreRetriever
会调用 vectorstore.similarity_search_with_relevance_scores()
方法。这里多了一个概念叫 relevance_scores
我们姑且暂时叫做相关性分数,这个和之前相似度有啥关系呢,我们先不揭晓答案,先看看这个函数做了啥。
1 | python复制代码def similarity_search_with_relevance_scores( |
这个函数文档中写到返回文档和对应的相关性分数,相关性分数在0到1之间,0表示不相似,1表示最相似。这个流程也不复杂,但是这里需要理一下流程:
- 把关键词参数中
score_threshold
给弹了出来,这意味着后面传入的关键词参数中不会有score_threshold
这个参数。(这里又是一个让人吐槽的地方,后面再说。) - 调用
_similarity_search_with_relevance_scores()
函数,(这里吐槽一下函数名里面是relevance_scores
但是接受变量确实docs_and_similarities
为什么要搞这么多复杂的名称呢?) - 如果第一步中获得的
score_threshold
不为空则进行过滤,保留相似度大于阈值的文档,注意这里并没有分支一最后的算子判断。
到这里我有点懵了,因为引入了一个 relevance_scores
但是似乎和相似度概念差不多,包括在函数文档以及函数内部都是混用的,所以我很好奇为啥要引入一个新概念。但是有一点确认的是,相关性分数越高,文本相似度越高,无论你采用了什么样的距离策略都是这样的。
让我们继续观察调用链,看看第二步中的函数:
1 | python复制代码def _similarity_search_with_relevance_scores( |
函数文档再次说明返回文档和对应的相关性分数,相关性分数在0到1之间,0表示不相似,1表示最相似。函数也很简单,首先调用了一个相关性分数函数,然后调用 similarity_search_with_score()
得到文档和相似度,最后将相似度按照相关性分数函数做一个转换,至此两个分支走到了一起,最终都是调用 similarity_search_with_score()
。
这里就可以回答为什么之前要 pop
关键词参数中的阈值,因为如果关键词参数中有 score_threshold
,那么在 similarity_search_with_score()
这步就会进行过滤,但是这个函数过滤是按照距离策略不同选不同算子,分支二过滤直接按照大于进行过滤。
到了这里在混乱的概念中有个初步的印象,可以得到如下三个观点:
- 相似度和相关性是不同的,至少在 LangChain 中是这样定义的,虽然在函数中两个变量混用,但是按照行为上确实是不同的两个定义。
- 相关性分数越大,则文本越相关;相似度则是根据距离策略决定,对于欧式距离,相似度越小,文本越相关。
- 相关性分数通过相似度计算出来的,计算函数就是
_select_relevance_score_fn()
。
我感觉到了胜利的曙光,只要查明这个 _select_relevance_score_fn()
具体做了啥,就知道这两个定义如何关联的了。
1 | python复制代码def _select_relevance_score_fn(self) -> Callable[[float], float]: |
这里可以看到不同的 vectorstore
实现是不同的,这里我当然讨论的是 FAISS
,我们看 LangChain 在 FAISS
中如何定义的。
1 | python复制代码def _select_relevance_score_fn(self) -> Callable[[float], float]: |
这里面可以看到 LangChain 对 FAISS
支持三种距离策略,每个策略有不同的计算公式,这里我直接贴出三个计算公式:
1 | python复制代码@staticmethod |
这里我们都考虑 embedding
向量经过 L2
正则化,则内积和余弦相似度计算应该相同,实际上在内积上有存在问题。
首先内积为负值,直接取其相反数没有问题,因为负相关也是相关,但是当为正值时就有问题了,举个例子,假如采用内积计算,得到一个相似度为 0.7 的值,理应这两个比较相关,但是通过这个相关性函数得到只有 0.3 反而变成不相关了。这三个公式只有欧式距离是正确的。
实验
上面说明 LangChain 对于不同距离策略,没能给出正确的过滤方式,且对于相关性的计算,搞反了语义相似性和相关性的关系。
对于 VectorStore
而言,如果采用欧氏距离,采用 similarity_search_with_relevance_scores()
才能正确按照相似度过滤文档,相应的 VectorStoreRetriever
中的 search_type
应该采用 similarity_score_threshold
。
如果采用最大内积,采用 similarity_search_with_score()
才能正确检索文档,相应的 VectorStoreRetriever
中的 search_type
应该采用 similarity
。
除此之外的组合都不能按照预期的检索出文档。
为了证明我的猜想,下面进行实验环节。
版本信息
我采用的 LangChain 版本如下:
1 | shell复制代码pip show langchain |
实验过程
导包环节
1 | python复制代码import numpy as np |
我将下面三句毫不相关的话为文档,建立三个不同距离策略的向量库。
1 | pyton复制代码text_list = ["今天天气真好", "我喜欢吃苹果", "猴子排序很不可靠"] |
OpenAIEmbeddings
会将向量进行 L2
正则化。
1 | python复制代码for embedding in embedding_list: |
建立下面三个向量库:
1 | python复制代码vs1 = FAISS.from_embeddings(zip(text_list, embedding_list), embeddings, normalize_L2=True, distance_strategy=DistanceStrategy.EUCLIDEAN_DISTANCE) |
我们先都检索一下,确保三个向量库中内容都存在。
1 | python复制代码print(vs1.similarity_search_with_score("今天天气真好")) |
这里可以看到采用余弦相似度作为距离策略的向量库,检索分数和欧氏距离相同,这里我认为是 FAISS
支持的是欧氏距离和内积,虽然正则化后内积和余弦相似度等价,但是建立索引时候 FAISS
并不支持余弦相似度,于是按照欧氏距离建立的索引。一个猜测,没有证实。
按照上面的猜想,在 VectorStore
中,如果采用 similarity_search_with_score()
给出分数阈值,只有采用内积的能正确过滤文档。
1 | python复制代码print(vs1.similarity_search_with_score("今天天气真好", score_threshold=0.8)) |
事实果真如此,如果采用 similarity_search_with_relevance_scores()
给出阈值分数,只有采用欧氏距离能正确过滤文档。
1 | python复制代码print(vs1.similarity_search_with_relevance_scores("今天天气真好", score_threshold=0.8)) |
结果也是如此,你可能会疑问余弦相似度也能正确输出,这是因为首先在距离计算时,它采用了欧氏距离,然后相关性分数时采用余弦相似度也是错的,两次错误导致语义和相关性的关系是对的。但是好的程序不能靠 BUG 过活!
在 VectorStore
层面,证明了我的结论的正确性,那按照调用链来说 VectorStoreRetriever
也满足我的结论,但是还是继续实验。
当 search_type
为 similarity
时,只有内积是正确召回。
1 | python复制代码search_type = "similarity" |
当 search_type
为 similarity_score_threshold
时,只有欧氏距离是正确召回。
1 | python复制代码search_type = "similarity_score_threshold" |
这里余弦相似度正确召回原因同上,靠 BUG 过活罢了。
实验最后再重申一下我的结论:
对于 VectorStore
而言,如果采用欧氏距离,采用 similarity_search_with_relevance_scores()
才能正确按照相似度过滤文档,相应的 VectorStoreRetriever
中的 search_type
应该采用 similarity_score_threshold
。
如果采用最大内积,采用 similarity_search_with_score()
才能正确检索文档,相应的 VectorStoreRetriever
中的 search_type
应该采用 similarity
。
注:当前实验只对 LangChain 封装的 FAISS
负责,别的向量库不负责。
后记
这次一个问题的溯源让我觉得那些流行的开源库也不是高高在上,里面也会存在很多问题:有的明明能靠一个标记变量区别,但是非要重新封装函数、引入过多概念,导致代码混乱等等。
后面使用 LangChain 构造 Agent 时,发现它似乎是让模型按照一定的 JSON 格式输出 action
和 action_input
然后解析这个 JSON 格式进行下一步操作,如果模型不是严格按照这个 JSON 格式输出(例如多输出一些文本)就会出现解析错误的问题,并且这种方式似乎没有利用模型本身的 function call
能力。这个还没有仔细查看,欢迎大家指正。
在这段时间使用 LangChain 的过程中,我感觉它只有文本分割和集成向量检索这两部分比较实用,现在发现检索也存在问题。他的复杂设计让我感觉不如自己编写一套可复用的库来实现自己的需求,也或许是我没有真正理解到 LangChain 设计之禅吧。
本文转载自: 掘金