作者:来自 Elastic Laura Trotta

了解如何通过其 Elasticsearch 集成在 LangChain4j 中使用混合搜索,并提供完整的 Java 示例。

Elasticsearch 提供了许多新功能,帮助你为你的使用场景构建最佳搜索解决方案。通过我们的实践型 webinar 学习如何构建现代 Search AI 体验。你也可以开始免费的云试用,或者现在就在本地机器上运行 Elastic。


在我们之前关于在 LangChain 中使用 Elasticsearch 混合搜索的文章中,我们解释了为什么混合搜索可以比简单的向量搜索检索到更好的结果,以及它是如何工作的。我们建议你先阅读那篇文章。

除了 Python 和 JavaScript 之外,LangChain 生态系统还有一个社区驱动的 Java 项目,叫做 LangChain4j。本文将重点介绍它,并通过编写一个完整应用展示混合搜索的强大能力,该应用使用 LangChain4j、Elasticsearch 和 Ollama。

设置环境

运行本地 Elasticsearch 实例

在运行示例之前,你需要在本地运行 Elasticsearch。最简单的方法是使用 start-local 脚本:

curl -fsSL https://elastic.co/start-local | sh

启动后,你将拥有:

注意:该脚本仅用于本地测试。不要在生产环境中使用它。对于生产安装,请参考 Elasticsearch 官方文档

运行本地 Ollama 实例

你还需要将你的应用连接到 embedding model。虽然你可以在 LangChain4j 支持的任何 provider 中选择(查看完整列表),但在本示例中我们将使用 Ollama,它可以按照 quickstart 在本地轻松设置。

开始编写代码

这个应用的思路很简单:给定一个电影数据集(来自 Kaggle 上的 IMDb 数据集),我们希望能够找到描述与你查询相关的电影。本演示使用的是清洗后的部分数据。你可以从我们的 GitHub repo 下载本文使用的数据集,以及本演示的完整代码。

步骤 1:依赖和环境

打开你喜欢的 integrated development environment (IDE),创建一个新的空项目,最好使用较新的 Java 版本(我们使用 Java24),并配套相应的 gradle/maven 版本(在我们的示例中是 Gradle 9.0)。

我们只需要三个 dependencies:

dependencies {
    implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.17.0")
    implementation("dev.langchain4j:langchain4j-elasticsearch:1.11.0-beta19")
    implementation("dev.langchain4j:langchain4j-ollama:1.11.0")
}

第一个 dependency 用于导入我们将要进行 embedding 和查询的数据;另外两个是连接和管理我们的 Elasticsearch vector store 和 Ollama embedding model 所需的 LangChain4j dependencies。

连接外部服务的最佳方式是设置 environment variables,并在 main function 开始时读取它们:

String elasticsearchServerUrl = System.getenv("ES_LOCAL_URL");
String elasticsearchApiKey = System.getenv("ES_LOCAL_API_KEY");

String ollamaUrl = System.getenv("ollama-url");
String ollamaModelName = System.getenv("model-name");

步骤 2:导入数据集

由于数据集是 CSV,我们将使用 Jackson dataformat 的 jackson-dataformat-csv 来轻松读取数据并映射到一个 Java class,定义如下:

public record Movie(
    String movie_id,
    String movie_name,
    Integer year,
    String genre,
    String description,
    String director
) {
}

现在我们可以创建一个 CsvSchema 实例来映射 CSV 结构,并将文件读取为 iterator:

CsvSchema schema = CsvSchema.builder()
    .addColumn("movie_id") // 与 csv 中顺序相同
    .addColumn("movie_name")
    .addColumn("year")
    .addColumn("genre")
    .addColumn("description")
    .addColumn("director")
    .setColumnSeparator(',')
    .setSkipFirstDataRow(true)
    .build();

CsvMapper csvMapper = new CsvMapper();

File initialFile = new File("src/main/resources/scifi_1000.csv");
InputStream csvContentStream = new FileInputStream(initialFile);

MappingIterator<Movie> it = csvMapper
    .readerFor(Movie.class)
    .with(schema)
    .readValues(new InputStreamReader(csvContentStream));

每一行数据都需要先进行 embedding,然后 embedding 内容和文本表示都会被导入 Elasticsearch。

首先创建一个 Ollama embedding model 实例:

EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder()
    .baseUrl(ollamaUrl)
    .modelName(ollamaModelName)
    .build();

然后创建 Elasticsearch vector store,它需要一个 Elasticsearch Java RestClient 实例:

RestClient restClient = RestClient
    .builder(HttpHost.create(elasticsearchServerUrl))
    .setDefaultHeaders(new Header[]{
        new BasicHeader("Authorization", "ApiKey " + elasticsearchApiKey)
    })
    .build();

EmbeddingStore<TextSegment> embeddingStore = ElasticsearchEmbeddingStore.builder()
    .restClient(restClient)
    .build();

