“让 Chroma 支持中文”这个说法,其实有点误导——Chroma 本身是向量数据库,理论上它不挑语言,文本进来都会变成向量存进去,查询的时候再变成向量算相似度。真正的问题是:Chroma 默认带的那套 embedding 模型是英文模型,中文文本经过它转换之后,语义信息丢得七七八八,所以中文检索效果惨不忍睹。我见过太多人兴冲冲用 Chroma 搭本地知识库,结果中文一问一个不准,第一反应就是“这数据库不支持中文”,其实病根在 embedding,不在数据库。
这篇文章把我做中文知识库时踩过的坑、改过的配置、验证过有效的方法都写清楚:怎么换中文 embedding 模型、中文文档怎么切分、查询时需要注意什么,以及一条完整的 Ollama + LangChain + Chroma 本地知识库链路。适合准备搭中文 RAG 应用、本地知识库的开发者,不管新手老手,都能直接照着做。
1. 先搞清楚:Chroma 中文效果差,病根到底在哪
1.1 向量数据库的原理与“不支持中文”的真相
很多人第一次接触 Chroma,是照着英文教程搭了一个问答机器人,换成中文语料后检索结果完全没法看。于是得出结论:Chroma 不支持中文。这个结论是错的。Chroma 内部根本没有“分词器”,也没有“语言检测”这种东西,它的核心逻辑只有三步:
- 把文本通过 embedding 模型变成向量。
- 把向量和元数据一起存进 HNSW 索引。
- 查询时把你的问题也变成向量,在索引里做最近邻搜索。
换句话说,Chroma 本身是语言无关的。真正决定中文检索质量的地方,在第一步:文本变成向量的过程中,embedding 模型到底懂不懂中文。
Chroma 默认的 embedding 函数来自 ONNX 版的all-MiniLM-L6-v2,这是一个在英文语料上训练的小型模型。把中文句子丢进去,它并不是“不能处理”,而是处理得很粗糙:中文里的同义词、语序变化、口语表达,在这个模型看来和随机噪声没太大区别。打个比方,这就像让一个完全不懂中文的人给中文图书馆做索引,他能记住每个字的长相,但完全不知道这些字组合起来是什么意思。查询的时候只能靠“字面重合”去碰运气,语义检索自然无从谈起。
所以,让 Chroma 支持中文,本质上不是改 Chroma,而是换掉它默认的 embedding 模型,换成在中文语料上训练过的模型。这一步能做到位,后面切分和检索的很多问题都会迎刃而解。
1.2 三分钟定位:做一个 embedding 效果小实验
我建议你在动手改造之前,先花三分钟做一个验证实验,亲眼看看默认模型对中文有多“瞎”。这个实验不需要 LangChain,只要装一个sentence-transformers就够了。
from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity texts = [ "今天天气怎么样", "明天会下雨吗", "The weather is nice today", ] # 默认模型(Chroma 默认同源)和中文模型各跑一次 default_model = SentenceTransformer("all-MiniLM-L6-v2") zh_model = SentenceTransformer("BAAI/bge-small-zh-v1.5") vecs_default = default_model.encode(texts) vecs_zh = zh_model.encode(texts) print("默认模型相似度矩阵:") print(cosine_similarity(vecs_default)) print("\n中文模型相似度矩阵:") print(cosine_similarity(vecs_zh))在我自己的测试里,默认模型对“今天天气怎么样”和“明天会下雨吗”这两句中文的相似度可能在 0.6 左右,而对“The weather is nice today”这句英文反而能给到 0.7 以上。这已经很不合理了:明明都是天气话题,中文近义句的相似度居然不如跨语言的句子。换成bge-small-zh-v1.5之后,前两句中文的相似度能到 0.8 左右,和英文句子的相似度则明显下降。
这个实验能直观地告诉你两件事:第一,你的知识库检索不准,大概率是 embedding 的问题;第二,换成中文模型之后,效果提升是肉眼可见的。所谓“让 Chroma 支持中文”,核心就是这一步。
注意:不同版本模型的相似度数值会有浮动,但趋势是一致的。如果默认模型对中文句对和英文句对的相似度差异不大,说明这个模型基本没学到中文的语义结构,该换了。
2. 中文支持的关键改造:Embedding、切分、检索三件套
2.1 换 Embedding 模型:选型与接入方式
目前中文场景下常用的 embedding 模型大致有以下几类,我按“无脑可用”到“效果好但更重”排个序:
| 模型 | 体积 | 中文效果 | 特点与适用场景 |
|---|---|---|---|
BAAI/bge-small-zh-v1.5 | 约 100MB | 中上 | 体积小、CPU 可跑、效果稳定,个人知识库首选 |
shibing624/text2vec-base-chinese | 约 400MB | 中上 | 中文语义相似度任务表现好,查询时不需要特殊前缀 |
BAAI/bge-large-zh-v1.5 | 约 400MB | 高 | 效果更好,CPU 推理明显更慢,适合离线批处理 |
BAAI/bge-m3 | 约 2GB | 高 | 多语言、支持长文本,能处理 8192 token,重但全面 |
nomic-embed-text(Ollama) | 约 500MB | 中下 | 英文为主,Ollama 直接可用,中文不如专门中文模型 |
我的建议很简单:如果你用 LangChain 或者直接操作 Chroma,优先选bge-small-zh-v1.5,它是我目前用过“性价比”最高的中文 embedding 模型。如果电脑配置比较好、检索精度要求高,可以上bge-large-zh-v1.5或者bge-m3。如果整个链路都在 Ollama 里,那么bge-m3是目前 Ollama 官方模型库中中文支持最好的向量模型。
接入 LangChain 时,新版用法是走langchain-huggingface包:
from langchain_huggingface import HuggingFaceEmbeddings embedding_model = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", model_kwargs={"device": "cpu"}, encode_kwargs={"normalize_embeddings": True}, )如果你没有用 LangChain,也可以直接给 Chroma 传一个自定义 embedding function:
from chromadb.utils import embedding_functions ef = embedding_functions.SentenceTransformerEmbeddingFunction( model_name="BAAI/bge-small-zh-v1.5", device="cpu", normalize_embeddings=True, )这里有两个细节值得注意。第一,normalize_embeddings=True会把向量归一化成单位向量,让余弦相似度和内积等价,在很多检索任务里都能提升稳定性。第二,首次运行会自动从 HuggingFace 下载模型,如果网络环境不稳定,建议先手动跑一次下载,后面就不会中断了。
2.2 中文文档切分:让语义单元保持完整
Embedding 模型换好了,第二个大坑就是文档切分。LangChain 默认的RecursiveCharacterTextSplitter是按["\n\n", "\n", " ", ""]来切分的,这套规则对英文很友好,对中文却有点水土不服:中文没有空格分词,默认切分器经常会把一句完整的话从中间截断,导致一个 chunk 里装了一半语义,检索时自然找不对上下文。
我常用的做法是自定义 separators,把中文标点加进切分规则:
from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=300, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], )原理不复杂:切分器会优先按长分隔符切,如果某个 chunk 仍然超过chunk_size,就依次尝试下一个分隔符。加了中文标点之后,一个 chunk 能在句子边界附近停下来,而不是毫无预兆地把句子腰斩。
chunk_size的选择也很关键。我见过有人直接把整篇文档丢进 Chroma,结果检索到的几乎都是整个文档的向量,一问就答非所问。对中文知识库来说,chunk_size=300到500是一个比较稳妥的区间,既能保证每个片段有足够的语境,又不至于超过大部分 embedding 模型 512 token 的输入上限。chunk_overlap建议设成 30 到 60,让相邻片段有少量重叠,避免一个完整知识点刚好卡在切分缝里。
一个小技巧:切分完成后,可以先随机打印几个 chunk 检查一遍。如果每个 chunk 都能读通、语义完整,说明切分参数基本靠谱;如果经常出现“半个标题”“半句话”,就要继续调 separators。
2.3 查询侧也要对齐:指令前缀与距离度量
Embedding 模型选好、切分参数调好之后,还有一个很容易被忽略的细节:查询时怎么把问题也变成向量。
bge系列模型官方建议,在检索场景下需要给查询语句加上一个指令前缀,文档入库时则不加。具体到bge-small-zh-v1.5,前缀是:
query = "为这个句子生成表示以用于检索相关文章:" + raw_query这是微软在发布 bge 模型时定下的规则,原因是训练时查询侧的表示和文档侧的表示是分别优化的。如果忘了加这个前缀,检索效果会打一些折扣,但也不会完全崩。text2vec系列则没有这个要求,直接在原始查询上计算即可。
另一个容易被忽略的配置是向量距离度量。Chroma 底层用的 HNSW 索引,默认的距离度量是 L2(欧氏距离),但 embedding 模型产出的向量空间更适合用余弦相似度来度量。建议在创建 collection 的时候显式指定:
vectorstore = Chroma.from_documents( documents=split_docs, embedding=embedding_model, persist_directory="./chroma_db", collection_name="zh_kb", collection_metadata={"hnsw:space": "cosine"}, )注意一点:collection_metadata里的空间度量在 collection 创建时生效,之后不能修改。所以哪怕你只是建一个测试库,也建议一开始就把cosine写进去,不然后面想改只能删了重建。
3. 实录:Ollama + LangChain + Chroma 搭建中文本地知识库
3.1 环境准备与模型选择
接下来我们走一遍完整链路:本地文档 → 切分 → 中文 embedding → 存入 Chroma → 用户提问 → 检索 → Ollama 大模型生成回答。
首先确认环境。假设你已经安装了 Python 3.9 以上版本,然后安装依赖:
pip install langchain langchain-huggingface langchain-ollama langchain-chroma chromadb sentence-transformersOllama 方面,需要两个模型:一个生成模型,一个 embedding 模型。生成模型我用的是qwen2.5:7b,中文对话能力强,跑在本地完全够用。embedding 模型用bge-m3,中文效果好、支持超长文本,是 Ollama 里最省事的选项。
ollama pull qwen2.5:7b ollama pull bge-m3如果你不想折腾 Ollama 的 embedding,也可以直接用前面说的HuggingFaceEmbeddings。两条路都能走通,我这边的经验是:如果 Ollama 已经跑起来了,用bge-m3少一套模型加载逻辑,链路更干净;如果 Ollama 只用来跑大语言模型,embedding 单独用 HuggingFace 模型也完全没问题。
3.2 核心代码:从入库到问答的完整链路
下面是一段可以直接跑的完整代码,注释我写得比较细:
from langchain_huggingface import HuggingFaceEmbeddings from langchain_ollama import ChatOllama from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_community.document_loaders import TextLoader from langchain_chroma import Chroma # 1. 初始化中文 embedding 模型 embedding_model = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", model_kwargs={"device": "cpu"}, encode_kwargs={"normalize_embeddings": True}, ) # 2. 加载本地文档(注意编码) loader = TextLoader("knowledge_base.txt", encoding="utf-8") docs = loader.load() # 3. 按中文标点切分 splitter = RecursiveCharacterTextSplitter( chunk_size=300, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], ) split_docs = splitter.split_documents(docs) # 4. 写入 Chroma,持久化到本地目录 vectorstore = Chroma.from_documents( documents=split_docs, embedding=embedding_model, persist_directory="./chroma_db", collection_name="zh_kb", collection_metadata={"hnsw:space": "cosine"}, ) # 5. 构造检索器 retriever = vectorstore.as_retriever(search_kwargs={"k": 5}) # 6. 初始化 Ollama 大模型 llm = ChatOllama(model="qwen2.5:7b", temperature=0.1) # 7. 检索 + 拼接 prompt + 生成回答 def ask(question: str): docs = retriever.invoke(question) context = "\n\n".join([d.page_content for d in docs]) prompt = f"""你是一个知识库问答助手,请根据下面的参考资料回答用户问题。 如果参考资料中没有相关内容,请直接说明不知道,不要编造。 参考资料: {context} 用户问题:{question}""" resp = llm.invoke(prompt) return resp.content, docs if __name__ == "__main__": for q in ["退货流程是什么?", "支持哪些支付方式?", "怎么联系客服?"]: answer, refs = ask(q) print("问题:", q) print("回答:", answer) print("参考片段:", len(refs)) print("-" * 40)这里有一个容易踩的坑:如果你用 Ollama 的bge-m3作为 embedding,LangChain 侧要用OllamaEmbeddings,代码是这样的:
from langchain_ollama import OllamaEmbeddings embedding_model = OllamaEmbeddings( model="bge-m3", )其他部分不用改。什么时候用哪个,取决于你 Ollama 里到底拉了什么模型。不管选哪个,核心原则不变:入库和查询必须用同一个 embedding 模型,不能混用。
3.3 效果验证:检索精度到底提升在哪
跑通之后,我建议你做一个 A/B 对比,用同一份中文文档、同一个查询,分别用默认模型和中文模型检索,对比返回的片段。下面是我实际测试时的结果(文档是一份电商客服知识库):
| 查询 | 默认模型 top1 命中 | 中文模型 top1 命中 |
|---|---|---|
| “退货流程是什么” | 商品介绍段落 | 退货政策第一段 |
| “怎么申请发票” | 配送说明段落 | 发票申请段落 |
| “会员积分怎么用” | 会员等级介绍段落 | 积分使用说明段落 |
差距就是这么明显。原因也不复杂:默认模型把中文语义“混成一团”,检索时只能靠字符重合度蒙,所以经常返回一些看起来无关但字面相近的段落。换成中文模型后,语义信息保留充分,返回的段落才真正对得上问题。
如果你不打算接大语言模型,只做纯检索,用vectorstore.similarity_search(query, k=5)也能直接看到效果提升。后面接大模型,只是让系统从“搜到相关片段”进一步变成“组织成自然回答”。
4. 中文场景下的疑难杂症与进阶技巧
4.1 常见问题速查表
下面这些问题,都是我实际见过、或者自己在项目里踩过的:
| 问题 | 原因 | 解决方法 |
|---|---|---|
| 中文检索结果完全不相关 | 还在用 Chroma 默认英文 embedding | 换成bge-small-zh-v1.5或text2vec |
| 报错说向量维度不匹配 | collection 已经用其他 embedding 建过,向量维度不同 | 删掉旧 collection 或新开 collection,不要混用 |
| 加载旧的持久化目录后检索变差 | 没传同一个 embedding 模型 | 加载时传相同的embedding_function |
| 中文文档全是乱码 | 文件不是 UTF-8 编码 | 加载时指定encoding="utf-8",必要时先转码 |
| CPU 推理慢,批量入库要等很久 | 模型太大或 chunk 太多 | 换bge-small-zh,或者分批 encode 后再写入 |
| Ollama 返回 404 / model not found | Ollama 没拉模型或名字不一致 | 先ollama pull bge-m3,确认OllamaEmbeddings(model=...)名称一致 |
| 所有查询都返回同一个片段 | 切分粒度过大、chunk 内容过于笼统 | 缩小chunk_size,增加中文标点切分 |
| 加了 bge 指令前缀后效果反而差 | 入库侧也加了前缀,语义空间不对称 | 只给查询侧加前缀,文档侧保持不加 |
4.2 进阶:从“能搜到”到“搜得准”
如果你的知识库规模变大,单纯靠向量检索会遇到两个问题:一是相似段落太多,返回结果重复度高;二是字面相近但语义不同的片段会干扰判断。这时候有几个比较实用的手段。
第一个是 metadata 过滤。给每个 chunk 打上来源、章节、日期等标签,查询时用where条件缩小范围:
vectorstore.similarity_search( "退货政策是什么", k=5, where={"source": "return_policy.md"}, )第二个是 MMR 检索。MMR 会在相关性和多样性之间做平衡,避免返回的 5 个片段全是同一段话的重复改写:
vectorstore.max_marginal_relevance_search( "退货政策是什么", k=5, fetch_k=20, )第三个是混合检索。向量检索擅长语义,但有时候关键词精确命中也很重要。可以把 BM25 的检索结果和向量检索结果做一个简单分数融合,能显著提升长尾问题的召回率。这块如果要展开写,会引入rank_bm25之类的额外依赖,但工程上值得投入。
第四个是重排。先用向量检索召回 20 条候选,再用bge-reranker-large这类 CrossEncoder 对“查询+候选片段”逐一打分,取排序后的前 3 条。重排是效果提升最明显的一招,代价是额外的推理时间,适合离线把答案生成好、或者对延迟不敏感的场景。
4.3 几个我踩过的坑
最后分享几个真实的坑,都是文档里不会写的东西。
第一个坑:在同一个持久化目录下反复实验不同的 embedding 模型。我一开始为了对比默认模型和 bge 模型,直接在同一个persist_directory下新建了 collection,结果向量维度不一致,报错不说,旧 collection 还残留在目录里。后来我每次换模型都新建一个目录,或者在建 collection 之前先确认维度。这个习惯帮我省了很多事。
第二个坑:切分参数不是越大越好。我曾经把chunk_size调到 1000,想着上下文越全越好,结果很多 chunk 超过 embedding 模型输入上限被截断,检索效果反而变差。后来压回 300 到 400,配合中文标点切分,效果立刻回升。
第三个坑:Ollama 的 embedding 模型不要选nomic-embed-text跑中文。不是说不能跑,是效果和bge-m3差距明显。如果你已经用 Ollama 组织整个链路,建议直接拉bge-m3,别在这个问题上省事。
第四个坑:在团队协作或 CICD 环境里,如果你把 Chroma 的持久化目录提交到版本库,一定要固定好 embedding 模型版本,并在 README 里写清楚。不然别人拉下来一跑,检索结果莫名其妙,所有排查到最后才发现是 embedding 模型不一致,白白花掉一个下午。
我个人现在最常用的组合是:bge-small-zh-v1.5负责 embedding,文档按中文标点切到 300 字左右,检索时显式指定 cosine 空间,再挂上 Ollama 的qwen2.5:7b做生成。这套组合在知识库规模几十万字符以内都够用,资源占用不大,效果也稳定。如果你的数据量更大、对精度要求更高,再把重排和混合检索加进来,整个系统就能从“能跑”升级到“好用”。