Chroma+LangChain:让AI联网回答更精准

前言

前段时间实现了《Ollama + DeepSeek 本地大模型实现联网回答》的功能,但当时直接将搜索引擎抓取到的内容截取 5000 字符后提交给 AI 了。这么做不仅可能会丢掉有效信息,而且由于 tokens 过多,也会导致 AI 回复的准确性和速度变差。因此,在此基础上,我加入了 Chroma 向量数据库作为中间层。当网页内容抓取到之后,通过 LangChain 的分片方法进行分片后入库,再通过关键词从向量数据库中搜索获取高关联的内容。

Chroma 向量数据库

自从 ChatGPT 带火了 AI 大模型之后,向量数据库也紧跟着迎来了一波热潮。目前可选择的开源向量数据库有很多,Chroma 并不是其中最优秀的,但它轻量、名气大,同时官方在一年前也推出了 Node.js 客户端。因此,对于这个 Demo 项目来说,选择 Chroma 绰绰有余。

安装

Chroma 的安装非常简单,可以通过 Python Pip 安装,也可以通过 Docker 安装。

docker 安装

推荐使用 Docker 安装,这样可以避免安装 Python 环境。

bash 复制代码
docker pull chromadb/chroma
docker run -p 8000:8000 chromadb/chroma

示例:

pip 安装

通过 Python pip 命令安装,需要先部署 Python 环境。

bash 复制代码
pip install chromadb
chroma run --path /db_path

示例:

使用

这里我们只介绍 Node.js 中的用法,Python 用法可以参考官方文档。

安装客户端

  • chromadb 是数据库的客户端,连接数据库以及数据库的操作都依赖它。
  • chromadb-default-embed 是用来将内容转换成词向量的库,目测应该是基于 2.x 版本的 transformers.js 进行二次开发的。
bash 复制代码
npm install --save chromadb chromadb-default-embed

创建客户端

可以通过 new ChromaClient 传入 path 参数,连接到 Chroma 服务端。

js 复制代码
import { ChromaClient } from "chromadb";
const client = new ChromaClient({path: 'http://127.0.0.1:8000'});

创建集合

Chroma 需要创建集合来存储数据,集合相当于关系型数据库中的 databaseschema。集合中存储嵌入式数据、文档和任何附加元数据。集合可以为嵌入式文件和文档编制索引,并实现高效检索和过滤。

js 复制代码
const collection = await client.createCollection({ name: "my_collection", });

添加文档

Chroma 会自动存储文本并处理嵌入和索引。你还可以自定义嵌入模型,同时必须为文档提供唯一的字符串 ID。

js 复制代码
await collection.add({
    documents: [
        "这是一个关于介绍 DeepSeek 的文档......",
        "这是一个关于前端技术栈的文档......",
    ],
    ids: ["id1", "id2"],
});

查询集合

可以通过查询文本来查询集合,Chroma 会返回 n 个最相似的结果。如果没有设置 nResults 参数,Chroma 默认会返回 10 个结果。

js 复制代码
const results = await collection.query({
    queryTexts: "介绍一下 DeepSeek", // 查询文本,会自动转换为向量查询
    nResults: 2, // 查询返回的条数
});

console.log(results);

目前暂时就介绍这么多,毕竟这篇不是专门介绍 Chroma 的文章。

业务实现

上面我们介绍完 Chroma 的安装与基本使用,现在就可以回归主题来实现功能了。

在上篇文章中,我们已经实现了从搜索引擎搜索到拿到列表然后爬取页面内容的功能,现在我们将爬取到的数据存到 Chroma 中。

实现数据入库

上面我们已经介绍了创建、存储的 API,这里我们直接封装数据入库的方法。其实主要就两步:获取集合后通过 collection.add 添加数据。

js 复制代码
/**
 * 将结果存储到 Chroma 数据库中
 *
 * @param {Array<Object>} results - 结果数组,每个元素包含 url、title 和 content 属性
 * @param {string} [collectionName='web_pages'] - 集合名称
 * @returns {Promise<void>}
 */
export async function storeInChroma(results, collectionName = 'web_pages') {
  results = results.filter(({ link, content }) => link && content)
  if (!Array.isArray(results) || results.length === 0) {
    logger.error('存储数据失败: 结果数组为空');
    throw new Error('存储数据失败: 结果数组为空');
  }

  const collection = await getOrCreateCollection(collectionName, '存储网页数据');

  try {
    await collection.add({
      ids: results.map(({ link }) => link),
      metadatas: results.map(({ title, link }) => ({ title, link })),
      documents: results.map(({ content }) => content), // 主要网页内容
    });
    logger.info(`数据存储到集合 ${collectionName} 成功`);
  } catch (error) {
    logger.error(`数据存储到集合 ${collectionName} 失败:`, error);
    throw new Error(`数据存储到集合 ${collectionName} 失败: ${error.message}`);
  }
}

