Rag

19

langchain4j内涉及到了很多组件,虽然这是java版本,但是毕竟来自于python版本,所以python版本也无非是基于这些组件,只是实现方式不同,也可以更方便以后学习其版本的langchain4j

以下是我找到的一个比较好的思维导图,来描述langchain4j和rag的关联关系

Rag就是检索增强生成Retrieval-augmented Generation ,langchain4j4j的rag实现类RetrievalAugmentor

基础LLM只具备通用信息,它的参数都是在公网上进行训练的,无法的得知一些具体的业务数据和实时数据,如公司内部的项目的知识库,虽然也可以直接将知识库内的内容通过训练注入的模型当中,但是成本较大、并且不够灵活

虽然Function-call和SystemMessage可以解决一部分问题,但是如果需要大量的业务领域数据则需要单独外接一个或多个知识库

向量

通常用来做相似性的搜索,比如语义的一维向量可以表示词语或短句的相似性.如你好、hello、很高兴见到你,可以通过一维向量表示语义接近程度.不过如果是一个更复杂的对象,则无法仅通过一个向量而需要多个特征,如颜色、品种等,从而形成一个多位向量

如果需要检索的更加精准需要更多维度向量来组成更多维的空间,相似性检索变得更加复杂,不过我们可以通过向量数据库来实现

需要单独引入一个依赖

<!-- <langchain4j.version>1.0.0-beta1</langchain4j.version> -->
<!-- 接入阿里的相关模型 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-community-dashscope</artifactId>
    <version>${langchain4j.version}</version>
</dependency>

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j</artifactId>
    <version>${langchain4j.version}</version>
</dependency>

以下代码就可以很简单的将字符串你好!转换成一个1536个长度的一维数组

// 使用text-embedding-v2的向量模型,将字符串转换成向量
QwenEmbeddingModel embeddingModel= QwenEmbeddingModel.builder()
        .apiKey(System.getenv("ALI_AI_KEY"))
        .build();
Response<Embedding> embed = embeddingModel.embed("你好!");
System.out.println(embed.content());
// 1536个向量值
System.out.println(embed.content().vector().length);

当然除了阿里的千文以外也可以使用其他的模型,或者使用ollama本地部署,和阿里的模型使用大差不差这里就不说了

不过除此之外langchain4j4j项目内也包含了一个内部模型可以直接使用

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
    <version>0.29.1</version>
</dependency>

内部会加载一个onnx格式的模型文件,并运行这个本地的模型
onnx格式是通过一些python框架训练出的模型,是一种开放性较强的模型格式

public class AllMiniLmL6V2EmbeddingModel extends AbstractInProcessEmbeddingModel {

    private static final OnnxBertBiEncoder MODEL = loadFromJar(
            "all-minilm-l6-v2.onnx",
            "tokenizer.json",
            PoolingMode.MEAN
    );

    @Override
    protected OnnxBertBiEncoder model() {
        return MODEL;
    }
}

向量数据库

可以将向量模型生成的向量持久化到数据库,并利用向量数据库计算两个向量之间的相似度

在langchain4j4j中EmbeddingStore代表向量数据库,有至少20个实现类.一般都是优先使用已有的技术栈,这样就避免运维更多技术栈的负担

  • ElasticsearchEmbeddingStore 基于Elasticsearch的向量数据库

  • InMemoryEmbeddingStore 基于ConcurrentHashMap的向量数据库

  • RedisEmbeddingStore 居于redis的向量数据库

直接内存

EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
QwenEmbeddingModel embeddingModel = QwenEmbeddingModel.builder()
        .apiKey(System.getenv("ALI_AI_KEY")).build();
// 通过向量模型向量化,然后存储到向量数据库
TextSegment segment = TextSegment.from("""
        1.酒店入住
		可通过官网、电话或合作平台进行预订。
		入住时间为下午3点后,退房时间为中午12点前。
		需提供有效身份证件及信用卡预授权担保。
        """);
Embedding embedding = embeddingModel.embed(segment).content();
embeddingStore.add(embedding, segment);
TextSegment segment2 = TextSegment.from("""
        2. 会员权益
		会员等级分为银卡、金卡、白金卡,对应不同积分倍数。
		白金会员可享受免费早餐、延迟退房及行政酒廊权限。
		积分可兑换免费住宿或航空里程,有效期至次年年底。
        """);
