从向量到关键词:在 LangChain 中的 Elasticsearch 混合搜索

作者:来自 Elastic Margaret GuEyo Eshetu

学习如何通过其 Elasticsearch 集成在 LangChain 中使用混合搜索,并提供完整的 Python 和 JavaScript 示例。

Elasticsearch 充满了新功能,帮助你为你的用例构建最佳搜索解决方案。学习如何在我们的动手网络研讨会中将它们付诸实践,构建现代 Search AI 体验。你也可以现在开始免费的云试用,或在你的本地机器上试用 Elastic。


Elasticsearch 混合搜索 已通过我们的 PythonJavaScript 集成在 LangChain 中可用。这里我们将讨论什么是混合搜索、它在什么时候有用,并通过一些简单示例来开始。

我们也计划很快在社区驱动的 Java 集成中支持 混合搜索。

什么是混合搜索?

混合搜索是一种信息检索方法,它将基于关键词的全文搜索(词法匹配)与 语义搜索(向量相似度)结合在一起。实际来说,这意味着一个查询之所以能匹配到文档,既可能是因为文档包含了正确的词语,也可能是因为文档表达了正确的含义(即使用词不同)。

用简单的话来说,你可以这样理解:

  • 词法检索:这些文档是否包含我输入的词(或相关词)?
  • 语义检索:这些文档的含义是否与我输入的内容相似?

这两种检索方式产生的分数位于不同的尺度上,因此 混合搜索 系统通常会使用一种融合策略,将它们合并成一个统一的排序结果,例如使用 倒数排序融合( RRF )。

在上图中,我们展示了一个示例:BM25(关键词搜索)返回了 文档 A、文档 B 和 文档 C,而 语义搜索 返回了 文档 X、文档 A 和 文档 B。随后,RRF 算法 将这两个结果列表合并成最终排序:文档 A、文档 B、文档 X 和 文档 C。通过 混合搜索,由于 BM25 的存在,文档 C 也被包含在结果中。

为什么混合搜索很重要

如果你已经在生产环境中构建过搜索或 检索增强生成( RAG )功能,你可能一再看到相同的失败模式:

  • 关键词搜索可能过于字面化。如果用户没有使用与你文档中完全相同的词语,相关内容就会被埋没或遗漏。
  • 语义搜索可能过于模糊。它在理解含义方面很强,但也可能返回看起来相关却缺少关键约束的结果,比如产品名称、错误代码,或用户实际输入的特定短语。

混合搜索之所以存在,是因为生产环境中的真实用户查询通常同时需要这两者。

接下来,我们将深入介绍如何在 LangChain 的 Python 和 JavaScript 集成中开始使用混合搜索。如果你想了解更多关于混合搜索的内容,可以查看 什么是混合搜索? 和 混合搜索真正发挥优势的时候

本地搭建 Elasticsearch 实例

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

sql 复制代码
`curl -fsSL https://elastic.co/start-local | sh`AI写代码

启动后,你将拥有:

你的 API key 存储在 .env 文件中(位于 elastic-start-local 文件夹下),名称为 ES_LOCAL_API_KEY

注意 :此脚本仅用于本地测试。请勿在生产环境中使用。有关生产环境安装,请参考 Elasticsearch 官方文档

在 LangChain 中开始使用混合搜索(Python 和 JavaScript)

数据集是一个包含 1,000 部科幻电影信息的 CSV,取自 Kaggle 上的 IMDb 数据集。本演示使用经过清理的数据子集。你可以从我们的 GitHub gist 下载本文使用的数据集,以及本演示的完整代码。

步骤 1:安装所需工具

首先,你需要安装 LangChain 的 Elasticsearch 集成和 Ollama 用于 embeddings。(如果你愿意,也可以使用其他 embedding 模型。)

在 Python 中:

go 复制代码
`pip install langchain-elasticsearch langchain-ollama`AI写代码

在 JavaScript 中:

bash 复制代码
`npm install @langchain/community @langchain/ollama @elastic/elasticsearch csv-parse`AI写代码

步骤 2:配置连接和数据集路径