查询数据库

查询数据库同样简单,获取集合之后,通过 queryTexts 参数可以进行全文检索。查询时 Chroma 会自动转换成向量进行搜索,还支持 nResults 参数,可以指定查询的数量。

js 复制代码
/**
 * 根据文本查询指定集合
 * @param {Array<string>} queryTexts - 查询文本数组
 * @param {string} [collectionName='web_pages'] - 集合名称
 * @returns {Promise<Object>} - 查询结果
 */
export async function queryCollectionByText(queryTexts, collectionName = 'web_pages') {
  if (!Array.isArray(queryTexts) || queryTexts.length === 0) {
    logger.error('查询数据失败: 查询文本数组为空');
    throw new Error('查询数据失败: 查询文本数组为空');
  }

  const collection = await getOrCreateCollection(collectionName);
  try {
    const result = await collection.query({ queryTexts });
    return result;
  } catch (error) {
    logger.error(`查询集合 ${collectionName} 失败:`, error);
    throw new Error(`查询集合 ${collectionName} 失败: ${error.message}`);
  }
}

文本预处理

有了 Chroma 也并不是万事大吉了。Chroma 只是一个向量数据库,只能根据你的查询内容返回相似的完整片段。因此,当你将多个文档原封不动地存入数据库之后,查询出来的精度并不会提升。

所以在此之前,我们需要对数据进行预处理,比如清洗、分词、去除停用词等等,以便于后续的嵌入和检索。

网页内容清洗

在之前的实现中,我们是直接将 Google 搜索的结果列表进行内容爬取,然后直接提交给 AI。但网页内容通常不仅仅只有文章内容,还有很多例如广告、网站菜单、背景图片以及视频,这些无用的内容会影响数据的质量,因此需要清洗掉。

比如我们访问的这个网页,里面包含了很多与文章内容无关的其他内容,比如右侧面板、底部的评论和广告等等,这些无意义的数据都会影响数据的质量。

这里我们使用 Mozilla(火狐浏览器)开源的 Readability 工具库,可以清除按钮、广告、背景图片以及视频等内容,获取最纯粹的文章内容。在截图中我们可以看到,最终通过 Readability 清洗之后的数据只有文章内容,如评论、广告、菜单等都被清洗掉了。

文本分片

当我们拿到清洗好的数据之后,还需要进一步加工,即文本分片。为什么要进行文本分片呢?

首先,一篇文章中并不是所有内容都是有效的。比如一篇技术文章,可能有介绍、安装、使用、案例等部分。当我们询问有哪些案例时,并不需要基本介绍、安装、使用的步骤,只需要返回案例相关的内容即可,其他部分就变得有些冗余。其次,因为我们并不是只给 AI 一篇文章作为参考,所以当十篇甚至更多的文章给 AI 时,会导致 token 倍增,不仅会让 AI 理解变慢,精确度也会降低。同时,AI 处理内容的长处也是有限的,如果使用的是付费的 API,token 数也会浪费费用。

当然,自己实现的分片算法很难达到合适的效果,尤其是无法按照语义分片,这会导致分片出来的内容上下文不连贯。因此,我们这里使用 LangChain 的 RecursiveCharacterTextSplitter 方法。RecursiveCharacterTextSplitter 是 LangChain 中的一种递归式文本分割工具,专为处理长文本设计。它通过优先保留完整语义单元(如段落、句子)的方式,将文本切割成指定大小的片段,避免生硬的截断。

与普通分割器的区别在于,普通分割器通常会按照长度一刀切,比如每 500 个字符,这可能会导致切断句子和段落。而递归分割器优先按照语义单元(段落-句子-词语)分割,更接近人类的阅读习惯。

具体实现如下所示,在获取到网页后首先进行文本分片,然后再存入 Chroma。

js 复制代码
/**
 * 使用 LangChain 进行文本分片并存储到 ChromaDB
 * @param {Array<Object>} webPages - 网页数据数组,每个对象包含 link、title 和 content 属性
 * @param {string} collectionName - 集合名称
 */
export async function splitAndStoreInChroma(webPages, collectionName = 'web_pages') {
  const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 1500, // 每个分片的最大长度
    chunkOverlap: 250, // 相邻分片的重叠长度
    separators: ['。', '!', '?', '\n', ' ', ''] // 自定义分隔符
  });

  const allSlices = [];
  for (const page of webPages) {
    const { link, title, content } = page;
    const slices = await textSplitter.splitText(content);
    slices.forEach((slice, index) => {
      allSlices.push({
        link: `${link}-slice-${index}`,
        title: `${title}-slice-${index}`,
        content: slice
      });
    });
  }

  await storeInChroma(allSlices, collectionName);
}