Embedding embedding2 = embeddingModel.embed(segment2).content();
embeddingStore.add(embedding2, segment2);
TextSegment segment3 = TextSegment.from("""
        3. 取消与修改
		普通预订需在入住前24小时取消,否则收取首晚费用。
		促销房型不可更改或取消,部分套餐需支付50元服务费。
		不可抗力因素需提供证明,可申请全额退款。
        """);
Embedding embedding3 = embeddingModel.embed(segment3).content();
embeddingStore.add(embedding3, segment3);
// 将需要查询的内容向量化
Embedding query = embeddingModel.embed("退票需要多少钱").content();
// 构建向量数据库查询请求
EmbeddingSearchRequest build = EmbeddingSearchRequest.builder()
        .queryEmbedding(query)
        .maxResults(1) // 符合条件的一条
        .minScore(0.6) // 至少要满足的分数
        .build();
// 查询
EmbeddingSearchResult<TextSegment> segmentEmbeddingSearchResult = embeddingStore.search(build);
segmentEmbeddingSearchResult.matches().forEach(match -> {
    System.out.println(match.score()); // 相似度分数,相似度越高越符合条件
    System.out.println(match.embedded().text()); // 符合条件的文本
});

使用redis

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-redis</artifactId>
	<!-- 到目前为止redis的最新版本依然是1.0.0-alpha1,所以要单独引入版本 -->
    <version>1.0.0-alpha1</version>
</dependency>

需要特殊的版本的redis,否则无法作为向量存储,我这里直接使用docker

docker run -p 6379:6379 redis/redis-stack-server:latest

存储向量,并进行相似度查找

RedisEmbeddingStore embeddingStore = RedisEmbeddingStore.builder()
        .host("127.0.0.1")
        .port(6379)
        .dimension(1536) // 生成向量的维度
        .build();
TextSegment segment = TextSegment.from("我是苹果");
Response<Embedding> embed = embeddingModel.embed(segment);
// 存储到redis
embeddingStore.add(embed.content(), segment);
Embedding query = embeddingModel.embed("我是一个苹果").content();
EmbeddingSearchRequest searchRequest = EmbeddingSearchRequest.builder()
        .queryEmbedding(query)
        .maxResults(1) // 符合条件的一条
        .minScore(0.6) // 至少要满足的分数
        .build();
EmbeddingSearchResult<TextSegment> segmentEmbeddingSearchResult = embeddingStore.search(searchRequest);
segmentEmbeddingSearchResult.matches().forEach(match -> {
    System.out.println(match.score()); // 相似度分数,相似度越高越符合条件
    System.out.println(match.embedded().text()); // 符合条件的文本
});

文本解析

一个知识库系统的资料可能在各种文件中,如word、txt、pdf、image、html等等,langchain4j也提供了不同的文档解析器

  • TextDocumentParser 解析纯文本格式

  • ApachePdfBoxDocumentParser 解析PDF

  • ApachePoiDocumentParser 可以解析Office文件格式

  • ApacheTikaDocumentParser 可以自动检测和解析几乎所有现有的文件格式

// 获取当前类的类加载器并加载resource
Path documentPath = Paths.get(Main.class.getClassLoader().getResource("rag/terms-of-service.txt").toURI());
DocumentParser documentParser = new TextDocumentParser();
Document document = FileSystemDocumentLoader.loadDocument(documentPath, documentParser);
System.out.println(document);

由于文本读取过来后还需要分成一段一段的片段(分块chunk),分块是为了更好地拆分语义单元,这样在后面可以更精确地进行语义相似性检索,也可以避免LLM的Token限制.langchain4j也提供了不同的文档拆分器,不过可能还是需要人工干预

  • DocumentByCharacterSplitter‌ 严格根据字数分隔,会出现断句

  • DocumentByRegexSplitter‌ 根据自定义正则‌分隔

  • DocumentByParagraphSplitter‌ 删除大段空白内容

  • DocumentByLineSplitter‌ 删除单个换行符周围的空白替换一个换行

  • DocumentByWordSplitter‌ 删除连续的空白字符

  • DocumentBySentenceSplitter‌ 按句子分割,能够识别标点符号

DocumentByCharacterSplitter splitter = new DocumentByCharacterSplitter (
        20,         // 每段最长字数
        10          // 自然语言最大重叠字数
);
List<TextSegment> segments = splitter.split(document);