在 Python 中

在脚本开头,我们设置了:

  • Elasticsearch 所在位置(ES_LOCAL_URL)
  • 如何进行身份验证(ES_LOCAL_API_KEY)
  • 使用的演示索引名称(INDEX_NAME)
  • 我们将导入的 CSV 文件(scifi_1000.csv)
ini 复制代码
`

1.  ES_URL = os.getenv("ES_LOCAL_URL", "http://localhost:9200") 
2.  ES_API_KEY = os.getenv("ES_LOCAL_API_KEY")
3.  INDEX_NAME = "scifi-movies-hybrid-demo" 
4.  CSV_PATH = Path(__file__).with_name("scifi_1000.csv")

`AI写代码

在 JavaScript 中

JavaScript 注意事项**:**

  • JavaScript 使用 process.env 而不是 os.getenv
  • 路径解析需要使用 fileURLToPath 和 dirname 来处理 Elasticsearch 模块
  • 类名为 ElasticVectorSearch(Python 中为 ElasticsearchStore)
arduino 复制代码
`

1.  import { Client } from "@elastic/elasticsearch";
2.  import { OllamaEmbeddings } from "@langchain/ollama";
3.  import {
4.    ElasticVectorSearch,
5.    HybridRetrievalStrategy,
6.  } from "@langchain/community/vectorstores/elasticsearch";
7.  import { parse } from "csv-parse/sync";
8.  import { readFileSync } from "fs";
9.  import { dirname, join } from "path";
10.  import { fileURLToPath } from "url";

12.  const __dirname = dirname(fileURLToPath(import.meta.url));

14.  const ES_URL = process.env.ES_LOCAL_URL || "http://localhost:9200";
15.  const ES_API_KEY = process.env.ES_LOCAL_API_KEY;
16.  const INDEX_NAME = "scifi-movies-hybrid-demo";
17.  const CSV_PATH = join(__dirname, "scifi_1000.csv");

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

现在我们也可以创建客户端。

在 Python 中:

ini 复制代码
`es = Elasticsearch(ES_URL, api_key=ES_LOCAL_API_KEY)`AI写代码

在 JavaScript 中:

css 复制代码
`

1.  const client = new Client({
2.    node: ES_URL,
3.    auth: ES_API_KEY ? { apiKey: ES_LOCAL_API_KEY } : undefined,
4.  });

`AI写代码

步骤 3:导入数据集,然后比较仅向量搜索与 混合搜索

步骤 3a:读取 CSV 并构建索引内容

我们构建三个列表:

  • texts:将被嵌入并搜索的实际文本
  • metadata:与文档一起存储的结构化字段
  • ids:稳定 ID(以便 Elasticsearch 在需要时去重)

在 Python 中

python 复制代码
`

1.  # --- Ingest dataset ---
2.  texts: list[str] = []
3.  metadatas: list[dict] = []
4.  ids: list[str] = []

6.  with CSV_PATH.open(newline="", encoding="utf-8") as f:
7.      for row in csv.DictReader(f):
8.          movie_id = (row.get("movie_id") or "").strip()
9.          movie_name = (row.get("movie_name") or "").strip()
10.          year = (row.get("year") or "").strip()
11.          genre = (row.get("genre") or "").strip()
12.          description = (row.get("description") or "").strip()
13.          director = (row.get("director") or "").strip()

15.          # This text is both:
16.          #  - embedded (vector search)
17.          #  - keyword-matched (BM25 in hybrid mode)
18.          text = "\n".join(
19.              [
20.                  f"{movie_name} ({year})" if year else movie_name,
21.                  f"Director: {director}" if director else "Director: (unknown)",
22.                  f"Genres: {genre}" if genre else "Genres: (unknown)",
23.                  f"Description: {description}" if description else "Description: (missing)",
24.              ]
25.          )
26.          texts.append(text)
27.          metadatas.append(
28.              {
29.                  "movie_id": movie_id or None,
30.                  "movie_name": movie_name or None,
31.                  "year": year or None,
32.                  "genre": genre or None,
33.                  "director": director or None,
34.              }
35.          )
36.          ids.append(movie_id or movie_name)

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

