从零搭建本地向量检索:ChromaDB与LangChain实战指南
1. 为什么我要从零搭一套本地向量检索1.1 一个真实的需求场景去年年底我接手了一个内部知识库项目需求很朴素把团队积累的几百份技术文档、会议纪要、复盘报告做成一个能“问一句、答一句”的检索工具。一开始我想得很简单直接上大模型不就行了结果一测就傻眼——模型根本不知道我们内部的业务术语问“上次那个缓存击穿的事故怎么处理的”它给我编了一段听起来很合理但完全不存在的过程。这就是典型的大模型幻觉问题。模型的知识截止在训练数据那一天它没见过你的私有文档硬问就是瞎编。解决办法有两条路一是把文档塞进提示词里但上下文窗口有限几百份文档根本塞不下二是向量检索——先把文档切成小块转成向量存起来用户提问时先检索出最相关的几块再连同问题一起喂给模型。第二条路才是正解也是现在 RAG检索增强生成的标准做法。而要做向量检索绕不开两个东西一个存向量的数据库一个把文本转成向量的框架。ChromaDB和LangChain就是这套组合里最经典的搭档。ChromaDB 轻量、本地就能跑、不用额外部署服务LangChain 把文档加载、切分、嵌入、检索这一整套流程封装成了标准接口几行代码就能串起来。这篇文章我就把从零搭建的完整过程拆开讲包括我踩过的坑和调参心得适合刚接触向量检索、想快速跑通一个 demo 的朋友。1.2 这套组合到底解决了什么问题先把概念理清楚不然后面容易晕。向量检索的本质是“语义相似度匹配”。传统关键词搜索是字面匹配你搜“缓存击穿”文档里写的是“缓存穿透”字不一样就搜不到。向量检索是把每段文本通过嵌入模型转成一串数字比如 768 维或 1536 维的浮点数数组语义相近的文本它们的向量在空间里的距离就近。这样即使用词不同只要意思接近也能被检索出来。ChromaDB负责存这些向量并提供相似度查询能力。它支持内存模式进程退出就没了和持久化模式存到本地磁盘对个人项目和小团队来说完全够用不需要像 Milvus、Weaviate 那样单独部署集群。LangChain在这里扮演“胶水”的角色。它提供了DocumentLoader加载各种格式的文档、TextSplitter切分文本、Embeddings调用嵌入模型、VectorStore对接向量库这些抽象把整个流程标准化了。你换一个嵌入模型或者换一个向量库上层代码基本不用大改。三者关系可以这样理解LangChain 是流水线ChromaDB 是仓库嵌入模型是翻译官——把人类语言翻译成机器能算距离的数字。2. 环境准备与核心组件选型2.1 依赖安装与版本坑先把环境搭起来。我用的 Python 3.10这个版本兼容性最好3.11、3.12 有些包还没跟上。pip install chromadb langchain langchain-community langchain-openai这里有个版本坑必须提醒LangChain 在 0.1 版本之后做了大规模重构很多老教程里的from langchain.vectorstores import Chroma已经不能用了现在要写成from langchain_community.vectorstores import Chroma。如果你照着两三年前的博客抄代码报ModuleNotFoundError八成就是这个原因。另外langchain-openai是单独拆出来的包专门放 OpenAI 相关的集成。如果你用别的嵌入模型比如本地跑的 sentence-transformers那要装langchain-huggingface。我建议装完之后锁定版本避免以后pip install升级把环境搞崩pip freeze requirements.txt2.2 嵌入模型怎么选嵌入模型是整套系统的核心它决定了“语义理解”的质量。选型主要看三个维度效果、成本、速度。模型维度特点适用场景text-embedding-3-small1536效果好、便宜、需联网通用场景首选text-embedding-3-large3072效果最好、成本高对精度要求极高bge-large-zh1024中文优化、可本地跑中文文档、数据敏感all-MiniLM-L6-v2384轻量、快、英文为主快速原型、英文场景我实测下来如果是中文技术文档bge-large-zh的效果明显好于通用模型而且本地跑不用担心数据外泄。但它的缺点是首次加载要下载几个 G 的模型文件机器内存最好 16G 以上。如果你只是想快速跑通流程用 OpenAI 的text-embedding-3-small最省事一行 API key 搞定。但要注意成本嵌入是按 token 计费的几百份文档切完可能几十万 token虽然单价低但心里要有数。提示嵌入模型一旦选定后续所有文档和查询都必须用同一个模型。中途换模型意味着所有已存的向量全部作废必须重新嵌入。这个坑我在项目中期踩过一次血的教训。2.3 ChromaDB 的两种运行模式ChromaDB 有两种用法很多人一开始分不清。内存模式chromadb.Client()数据存在内存里程序一退出就没了。适合做实验、跑 demo每次重新嵌入。持久化模式chromadb.PersistentClient(path./chroma_db)数据存到本地磁盘下次启动直接加载。适合真实项目。我建议一开始就用持久化模式虽然第一次嵌入慢一点但后续调试不用反复重跑嵌入省时间。而且持久化模式下你可以直接用 ChromaDB 的客户端查看已存的数据排查问题方便很多。3. 完整实操流程拆解3.1 文档加载别小看这一步LangChain 的文档加载器非常丰富TextLoader、PyPDFLoader、UnstructuredMarkdownLoader、DirectoryLoader等等。我处理的是 Markdown 和 PDF 混合的文档用DirectoryLoader批量加载最省事。from langchain_community.document_loaders import DirectoryLoader, TextLoader loader DirectoryLoader( ./docs, glob**/*.md, loader_clsTextLoader, loader_kwargs{encoding: utf-8} ) documents loader.load() print(f加载了 {len(documents)} 个文档)这里有个编码坑Windows 上默认编码可能是 GBK读中文文档会乱码。一定要显式指定encodingutf-8。我第一次跑的时候没加结果嵌入出来的向量全是乱的检索结果驴唇不对马嘴排查了半天才发现是编码问题。加载完之后每个Document对象有两个属性page_content文本内容和metadata元数据比如来源文件、页码。metadata 非常重要后面检索出来要显示来源靠的就是它。加载时尽量保留原始路径信息。3.2 文本切分切得好不好直接决定检索质量这是整个流程里最容易被忽视、但影响最大的一步。文档不能整篇嵌入因为太长了嵌入模型有 token 上限而且一整篇的向量会“语义稀释”——什么都沾一点什么都不精。所以要切分。LangChain 提供了多种切分器最常用的是RecursiveCharacterTextSplitter它按段落、句子、字符的优先级递归切分尽量保持语义完整。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , ] ) chunks splitter.split_documents(documents) print(f切分成 {len(chunks)} 个块)参数怎么定我分享下我的调参逻辑chunk_size中文一般 300-500 字比较合适。太小了语义不完整太大了检索精度下降。我试过 200 和 1000200 的时候检索出来的块经常缺上下文1000 的时候一个块里混了好几个主题都不理想。500 是折中点。chunk_overlap块与块之间的重叠部分防止一个完整句子被切断后语义丢失。一般设成 chunk_size 的 10%-20%。我设 50也就是 10%。separators这个很关键。默认的分隔符是英文的[\n\n, \n, , ]对中文不友好。我加上了中文标点。这样切分时会优先在句子边界断开而不是硬切。实测下来加了中文分隔符之后检索出来的块读起来通顺多了。注意切分不是一劳永逸的。不同类型的文档要用不同的策略。技术文档按标题层级切对话记录按轮次切代码按函数切。通用切分器只是兜底方案。3.3 嵌入与入库把文本变成可检索的向量这一步把切分好的块转成向量存进 ChromaDB。from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-large-zh-v1.5, model_kwargs{device: cpu}, encode_kwargs{normalize_embeddings: True} ) vectorstore Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db, collection_nametech_docs )几个关键点normalize_embeddingsTrue把向量归一化到单位长度。这样计算相似度时用余弦距离和点积结果一致而且数值更稳定。bge 系列模型官方推荐开启这个选项。device有 GPU 就填cuda速度快十几倍。没有就cpu几百个块也就几分钟的事。collection_name相当于数据库里的“表名”。同一个持久化目录下可以有多个 collection互不干扰。我习惯按文档类型分 collection比如tech_docs、meeting_notes检索时可以指定范围。persist_directory持久化目录。注意新版 ChromaDB 会自动持久化不需要再手动调persist()了。老教程里的vectorstore.persist()现在调了会报错或者无效。3.4 检索从问题到答案的关键一跳入库之后就可以检索了。最基础的用法query 缓存击穿怎么处理 results vectorstore.similarity_search(query, k3) for doc in results: print(doc.page_content) print(---)k3表示返回最相似的 3 个块。k 设多少我的经验是 3-5 比较合适。太少了可能漏掉关键信息太多了会引入噪声而且喂给大模型的上下文变长成本和延迟都上升。相似度阈值也很重要。默认的similarity_search不设阈值哪怕文档里完全没有相关内容它也会硬返回 k 个“最相似”的。这会导致答非所问。更好的做法是用similarity_search_with_score拿到相似度分数后自己过滤results vectorstore.similarity_search_with_score(query, k5) for doc, score in results: if score 0.5: # 距离越小越相似 print(doc.page_content, score)注意 ChromaDB 返回的是距离不是相似度所以是越小越好。具体阈值多少合适要拿你的数据实测。我一般从 0.5 开始试根据召回结果调整。3.5 接上大模型完整的 RAG 链路检索出相关块之后把它们拼进提示词交给大模型生成答案from langchain_openai import ChatOpenAI from langchain.chains import RetrievalQA llm ChatOpenAI(modelgpt-4o-mini, temperature0) qa_chain RetrievalQA.from_chain_type( llmllm, retrievervectorstore.as_retriever(search_kwargs{k: 3}), return_source_documentsTrue ) result qa_chain.invoke({query: 缓存击穿怎么处理}) print(result[result]) print(来源, [d.metadata[source] for d in result[source_documents]])temperature0是为了让输出稳定减少随机性。return_source_documentsTrue让答案能附带来源方便溯源——这在知识库场景里是刚需用户看到答案还得知道是从哪份文档来的。4. 常见问题与排查实录4.1 检索结果不相关怎么办这是最高频的问题。排查顺序我总结成一张表现象可能原因排查方法完全搜不到相关内容嵌入模型选错/编码问题打印几个块的向量看是否正常搜到的块语义不完整chunk_size 太小调大到 500-800 试试搜到的块混了多个主题chunk_size 太大调小或换更细的分隔符相似度分数普遍偏高未归一化/模型不匹配开启 normalize确认查询和入库用同一模型中文检索效果差用了英文模型换 bge-large-zh 等中文模型我遇到最多的是编码问题和模型不匹配。前者表现为向量数值异常后者表现为检索结果随机。排查时先打印几个块的page_content看文本是否正常再打印向量看数值范围是否合理归一化后应该在 -1 到 1 之间。4.2 嵌入速度太慢怎么优化几百个块用 CPU 跑 bge-large 可能要十几分钟。优化手段有几个批量嵌入LangChain 的from_documents内部是批量的但你可以手动控制 batch_size。ChromaDB 的add_documents支持批量一次传几百个块比一个个传快很多。换小模型如果对精度要求不高bge-small-zh只有 large 的四分之一大小速度快好几倍效果差距在可接受范围内。用 GPU有显卡的话devicecuda速度提升非常明显。我实测 3090 上跑 bge-large几百个块几十秒就完事。缓存嵌入结果LangChain 支持CacheBackedEmbeddings把嵌入结果缓存到本地重复嵌入同样的文本时直接读缓存。调试阶段特别有用改检索逻辑不用重跑嵌入。4.3 数据更新了怎么办文档会更新向量库也要跟着更新。ChromaDB 支持增删改# 新增 vectorstore.add_documents(new_chunks) # 删除按 metadata 过滤 vectorstore.delete(where{source: old_doc.md})但要注意删除是按 metadata 匹配的所以入库时 metadata 一定要规范。我习惯用文件路径作为 source更新时先按路径删掉旧块再插入新块。如果文档变动频繁建议做一个增量更新脚本对比文件修改时间只重新处理变动的文件。全量重建虽然简单但文档多了之后每次几分钟也受不了。4.4 几个我踩过的坑坑一collection 重名。同一个持久化目录下如果两次运行用了同一个 collection_name第二次的数据会追加进去而不是覆盖。结果就是重复数据越来越多检索结果里全是重复块。解决方法是每次重建时先delete_collection或者用带时间戳的 collection 名。坑二metadata 丢失。用split_documents切分时metadata 会自动继承但如果你手动构造Document对象忘了传 metadata检索出来就没法溯源。我建议切分后抽查几个块的 metadata确认 source 字段在。坑三查询和入库模型不一致。这个最隐蔽。入库用 bge查询用 OpenAI向量空间都不一样算出来的距离毫无意义。表现是检索结果完全随机。一定要确保两边用同一个嵌入模型实例。坑四中文标点分隔符顺序。separators列表是有顺序的从前往后尝试。如果你把空字符串放在前面它会直接按字符切前面的分隔符就失效了。一定要把粗粒度的分隔符放前面。5. 进阶优化与扩展方向5.1 混合检索向量 关键词纯向量检索有个短板对专有名词、代码符号、数字不敏感。比如搜“ERR_5003”向量模型可能把它和“ERR_5004”当成差不多的东西但实际完全不同。解决办法是混合检索向量检索和 BM25 关键词检索各跑一遍把结果融合。LangChain 提供了EnsembleRetrieverfrom langchain.retrievers import EnsembleRetriever, BM25Retriever bm25 BM25Retriever.from_documents(chunks) bm25.k 3 vector_retriever vectorstore.as_retriever(search_kwargs{k: 3}) ensemble EnsembleRetriever( retrievers[bm25, vector_retriever], weights[0.4, 0.6] )权重怎么分看你的场景。技术文档里专有名词多BM25 权重要高一些自然语言问答为主向量权重要高。我一般从 0.5:0.5 开始调。5.2 重排序让最相关的排到最前检索出来的 k 个块顺序不一定最优。可以加一个重排序模型reranker对这 k 个块重新打分排序。bge-reranker 系列效果不错from langchain.retrievers import ContextualCompressionRetriever from langchain_community.document_compressors import CrossEncoderReranker from langchain_community.cross_encoders import HuggingFaceCrossEncoder model HuggingFaceCrossEncoder(model_nameBAAI/bge-reranker-base) compressor CrossEncoderReranker(modelmodel, top_n3) retriever ContextualCompressionRetriever( base_compressorcompressor, base_retrievervectorstore.as_retriever(search_kwargs{k: 10}) )思路是先用向量检索召回 10 个候选再用 reranker 精排出前 3 个。这样既保证了召回率又提升了精度。代价是多一次模型推理延迟增加但对质量要求高的场景值得。5.3 后续可以怎么扩展跑通基础版之后这套东西还能往几个方向长多模态ChromaDB 支持存图片向量配合 CLIP 模型可以做图文混合检索。Agent 集成把检索器封装成 LangChain 的 Tool让 Agent 自己决定什么时候检索、检索什么。这就是现在很火的 Agentic RAG 思路。评估体系用 RAGAS 之类的框架量化检索质量看召回率、精确率、答案相关性这些指标而不是靠感觉调参。前端界面用 Streamlit 或 Gradio 套个界面非技术同事也能用。我给我们团队搭了个 Streamlit 版本十几行代码效果比想象中好。我个人在实际操作中的体会是向量检索这套东西入门容易、调优难。跑通 demo 可能半小时但要让检索质量真正可用得反复试切分策略、嵌入模型、检索参数。别指望一次调好把它当成一个持续迭代的过程。另外metadata 的设计从一开始就要想清楚后面做过滤、溯源、增量更新都靠它返工成本很高。最后分享一个小技巧调试检索时把 query 和返回的块、分数都打印出来肉眼看看相关性比任何指标都直观。