chunk_size(块大小)指的就是分割的字符块的大小,chunk_overlap(块间重叠大小)就是下图中加深的部分,上一个字符块和下一个字符块重叠的部分,即上一个字符块的末尾是下一个字符块的开始

在使用按字符切分时,需要指定分割符,另外需要指定块的大小以及块之间重叠的大小,允许重叠是为了尽可能地避免按照字符进行分割造成的语义损失

过细分块的潜在问题

  • 语义割裂‌ 破坏上下文连贯性,影响模型理解‌

  • 计算成本增加‌ 分块过细会导致向量嵌入和检索次数增多,增加时间和算力开销‌

  • 信息冗余与干扰‌ 碎片化的文本块可能引入无关内容,干扰检索结果的质量,降低生成答案的准确性‌

分块过大的弊端

  • 信息丢失风险‌ 过大的文本块可能超出嵌入模型的输入限制,导致关键信息未被有效编码‌

  • 检索精度下降‌ 大块内容可能包含多主题混合,与用户查询的相关性降低,影响模型反馈效果‌

场景

分块策略

参考参数

微博/短文本

句子级分块,保留完整语义

每块100-200字符‌

学术论文

段落级分块,叠加10%重叠

每块300-500字符‌

法律合同

条款级分块,严格按条款分隔

每块200-400字符‌

长篇小说

章节级分块,过长段落递归拆分为段落

每块500-1000字符‌

同样也可以自己实现DocumentSplitter

public class CustomerServiceDocumentSplitter implements DocumentSplitter {

    @Override
    // TextSegment 文本片段
    public List<TextSegment> split(Document document) {
        List<TextSegment> segments = new ArrayList<>();

        String[] parts = split(document.text());
        for (String part : parts) {
            segments.add(TextSegment.from(part));
        }

        return segments;
    }

    public String[] split(String text) {
        // 多个换行符进行切分
        return text.split("\\s*\\R\\s*\\R\\s*");
    }
}

构建Rag

首先需要先构建一个AiServices对象,构建一个AiServices对象需要多个组件构成,chatModel、chatMemorry、检索增强器等

interface AiCustomer {
    String call(String query);
}

QwenChatModel chatModel = QwenChatModel.builder()
        .apiKey(System.getenv("ALI_KEY")).build();

AiCustomer aiCustomer = AiServices对象.builder(AiCustomer.class)
        .chatLanguageModel(chatModel) // 大语言模型
        .chatMemory(MessageWindowChatMemory.withMaxMessages(10)) // chatMemorry
        .retrievalAugmentor(retrievalAugmentor) // 检索增强器
        .build();
String result = aiCustomer.call(userMessage);

RetrievalAugmentor 检索增强器 内部包含了一系列组件,包含以下部分

  • ContentRetriever 内容检索器 通过和向量模型和向量数据库进行相似度匹配

  • QueryRouter 查询路由器 通过问题的描述,过滤出需要的知识库

  • QueryTransformer 问答转换器 将一个问题转换为多个问题

  • ContentAggregator 提示词增强器 将多个问题进行处理,可以设立优先级、过滤等

  • Contentinjector 提示词注入器 将外部的信息拼接到用户的原始问题,形成新的提示词

RetrievalAugmentor retrievalAugmentor =  DefaultRetrievalAugmentor.builder()
        .queryTransformer(queryTransformer) // 组件-问答转换器
        .contentRetriever(contentRetriever) // 组件-内容检索器
        .contentInjector(contentInjector) // 组件-提示词注入器
        .build();

内容检索器 通过向量检索进行相似度匹配,将匹配的结果用于增强提示词

// 向量存储组件
RedisEmbeddingStore embeddingStore = RedisEmbeddingStore.builder()
        .host("127.0.0.1")
        .port(6379)
  		// 按照模型返回的向量数配置
        .dimension(1536)
        .build();
// 向量组件
QwenEmbeddingModel embeddingModel = QwenEmbeddingModel.builder()
        .apiKey(System.getenv("ALI_KEY")).build();

ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
        .embeddingStore(embeddingStore)  // 组件-向量存储组件
        .embeddingModel(embeddingModel) // 组件-向量组件
        .maxResults(3)
        .minScore(0.8)
        .build();