在 JavaScript 中

ini 复制代码
``

1.  async function main() {
2.    // --- Ingest dataset ---
3.    const texts = [];
4.    const metadatas = [];
5.    const ids = [];

7.    const csvContent = readFileSync(CSV_PATH, "utf-8");
8.    const records = parse(csvContent, {
9.      columns: true,
10.      skip_empty_lines: true,
11.    });

13.    for (const row of records) {
14.      const movieId = (row.movie_id || "").trim();
15.      const movieName = (row.movie_name || "").trim();
16.      const year = (row.year || "").trim();
17.      const genre = (row.genre || "").trim();
18.      const description = (row.description || "").trim();
19.      const director = (row.director || "").trim();

21.      // This text is both:
22.      //  - embedded (vector search)
23.      //  - keyword-matched (BM25 in hybrid mode)
24.      const text = [
25.        year ? `${movieName} (${year})` : movieName,
26.        director ? `Director: ${director}` : "Director: (unknown)",
27.        genre ? `Genres: ${genre}` : "Genres: (unknown)",
28.        description ? `Description: ${description}` : "Description: (missing)",
29.      ].join("\n");

31.      texts.push(text);
32.      metadatas.push({
33.        movie_id: movieId || null,
34.        movie_name: movieName || null,
35.        year: year || null,
36.        genre: genre || null,
37.        director: director || null,
38.      });
39.      ids.push(movieId || movieName);
40.    }

``AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

这里的重要点:

  • 我们不仅嵌入 description,还嵌入了一个组合文本块(title/year + director + genre + description)。这样结果更容易展示,有时还能提升检索效果。
  • 同样的文本也是词法部分使用的文本(在 混合搜索 模式下),因为它被索引为可搜索文本。

步骤 3b:使用 LangChain 将文本添加到 Elasticsearch

这是索引步骤。我们在这里嵌入文本并写入 Elasticsearch。

对于异步应用,请使用 AsyncElasticsearchStore,API 相同。

你可以找到 ElasticsearchStore 的同步和异步版本参考文档,以及用于高级 RRF 微调的更多参数。

在 Python 中

ini 复制代码
`

1.  print(f"Ingesting {len(texts)} movies into '{INDEX_NAME}' from '{CSV_PATH.name}'...") 

3.  vector_store = ElasticsearchStore(
4.      index_name=INDEX_NAME,
5.      embedding=OllamaEmbeddings(model="llama3"),
6.      es_url=ES_LOCAL_URL,
7.      es_api_key=ES_LOCAL_API_KEY,
8.      strategy=ElasticsearchStore.ApproxRetrievalStrategy(hybrid=False),
9.  )

11.  #This is the indexing step. We embed the texts and add them to Elasticsearch
12.  vectore_store.add_texts(texts=texts, metadatas=metadatas, ids=ids)

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

在 JavaScript 中

javascript 复制代码
 ``1.    console.log(
2.      `Ingesting ${texts.length} movies into '${INDEX_NAME}' from 'scifi_1000.csv'...`
3.    );

5.    const embeddings = new OllamaEmbeddings({ model: "llama3" });

7.    // Vector-only store (no hybrid)
8.    const vectorStore = new ElasticVectorSearch(embeddings, {
9.      client,
10.      indexName: INDEX_NAME,
11.    });

13.    // This is the indexing step. We embed the texts and add them to Elasticsearch
14.    await vectorStore.addDocuments(
15.      texts.map((text, i) => ({
16.        pageContent: text,
17.        metadata: metadatas[i],
18.      })),
19.      { ids }
20.    );``AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

步骤 3c:为 混合搜索 创建另一个 store

我们创建另一个 ElasticsearchStore 对象,指向相同索引,但具有不同的检索行为:

  • hybrid=False 表示仅向量搜索
  • hybrid=True 表示 混合搜索(BM25 + kNN,通过 RRF 融合)

在 Python 中

ini 复制代码
`

1.  # Since we are using the same INDEX_NAME we can avoid adding texts again 
2.  # This ElasticsearchStore will be used for hybrid search

4.  hybrid_store = ElasticsearchStore(
5.      index_name=INDEX_NAME,
6.      embedding=OllamaEmbeddings(model="llama3"),
7.      es_url=ES_LOCAL_URL,
8.      es_api_key=ES_LOCAL_API_KEY,
9.      strategy=ElasticsearchStore.ApproxRetrievalStrategy(hybrid=True),
10.  )

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

在 JavaScript 中

arduino 复制代码
 `1.    // Since we are using the same INDEX_NAME we can avoid adding texts again
2.    // This ElasticVectorSearch will be used for hybrid search
3.    const hybridStore = new ElasticVectorSearch(embeddings, {
4.      client,
5.      indexName: INDEX_NAME,
6.      strategy: new HybridRetrievalStrategy(),
7.    });

9.    // With custom RRF parameters
10.    const hybridStoreCustom = new ElasticVectorSearch(embeddings, {
11.      client,
12.      indexName: INDEX_NAME,
13.      strategy: new HybridRetrievalStrategy({
14.        rankWindowSize: 100,  // default: 100
15.        rankConstant: 60,     // default: 60
16.        textField: "text",    // default: "text"
17.      }),
18.    });`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

步骤 3d:以两种方式运行相同查询,并打印结果

例如,让我们运行查询:"Find movies where the main character is stuck in a time loop and reliving the same day." 并比较 混合搜索 和 仅向量搜索 的结果。

在 Python 中

python 复制代码
`

1.  query = "Find movies where the main character is stuck in a time loop and reliving the same day."
2.  k = 5

4.  print(f"\n=== Query: {query} ===")

6.  vec_docs = vector_store.similarity_search(query, k=k)
7.  hyb_docs = hybrid_store.similarity_search(query, k=k)

9.  print("\nVector search (kNN) top results:")
10.  for i, doc in enumerate(vec_docs, start=1):
11.      print(f"{i}. {(doc.page_content or '').splitlines()[0]}")

13.  print("\nHybrid search (BM25 + kNN + RRF) top results:")
14.  for i, doc in enumerate(hyb_docs, start=1):
15.      print(f"{i}. {(doc.page_content or '').splitlines()[0]}")

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

在 JavaScript 中

javascript 复制代码
 ``1.    const query =
2.      "Find movies where the main character is stuck in a time loop and reliving the same day.";
3.    const k = 5;

5.    console.log(`\n=== Query: ${query} ===`);

7.    const vecDocs = await vectorStore.similaritySearch(query, k);
8.    const hybDocs = await hybridStore.similaritySearch(query, k);

10.    console.log("\nVector search (kNN) top results:");
11.    vecDocs.forEach((doc, i) => {
12.      console.log(`${i + 1}. ${(doc.pageContent || "").split("\n")[0]}`);
13.    });

15.    console.log("\nHybrid search (BM25 + kNN + RRF) top results:");
16.    hybDocs.forEach((doc, i) => {
17.      console.log(`${i + 1}. ${(doc.pageContent || "").split("\n")[0]}`);
18.    });
19.  }

21.  main().catch(console.error);``AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

示例输出:

java 复制代码
`

1.  Ingesting 1000 movies into 'scifi-movies-hybrid-demo' from 'scifi_1000.csv'...

3.  === Query: Find movies where main character is stuck in a time loop and reliving the same day. ===

5.  Vector search (kNN) top results:
6.  1. The Witch: Part 1 - The Subversion (20  18)
7.  2. Divinity (2023)
8.  3. The Maze Runner (2014)
9.  4. Spider-Man (2002)
10.  5. Spider-Man: Into the Spider-Verse (2018)

12.  Hybrid search (BM25 + kNN + RRF) top results:
13.  1. Edge of Tomorrow (2014)
14.  2. The Witch: Part 1 - The Subversion (2018)
15.  3. Boss Level (2020)
16.  4. Divinity (2023)
17.  5. The Maze Runner (2014)

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

为什么会得到这些结果?

这个查询("time loop / reliving the same day")是 混合搜索 特别擅长的一个案例,因为数据集中包含 BM25 可以匹配的字面短语,同时向量仍能捕捉含义。

  • 仅向量搜索(kNN):将查询嵌入并尝试找到语义相似的剧情。在广泛的科幻数据集中,这可能会偏离到 "trapped / altered reality / memory loss / high-stakes sci‑fi",即使没有 time-loop 的概念。这就是为什么像《The Witch: Part 1 -- The Subversion》(失忆)和《The Maze Runner》(困住/逃脱)这样的结果可能出现。

  • 混合搜索(BM25 + kNN + RRF):奖励同时匹配关键词和语义的文档。描述中明确提到 "time loop" 或 "relive the same day" 的电影会得到强烈的词法提升,因此像《Edge of Tomorrow》(一天一天不断重复)和《Boss Level》(困在不断重复的时间循环中)这样的标题会排在前面。

混合搜索 并不保证每个结果都是完美的。它平衡了词法信号和语义信号,因此你仍可能在前 k 条结果的尾部看到一些非 time-loop 的科幻电影。

主要结论是,当数据集包含关键词时,混合搜索帮助用精确文本证据锚定语义检索。

完整代码示例

你可以在 GitHub gist 上找到我们的完整演示代码(Python 和 JavaScript),以及使用的数据集。

结论

混合搜索通过将传统 BM25 关键词搜索与现代向量相似度结合为单一统一排序,提供了一种务实且强大的检索策略。你无需在词法精度和语义理解之间选择,而是两者兼得,同时不会给应用增加显著复杂性。

在真实世界的数据集中,这种方法通常产生更直观正确的结果。精确词匹配帮助将结果锚定到用户的明确意图,而 embeddings 确保对同义词、改写或不完整查询的鲁棒性。这种平衡对于嘈杂、多样化或用户生成的内容尤其有价值,因为仅依赖单一检索方法往往不够。

在本文中,我们展示了如何通过 LangChain 的 Elasticsearch 集成使用 混合搜索,并提供了 Python 和 JavaScript 的完整示例。我们还在贡献其他开源项目,例如 LangChain4j,以扩展对 Elasticsearch 混合搜索的支持。

我们相信 混合搜索 将成为生成式 AI(GenAI)和 agentic AI 应用的关键能力,并计划继续与生态系统中的库、框架和编程语言合作,使高质量检索更加可访问和稳健。

原文:www.elastic.co/search-labs...

相关推荐
春日见8 小时前
拉取与合并:如何让个人分支既包含你昨天的修改,也包含 develop 最新更新
大数据·人工智能·深度学习·elasticsearch·搜索引擎
Elastic 中国社区官方博客10 小时前
如何防御你的 RAG 系统免受上下文投毒攻击
大数据·运维·人工智能·elasticsearch·搜索引擎·ai·全文检索
Elastic 中国社区官方博客12 小时前
Elasticsearch:交易搜索 - AI Agent builder
大数据·人工智能·elasticsearch·搜索引擎·ai·全文检索
摇滚侠13 小时前
IDEA invalidate caches 中每个勾选项是什么
java·elasticsearch·intellij-idea
历程里程碑14 小时前
矩阵----=矩阵置零
大数据·线性代数·算法·elasticsearch·搜索引擎·矩阵·散列表
SelectDB15 小时前
日志成本降低 83%:云上 Elasticsearch 和 SelectDB 的基准测试及成本分析
数据库·elasticsearch·apache
Ghost Face...16 小时前
嵌入式Linux开发Git实战:从认证到Gerrit推送
linux·git·elasticsearch
历程里程碑18 小时前
Linux 24 进程通信及管道(附上源码实现)
大数据·linux·运维·服务器·算法·elasticsearch·搜索引擎
Elasticsearch18 小时前
易捷问数(NewmindExAI)平台解决 ES 升级后 AI 助手与 Attack Discovery 不正常问题
elasticsearch
fireworks9920 小时前
ELK技术栈监控SQL调用
elasticsearch·kibana