调整主逻辑

我们通过 Google 搜索 API 获取到查询列表后,在抓取网页内容并进行清洗后,将内容通过 LangChain 的 RecursiveCharacterTextSplitter 方法切割分片后存入 Chroma,再通过 Chroma 查出语义相关的内容合并之后一起提交给 AI。

js 复制代码
async function main() {
  const question = process.argv.slice(2).join(' ').trim();

  if (!question) {
    logger.error('错误: 请输入要搜索的问题');
    process.exit(1);
  }

  console.log(`问题: ${question}`);

  // 生成搜索关键词
  const questionPrompt = `请严格基于以下用户提问的核心语义,提炼出3-5个精准的Google搜索关键词。
  
要求:
1. 关键词必须完全来自原句内容,禁止添加任何外部信息
2. 保持原始语义的完整性,核心术语不得拆分或重组
3. 用英文半角加号连接(例:量子计算+应用场景+技术难点)
4. 输出仅返回关键词组合,不加任何说明
5. 输出的关键词使用同一语言,不要中英混杂

输入内容:${question}

(示例:
用户输入:如何解决量子计算机的散热问题
正确输出:量子计算机+散热问题+解决方案) `;

  try {
    const searchPrompt = await generateResponse(questionPrompt);
    const search = searchPrompt.trim().split('\n').pop(); // 获取最后一行内容
    console.log(`搜索关键词: ${search}`);

    // 进行 Google 搜索
    const searchResult = await searchGoogle(search);
    if (!searchResult?.items || searchResult.items.length === 0) {
      logger.error('未找到相关搜索结果');
      return;
    }
    const results = await getAllPageContent(searchResult.items);

    // 存储数据到 ChromaDB
    await splitAndStoreInChroma(results);

    // 从 ChromaDB 查询相关数据
    const data = await queryCollectionByText([search.replaceAll('+', ' ')]);

    if (!data?.documents?.[0] || data.documents[0].length === 0) {
      logger.error('未从 ChromaDB 找到相关数据');
      return;
    }
    const documents = data.documents?.[0]
    // 生成回答
    const answerPrompt = `基于以下可信来源回答问题:
${documents.join('\n')}

请遵循:
1. 使用中文回答
2. 标注引用来源
3. 当信息冲突时,优先采用多个来源共同支持的信息
4. 如果信息不足请明确说明

问题:${question}
答案:`;
    const response = await generateResponse(answerPrompt);

    console.log(`回答:\n${response.trim()}`);
  } catch (error) {
    logger.error('发生错误:', error);
  }
}

执行效果

目测回复准确率比之前有大幅提升,使用本地模型:deepseek-r1:14b

结语

至此,我们已经完成了对 ollama-web-search 项目的新一轮优化和改造。通过引入 Chroma 向量数据库和 LangChain 的文本分片技术,我们显著提升了查询的精度和 AI 回答的准确性。这一改进不仅解决了之前直接截取网页内容导致的 token 浪费问题,还通过语义化的分片和检索,让 AI 能够更精准地理解和回答用户的问题。

:这个功能其实在上一篇发出去没多久就实现了,只不过一直没时间写这篇。现在终于完成了,也算是了却了一桩心事。希望能够给大家带来帮助!

相关链接

相关推荐
艾思科蓝 AiScholar几秒前
【 IEEE出版 | 快速稳定EI检索 | 往届已EI检索】2025年储能及能源转换国际学术会议(ESEC 2025)
人工智能·计算机网络·自然语言处理·数据挖掘·自动化·云计算·能源
Fulima_cloud几秒前
智慧锂电:开启能源新时代的钥匙
大数据·人工智能·物联网
GUOYUGRA几秒前
高纯氢能源在线监测分析系统组成和作用
人工智能·算法·机器学习
hzw051017 分钟前
使用pnpm管理前端项目依赖
前端
AI小智18 分钟前
MCP:昙花一现还是未来标准?LangChain 创始人激辩实录
后端
bobz96520 分钟前
strongswan IKEv1 proposal 使用
后端
风清扬雨32 分钟前
Vue3中v-model的超详细教程
前端·javascript·vue.js
高志小鹏鹏33 分钟前
掘金是不懂技术吗,为什么一直用轮询调接口?
前端·websocket·rocketmq
八了个戒36 分钟前
「JavaScript深入」一文说明白JS的执行上下文与作用域
前端·javascript
高志小鹏鹏38 分钟前
Tailwind CSS都更新到4.0了,你还在抵触吗?
前端·css·postcss