// 从知识库中返回所有结果
String userMessage = "余额提现什么时候到账?";
List<Content> contentList = contentRetriever.retrieve(new Query(userMessage));

提示词注入器 将结果送给大模型,这些结果就是提示词

// 组件-提示词注入器
// 将结果送给大模型,这些结果就是提示词
ContentInjector contentInjector = new DefaultContentInjector();
// ChatMessage chatMessage = UserMessage.from(userMessage);
// 提示词注入
// ChatMessage injectedMessage = contentInjector.inject(contentList, chatMessage);

查询路由

ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
        .embeddingStore(embeddingStore)  // 组件-向量存储组件
        .embeddingModel(embeddingModel) // 组件-向量组件
        .maxResults(3)
        .minScore(0.8)
        .build();
ContentRetriever contentRetriever2 = EmbeddingStoreContentRetriever.builder()
        .embeddingStore(embeddingStore2)  // 另一个模型
        .embeddingModel(embeddingModel2) // 另一个数据库
        .maxResults(3)
        .minScore(0.8)
        .build();

// 通过大模型,在若干个知识库中寻找匹配的
QueryRouter queryRouter = new LanguageModelQueryRouter(chatModel,
        Map.of(contentRetriever,"订单知识库", contentRetriever2,"商品知识库"));
Collection<ContentRetriever> list = queryRouter.route(Query.from("今天新增了哪些商品"));;

提示词增强器,国内的一些模型都没有ScoringModel,所以这里需要引入一个另一个模型

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-cohere</artifactId>
    <version>${langchain4j.version}</version>
</dependency>
// 与ScoringModel搭配使用
ScoringModel scoringModel = CohereScoringModel.builder()
        .apiKey(System.getenv("RERANK_KEY"))
        .modelName("rerank-multilingual-v3.0")
        .build();
//  使用重新排序聚合器 ReRankingContentAggregator
ContentAggregator contentAggregator = ReRankingContentAggregator.builder()
        .scoringModel(scoringModel)
  			// 传递一个Function,此处为只获取分数最高的第一个
        .querySelector(queryToContents -> queryToContents.entrySet().iterator().next().getKey())
        .build();
List<Content> reRankedAggregated = contentAggregator.aggregate(queryContentMap);

问答转换器

QueryTransformer queryTransformer = new ExpandingQueryTransformer(chatModel);

总结

文档解析

文档解析分位以下几种,配合一下几种方式,向量化以后可以尝试提一下问题给大语言模型,看他匹配的结果怎么样,并进行动态调整

  • 段落明显 如markdown可以直接按照标题进行分割

  • 段落不明显 如ppt、world可以按照一些特征如换行、段落等进行分割

  • 手动切分 支持自定义规则进行拆分,手工修正知识点并重新向量化

直接将整个文字向量话其实意义不大,可以通过模型将整个文字生成摘要,再对这个摘要进行向量话.尽可能不要将向量话的段落特别长,越长匹配度就越低.将知识点切分开来是最好的

如果整个文章就是特别长,对某一个问题进行解答,文章的内容确实不太好拆分,可以直接将文章的标题向量化

如果直接将文章进行向量话,可能很多个问题都会和这个文章匹配,也就是匹配程度不高.最终影响答案的质量

文档查询

  • 匹配数量3个 不建议只匹配一个,可以将用户的问题拆分成多个问题,以后进行匹配.不过匹配的结果太多,也会消耗更多token,并且匹配的过多也会影响答案的质量,导致大语言模型回答和原本的问题关系不大的内容

  • 向量数据库的选择 建议ES,ES使用量更多,学习成本低,更容易在项目中被接受,并且ES自己就是搜索引擎,可以搭配向量混合搜索(如果直接直接通过ES基本的搜索功能查找到结果就不需要再用向量进行搜索),找到更多匹配的结果

  • 使用RAG功能,如QueryTransformer和ContentAggregator进行问题增强

  • 控制好资源使用,如tokens的使用(部分开源项目中就有tokens使用的控制)

  • 可以对项目现有的资源和开发人员喜好搭配不同的模型.所有的模型,如大语言模型、向量模型可以使用不同厂家,没必要统一使用一家公司的所有模型

  • 提示词非常重要要多调试,修改提示词就是一种微调.修改提示词可以让大语言模型的回答更易于理解