ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零搭建ChromaDB+LangChain向量检索:RAG知识库实战指南

从零搭建ChromaDB+LangChain向量检索:RAG知识库实战指南 1. 为什么我要从零搭一套 ChromaDB LangChain 的向量检索做 RAG 应用的人绕不开一个核心问题怎么把一堆非结构化的文本变成机器能理解并快速找到的东西。我最早做知识库问答的时候用的是最朴素的关键词匹配结果用户问怎么退款文档里写的是申请退货流程直接匹配不上召回率惨不忍睹。后来换成向量检索效果立竿见影但新的问题又来了——向量库选哪个、怎么和 LangChain 串起来、检索出来的结果怎么控制质量这些坑我几乎踩了个遍。这套 ChromaDB LangChain 的组合是我目前在小规模到中等规模知识库场景下最推荐的入门方案。ChromaDB 是一个轻量级的向量数据库可以理解成一个专门存语义向量的仓库它最大的好处是能直接跑在本地、零配置启动、Python 原生接口不需要你额外部署一套服务。LangChain 则是把大模型应用的各种组件文档加载、切分、嵌入、检索、生成串成流水线的框架它的VectorStore抽象层让你换向量库的时候几乎不用改业务代码。这篇文章适合谁看如果你已经会写 Python想给自己的应用加一个语义搜索或者知识库问答的能力但被各种向量库、嵌入模型、检索参数搞得头晕那这篇就是给你写的。我会从整体设计思路讲起把每个环节为什么这么选、参数怎么定、代码怎么写、出问题怎么排查全部掰开揉碎讲清楚。全程用我实际跑通的代码你可以直接抄作业。需要提前说明的是向量检索这个领域变化很快LangChain 的 API 版本迭代也频繁我下面用的接口以langchain0.1.x 之后的稳定版本为准如果你用的是更老的版本部分导入路径可能不一样这个我会在踩坑部分专门提醒。2. 整体方案设计与技术选型思路2.1 为什么是 ChromaDB 而不是 FAISS、Milvus选向量库这件事本质上是在部署成本和性能上限之间做权衡。我列一下我实际对比过的几个方案你就明白为什么入门阶段我推 ChromaDB。向量库部署方式持久化元数据过滤适用规模上手难度ChromaDB进程内/本地服务支持支持万级到十万级极低FAISS纯库无服务需手动弱百万级以上中等Milvus独立集群支持强亿级高Qdrant独立服务支持强千万级中等FAISS 是 Facebook 出的性能确实猛但它本质上是个索引库不负责存储和元数据管理你要自己处理 ID 映射、持久化、删除更新写起来很啰嗦。Milvus 和 Qdrant 功能全但要单独部署服务对只是想快速验证想法的人来说太重了。ChromaDB 的定位刚好卡在中间它自带持久化PersistentClient支持元数据过滤这个做 RAG 太重要了后面会讲API 简单到几行代码就能跑起来而且 LangChain 对它的集成非常成熟。我实测下来单机十万条以内的向量ChromaDB 的检索延迟完全够用没必要一上来就上分布式。注意ChromaDB 早期版本有个坑chromadb.Client()是纯内存的进程一退数据就没了。要持久化必须用chromadb.PersistentClient(path...)或者老版本的chromadb.Client(Settings(persist_directory...))。这个我后面会专门讲。2.2 LangChain 在这套方案里到底扮演什么角色很多人第一次接触 LangChain 会觉得它封装太厚、黑盒太多我一开始也这么想。但用久了会发现它真正的价值在于标准化了数据流转的接口。你想想一个 RAG 流程要经过加载文档 → 切分 → 向量化 → 存库 → 检索 → 重排 → 喂给大模型。如果每个环节都自己写光是不同文档格式的解析就能耗掉你半天。LangChain 把这些环节抽象成了几个核心概念DocumentLoader负责把各种来源PDF、网页、Markdown、数据库变成统一的Document对象TextSplitter负责把长文档切成适合嵌入的小块Embeddings负责把文本转成向量VectorStore负责向量的存储和检索Retriever负责把检索逻辑包装成统一接口这套抽象的好处是你今天用 ChromaDB明天想换 Qdrant只要改VectorStore那一行其他代码基本不动。这就是我坚持用 LangChain 而不是裸写 ChromaDB 客户端的原因。2.3 嵌入模型的选择本地还是 API嵌入模型决定了向量质量的上限这一步选错后面检索再优化都是白搭。我实际用过的几类OpenAI text-embedding-3-small效果好1536 维但要联网调用、按量付费数据敏感场景不合适BGE 系列如 bge-small-zh-v1.5中文效果好可以本地跑512 维适合中文知识库m3e-base中文社区常用768 维本地部署Sentence-Transformers 通用模型多语言场景可用我的建议是中文知识库优先用 BGE 或 m3e 这类中文优化的本地模型一是数据不出本地二是省调用成本三是延迟可控。英文或者多语言场景如果预算允许API 嵌入模型省心且效果好。这里有个关键点很多人忽略嵌入模型一旦选定整个知识库的向量维度就固定了。你后面换模型必须把全部文档重新嵌入一遍因为不同模型的向量空间完全不兼容。所以选模型的时候要一次性想清楚别中途换。3. 核心环节拆解与实操要点3.1 环境准备与依赖安装先把环境搭起来。我习惯用虚拟环境隔离避免依赖冲突。python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install chromadb langchain langchain-community langchain-chroma pip install sentence-transformers # 本地嵌入模型用 pip install pypdf # 如果要处理 PDF这里要特别注意版本兼容问题。LangChain 从 0.1 版本开始做了大量拆分Chroma这个向量库的封装被移到了独立的langchain-chroma包里。如果你装的是老版本导入路径是from langchain.vectorstores import Chroma新版本则是from langchain_chroma import Chroma。我下面统一用新版本的写法老版本你自己把导入改一下就行。实操心得装完依赖后先跑一句import chromadb; print(chromadb.__version__)确认版本。ChromaDB 0.4.x 和 0.5.x 在客户端 API 上有差异尤其是持久化那块版本不对会报一些莫名其妙的错。3.2 文档加载与切分切分策略决定检索质量文档切分是 RAG 里最容易被低估的环节。我见过太多人检索效果差最后发现是切分切得稀碎一个完整的语义单元被切成两半检索出来自然是残缺的。LangChain 提供了多种切分器最常用的是RecursiveCharacterTextSplitter。它的逻辑是优先按段落切段落太长就按句子切句子还长就按字符切尽量保证语义完整。from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size500, # 每块目标字符数 chunk_overlap50, # 相邻块重叠字符数 length_functionlen, separators[\n\n, \n, 。, , , , , ] ) chunks text_splitter.split_documents(documents)参数怎么定我分享我的经验值chunk_size中文场景我一般用 300 到 500。太小了语义不完整太大了嵌入向量会稀释重点检索精度下降。英文可以用 500 到 1000。chunk_overlap一般取 chunk_size 的 10% 到 20%。重叠的作用是防止关键信息刚好卡在切分边界上被割裂。比如一句话前半段在块 A 结尾后半段在块 B 开头有重叠就能保证至少有一个块包含完整句子。separators中文一定要把中文标点加进去默认的切分器只认英文标点中文长句会被硬切。注意chunk_overlap不能大于等于chunk_size否则会陷入死循环或者切出无限多的块。这个坑我踩过程序直接卡死。3.3 嵌入与入库把文本变成可检索的向量嵌入这一步我用本地 BGE 模型举例因为中文场景最实用。from langchain_community.embeddings import HuggingFaceBgeEmbeddings model_name BAAI/bge-small-zh-v1.5 encode_kwargs {normalize_embeddings: True} # 归一化配合余弦相似度 embeddings HuggingFaceBgeEmbeddings( model_namemodel_name, model_kwargs{device: cpu}, # 有 GPU 就写 cuda encode_kwargsencode_kwargs )这里normalize_embeddingsTrue是个关键设置。归一化之后向量的模长都是 1这时候余弦相似度和点积等价检索时计算更快而且相似度分数有明确的范围-1 到 1方便你设阈值。入库用 ChromaDB 的持久化客户端from langchain_chroma import Chroma vector_store Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db, # 持久化目录 collection_namemy_knowledge_base )collection_name相当于数据库里的表名你可以在一个 ChromaDB 实例里建多个 collection 隔离不同知识库。这个设计很实用比如你可以给产品文档和客服话术各建一个 collection检索时互不干扰。3.4 检索相似度检索与 MMR 的取舍最基础的检索是相似度检索直接返回和 query 向量最接近的 top-k 个块results vector_store.similarity_search( query怎么申请退款, k4 )但相似度检索有个问题如果文档里有大量重复或高度相似的内容返回的 top-k 可能全是同一个意思的块信息冗余严重。这时候就要用MMR最大边际相关性results vector_store.max_marginal_relevance_search( query怎么申请退款, k4, # 最终返回 4 个 fetch_k20, # 先从 20 个候选里挑 lambda_mult0.5 # 0 到 1越大越偏向相关性越小越偏向多样性 )MMR 的逻辑是先取一批候选fetch_k然后每次挑一个既和 query 相关、又和已选结果不重复的块。lambda_mult控制这个平衡我一般用 0.5 到 0.7。什么时候用哪个我的经验是问答类场景用相似度检索因为你要的是最相关的答案摘要、多角度分析类场景用 MMR因为你要覆盖不同方面。4. 完整实操流程与关键代码实现4.1 从零跑通一个最小可用示例我把完整流程串一遍你可以直接复制运行。假设你有一个docs目录里面放几个 txt 或 md 文件。import os from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.embeddings import HuggingFaceBgeEmbeddings from langchain_chroma import Chroma # 1. 加载文档 loader DirectoryLoader( ./docs, glob**/*.md, loader_clsTextLoader, loader_kwargs{encoding: utf-8} ) documents loader.load() print(f加载了 {len(documents)} 个文档) # 2. 切分 splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , ] ) chunks splitter.split_documents(documents) print(f切分成 {len(chunks)} 个块) # 3. 嵌入模型 embeddings HuggingFaceBgeEmbeddings( model_nameBAAI/bge-small-zh-v1.5, model_kwargs{device: cpu}, encode_kwargs{normalize_embeddings: True} ) # 4. 入库 vector_store Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db, collection_namedemo ) # 5. 检索 query 如何配置持久化 results vector_store.similarity_search_with_score(query, k3) for doc, score in results: print(f相似度: {score:.4f}) print(f内容: {doc.page_content[:100]}...) print(- * 40)注意第 5 步我用了similarity_search_with_score它会同时返回相似度分数。这个分数非常重要你可以用它做阈值过滤——比如分数低于某个值的直接丢弃避免把不相关的内容喂给大模型产生幻觉。关于分数方向要提醒一句ChromaDB 默认用的是 L2 距离欧氏距离分数越小越相似。如果你用的是余弦相似度那就是越大越相似。这个方向搞反了过滤逻辑就全错了。我建议统一用归一化嵌入 余弦距离语义更直观。4.2 元数据过滤让检索更精准实际项目里光靠语义相似度往往不够。比如你有一个混合了多个产品线的知识库用户问X 产品的退款政策语义检索可能把 Y 产品的退款政策也召回来因为文本太像了。这时候元数据过滤就派上用场。入库时给每个块打上元数据标签for chunk in chunks: # 假设从文件路径推断产品线 chunk.metadata[product] product_x chunk.metadata[doc_type] policy vector_store Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db, collection_namedemo )检索时加过滤条件results vector_store.similarity_search( query退款政策, k4, filter{product: product_x} # 只在这个产品线里检索 )ChromaDB 的 filter 支持$eq、$ne、$in、$gt等操作符组合起来很灵活。这个功能在真实项目里是刚需我强烈建议你入库时就把元数据设计好后面改起来要重新嵌入成本很高。4.3 接入大模型做 RAG 问答检索只是第一步最终要把它接进大模型做问答。LangChain 提供了RetrievalQA链但新版本更推荐用 LCELLangChain 表达式语言自己拼from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser retriever vector_store.as_retriever( search_typesimilarity, search_kwargs{k: 4} ) prompt ChatPromptTemplate.from_template( 根据以下上下文回答问题如果上下文没有相关信息就说我不知道。 上下文 {context} 问题{question} ) def format_docs(docs): return \n\n.join(doc.page_content for doc in docs) # 假设你已经有一个 llm 实例 chain ( {context: retriever | format_docs, question: RunnablePassthrough()} | prompt | llm | StrOutputParser() ) answer chain.invoke(怎么申请退款) print(answer)这段代码的核心是retriever | format_docs它把检索到的文档列表拼成一段文本塞进 prompt 的{context}占位符。RunnablePassthrough()则把用户的原始问题透传给{question}。整个链是声明式的读起来很清晰。实操心得prompt 里一定要加如果上下文没有相关信息就说不知道这句。不加的话大模型会拿它自己的知识硬编答案产生幻觉。这是 RAG 防幻觉的第一道防线。5. 常见问题排查与避坑实录5.1 数据没持久化重启就丢这是新手最常踩的坑。症状是程序跑完检索正常但关掉进程重新跑检索结果为空。原因通常是用了chromadb.Client()而不是PersistentClient。在 LangChain 里如果你用Chroma.from_documents()时没传persist_directory它默认就是内存模式。排查方法检查你的persist_directory参数有没有传以及目录里有没有生成chroma.sqlite3文件。有文件说明持久化成功了。# 正确写法 vector_store Chroma( persist_directory./chroma_db, embedding_functionembeddings, collection_namedemo )另外加载已有库的时候不要再用from_documents那个是新建的。要用Chroma(...)构造函数直接加载否则会重复写入数据。5.2 检索结果不相关分数还很高这种情况一般是嵌入模型和语言不匹配。比如你用英文模型处理中文文本向量空间对不上检索自然乱套。排查步骤确认嵌入模型支持你的语言。中文用 BGE-zh、m3e英文用 all-MiniLM 系列。检查切分是否合理。块太大重点被稀释块太小语义不完整。打印几个检索结果的分数看看分布。如果所有分数都挤在一起说明向量区分度不够可能模型选错了。我整理了一个速查表症状可能原因解决方向检索结果完全不相关嵌入模型语言不匹配换中文/对应语言模型结果相关但排序乱未归一化嵌入开启 normalize_embeddings召回内容重复未用 MMR改用 MMR 检索关键信息检索不到切分边界割裂增大 chunk_overlap分数方向搞反距离度量理解错确认 L2 还是余弦5.3 内存占用过高ChromaDB 默认会把索引加载到内存。数据量大的时候比如几十万条内存会吃紧。我的处理方式控制单次入库的批量大小不要一次性from_documents塞几十万条分批add_documents。如果只是做检索服务考虑用 ChromaDB 的 HTTP 服务模式把存储和计算分离。定期清理不需要的 collectionvector_store.delete_collection()。5.4 更新文档后检索还是旧内容ChromaDB 不会自动检测文档变化。你改了源文件必须重新切分、重新嵌入、重新入库。而且如果直接往同一个 collection 里加旧数据还在会出现新旧混杂。正确做法是给每个块生成稳定的 ID比如用文件路径 块序号做哈希入库时用add_documents带 ID相同 ID 会覆盖。或者干脆删掉 collection 重建。# 删除重建 vector_store.delete_collection() vector_store Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db, collection_namedemo )注意delete_collection()是不可逆的生产环境慎用。更稳妥的做法是用 ID 覆盖更新。6. 性能调优与进阶方向6.1 检索质量调优的几个抓手跑通之后下一步就是调优。我按投入产出比排序给你几个抓手第一优化切分策略。这是性价比最高的。试试按语义切分SemanticChunker它用嵌入相似度判断句子边界比固定字符切分更贴合语义。代价是切分阶段要跑嵌入慢一些。第二加检索后重排Rerank。先用向量检索召回一批比如 20 个再用交叉编码器cross-encoder精排取 top-4。交叉编码器把 query 和文档拼在一起算相关性精度比向量检索高很多但慢所以只用在精排阶段。BGE 有配套的 reranker 模型中文效果不错。第三调 top-k 和阈值。k 太小召回不足太大引入噪声。我一般先用 4 到 6配合分数阈值过滤。阈值怎么定跑一批测试 query看正确结果的分数分布取一个能过滤掉大部分噪声的值。第四query 改写。用户的问题往往口语化、有指代直接检索效果差。可以用大模型先把 query 改写成更适合检索的形式或者生成多个 query 分别检索再合并Multi-Query。6.2 从单机到服务的演进路径ChromaDB 单机够用但如果你要做成对外服务有几个方向ChromaDB HTTP 模式chroma run --path ./chroma_db起一个服务客户端通过 HTTP 连接多进程共享。换 Qdrant/Milvus数据量上千万或者要高可用的时候迁移到专业向量库。因为 LangChain 的VectorStore接口统一迁移成本主要在数据导出导入。加缓存层高频 query 的检索结果缓存起来减少重复计算。6.3 关于 Agent 框架的一点个人看法最近很多人问 LangChain、Dify、CrewAI 这些框架怎么选。我的看法是如果你只是做 RAG 检索问答LangChain 的 LCEL 就够了不需要上 Agent 框架。Agent 框架解决的是多步骤、多工具、自主决策的问题比如让模型自己决定先查数据库还是先调 API。你的场景如果就是检索 生成硬套 Agent 反而是过度设计调试起来还麻烦。Dify 这类低代码平台适合快速搭原型、非技术同学用CrewAI 适合多智能体协作的复杂流程。选型的关键是看你的问题复杂度别为了用框架而用框架。7. 我踩过的那些坑和最后的经验说几个文档里不会写、但实际一定会遇到的细节。第一个坑是编码问题。中文文档加载时如果不指定encodingutf-8Windows 上默认用 GBK直接报 UnicodeDecodeError。这个错误信息还特别隐晦我第一次遇到排查了半天。第二个坑是模型下载。HuggingFace 的模型第一次用会联网下载如果网络环境不好会卡住。我的做法是提前用huggingface-cli download把模型拉到本地然后model_name直接写本地路径。这样离线也能跑速度还快。第三个坑是 ChromaDB 的 collection 命名。它有一套命名规则不能有特殊字符长度也有限制。我一开始用中文名直接报错。后来统一用英文加下划线省心。第四个坑是相似度分数的解读。前面提过ChromaDB 默认 L2 距离分数越小越相似。但如果你在创建 collection 时指定了hnsw:space为cosine那分数方向就反过来了。这个一定要在代码里确认清楚不然阈值过滤逻辑全反。最后分享一个我常用的调试技巧建一个小的测试集准备 20 到 30 个典型 query 和对应的期望文档每次调整参数后跑一遍看召回率变化。凭感觉调参很容易越调越乱有测试集才有方向。这个测试集不用很正式一个 CSV 文件两列query 和期望命中的文档 ID就够了。向量检索这东西理论看着简单实际效果全在细节里。切分、嵌入、检索、重排每个环节都有讲究但也不用一次全做到位。先把最小闭环跑通再针对具体问题逐个优化这个节奏最稳。
返回列表