对于导入循环,LangChain4j library 要求将数据拆分为两个 list,一个用于 vector 表示,一个用于原始文本,因此我们创建两个 list 并在循环中填充:

List<Embedding> embeddings = new ArrayList<>();
List<TextSegment> embedded = new ArrayList<>();

其中 Embedding 和 TextSegment 都是 library 特定的 classes。

我们将遍历 movie dataset iterator,使用 embedding model 为每个电影信息(所有字段合并后的文本表示)生成 vector 表示,并将电影名称单独作为 metadata 添加,以便结果更易阅读。

boolean hasNext = true;

while (hasNext) {
    try {
        Movie movie = it.nextValue();
        String text = movie.toString();

        Embedding embedding = embeddingModel.embed(text).content();
        embeddings.add(embedding);

        Metadata metadata = new Metadata();
        metadata.put("movie_name", movie.movie_name());
        embedded.add(new TextSegment(text, metadata));

        hasNext = it.hasNextValue();
    } catch (JsonParseException | InvalidFormatException e) {
        // 忽略格式错误的数据
    }
}

最后,将 vector list 和 text list 传递给 vector store 方法 addAll(),该方法会异步将数据发送到 vector store:

embeddingStore.addAll(embeddings, embedded);

步骤 3:查询

我们的目标是找到剧情包含时间循环的电影,因此我们的 prompt 是:

String query = "Find movies where the main character is stuck in a time loop and reliving the same day.";

首先尝试简单的 vector search,通过创建一个 content retriever,并使用 k-nearest neighbor (kNN) 查询的默认配置,然后运行查询并打印结果:

ElasticsearchContentRetriever contentRetrieverVector = ElasticsearchContentRetriever.builder()
                .restClient(restClient)
                .configuration(ElasticsearchConfigurationKnn.builder().build())
                .maxResults(5)
                .embeddingModel(embeddingModel)
                .build();

List<Content> vectorSearchResult = contentRetrieverVector.retrieve(Query.from(query));

System.out.println("Vector search results:");
vectorSearchResult.forEach(v -> System.out.println(v.textSegment().metadata().getString(
                "movie_name")));

输出结果:

Vector search results:
The Witch: Part 1 - The Subversion
Divinity
The Maze Runner
Spider-Man
Spider-Man: Into the Spider-Verse

现在看看 hybrid search 的表现:

ElasticsearchContentRetriever contentRetrieverHybrid = ElasticsearchContentRetriever.builder()
    .restClient(restClient)
    .configuration(ElasticsearchConfigurationHybrid.builder().build())
    .maxResults(5)
    .embeddingModel(embeddingModel)
    .build();

List<Content> hybridSearchResult = contentRetrieverHybrid.retrieve(Query.from(query));

System.out.println("Hybrid search results:");
hybridSearchResult.forEach(v -> System.out.println(v.textSegment().metadata().getString(
            "movie_name")));

输出:

Hybrid search results:
Edge of Tomorrow
The Witch: Part 1 - The Subversion
Boss Level
Divinity
The Maze Runner

为什么会出现这些结果?

这个查询(“time loop / reliving the same day”)是 hybrid search 表现很好的典型案例,因为数据集中包含 BM25 可以匹配的字面短语,同时向量也可以捕获语义。

  • Vector-only (kNN) 会对查询进行 embedding,然后寻找语义相似的剧情。在广泛的科幻数据集中,这可能会偏向 “被困 / 改变现实 / 失忆 / 高风险科幻” 等概念,即使没有时间循环。因此像 “The Witch: Part 1 – The Subversion”(失忆)和 “The Maze Runner”(被困 / 逃脱)这样的结果可能出现。
  • Hybrid (BM25 + kNN + reciprocal rank fusion (RRF)) 会奖励同时匹配关键词和语义的文档。那些描述中明确提到 “time loop” 或 “relive the same day” 的电影会获得更强的词汇匹配提升,因此像 “Edge of Tomorrow”(不断重复同一天)和 “Boss Level”(陷入不断重复一天的时间循环)会排在前面。

Hybrid search 不能保证每个结果都完全准确;它是在词汇信号和语义信号之间进行平衡,因此在 top-k 结果的尾部仍可能出现一些非时间循环的科幻电影。

主要结论是:当数据集中包含这些关键词时,hybrid search 可以通过精确文本证据来锚定语义检索。关于 hybrid search 的更多信息,请查看上一篇文章

完整代码示例

完整演示代码可以在 GitHub 上找到。

结论

在本文中,我们通过 Elasticsearch 集成展示了如何在 LangChain4j 中使用 hybrid search,并提供了完整的 Java 示例。本文是上一篇文章的扩展,那篇文章介绍了 Python 和 JavaScript 的 LangChain 集成,并解释了 hybrid search。未来我们计划继续与 LangChain4j 合作,通过 Elasticsearch Inference API 为 embedding models 做出贡献。

原文:https://www.elastic.co/search-labs/blog/langchain4j-elasticsearch-hybrid-search

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