☰
Chroma中文支持全攻略:换对嵌入模型,让向量检索不跑偏
2026/9/29 18:29:00 网站建设 项目流程

1. 先搞清楚:Chroma不是不认中文,是默认组件不懂中文

1.1 大多数人遇到的典型场景

我见过好几个朋友做本地知识库时都是同一个流程:从GitHub上找一套LangChain加Chroma的代码,把PDF和Markdown灌进去,然后高高兴兴去检索。结果中文问一句,返回的全是些看着像“相关”实际驴唇不对马嘴的内容。有人以为是文档切太碎,有人以为是问答模型不够聪明,调了半天提示词,问题原封不动还在。

这里的坑不在最后那层大模型,而在链条的上游——向量数据库这一环就没把中文处理好。Chroma本身是个存储和检索引擎,它对文本其实没有语言偏好,真正偏向英文的是它那套默认配置。说白了,Chroma不会拒绝中文,但你如果不做任何定制,它就会用一套为英文优化的默认参数去硬啃中文,效果自然不行。

1.2 第一个根因:默认嵌入模型是英文特化的

Chroma如果不显式指定嵌入函数,默认加载的是all-MiniLM-L6-v2。这个模型在小规模英文语义任务里确实能打,几百MB的体量,句子向量效果对得起体积。但它是用英文语料训练的,压根没见过多少中文。

你拿一段中文文本喂进去,它不会像人一样先看懂句子,而是把每个字或者每个连续片段按字符切散,映射到一个它自己也不知道该放哪儿的向量空间里。结果就是:不同句子在这些向量上的距离分布几乎没规律,语义相近的中文句子可能离得很远,语义无关的句子反而挤在一起。

我做过一次简单测试,用默认嵌入模型把“如何配置静态路由”和“怎么煮一杯手冲咖啡”编码成向量,余弦相似度居然比“如何配置静态路由”和“怎么配置OSPF动态路由”还高。这已经不能用效果差来形容了,基本等于随机排序。所以如果直接用默认配置跑中文知识库,后面的检索就是在噪声里捞针,捞到什么全凭运气。

1.3 第二个根因:默认分词逻辑对中文是失效的

另一个隐蔽问题是Chroma内部处理文本时做的tokenization,逻辑是按空格、标点和常见英文词根来切分。英文句子词与词之间有天然空格,切出来就是一个个有意义的单词。中文没有这个边界,整句话是连续的汉字流,按空格切就只剩一个巨大的整体。

有人会问:那按字符切行不行?理论上所有主流tokenizer都能把中文字符编成token,但这样切出来的单个汉字基本不携带语义。比如“苹果”这个词,拆成“苹”和“果”两个字去编码,语义就散架了。即便和“香蕉”“手机”等词做相似度计算,结果也接近随机。

所以中文场景真正的问题顺序是:嵌入模型不懂中文,导致向量空间没有语义结构;分词逻辑又加剧了这个问题,让模型连输入都“读不顺”。这两件事加起来,中文知识库的检索效果自然惨不忍睹。下面要做的就是一层一层把这些默认配置换掉。

2. 第一步改造:换一个真正懂中文的嵌入模型

2.1 中文嵌入模型怎么选

换嵌入模型是让Chroma支持中文的最关键一步。目前中文场景有几个成熟的开源选项,我简单说一下选型逻辑,方便你直接对号入座。

  • BAAI/bge-large-zh-v1.5:1024维,BERT框架,效果在中文检索里排第一梯队,但模型文件接近1.3GB,建议有GPU再上,CPU跑起来太痛苦。
  • BAAI/bge-small-zh-v1.5:512维,只有约100MB,CPU也能跑,检索效果略逊但日常问答够用,是我目前最推荐的中文起步款。
  • moka-ai/m3e-base:768维,对中文长文本支持不错,在短文本相似度上稍微吃亏一点。
  • shibing624/text2vec-base-chinese:768维,同样是中文预训练模型,和bge系列风格类似,可以作为备选。
  • Ollama里可以直接拉bge-m3,它有5700维但做了降维处理,通过OllamaEmbeddings集成很省事,适合不想折腾Python依赖的人。

选型的时候别只看效果排行,还要看你的硬件。我实测下来,bge-small-zh-v1.5在CPU上嵌入1000段中文文本大约需要几分钟,而bge-large-zh-v1.5可能要十几分钟甚至更久。如果机器没有独立显卡,老老实实选小模型。

2.2 嵌入函数怎么接入Chroma(LangChain模式)

接入方式不复杂,关键是别搞错API版本。

在LangChain框架下,你先把嵌入模型封装成HuggingFaceEmbeddings,再传给Chroma的collection或from_documents。一段最简洁的示例代码:

from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma embedding = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", encode_kwargs={"normalize_embeddings": True} ) vectorstore = Chroma( persist_directory="./chroma_db", embedding_function=embedding )

注意normalize_embeddings这个参数建议设为True,因为后续算余弦相似度时归一化能省掉很多隐性bug。

如果你不想依赖LangChain,直接用Chroma客户端也行:

from chromadb.utils import embedding_functions from chromadb import PersistentClient client = PersistentClient(path="./chroma_db") ef = embedding_functions.HuggingFaceEmbeddingFunction( api_key="unused", model_name="BAAI/bge-small-zh-v1.5", ) collection = client.get_or_create_collection( name="zh_docs", embedding_function=ef )

这里有个容易踩的坑:HuggingFaceEmbeddingFunction内部会从HuggingFace Hub下载模型,如果你的网络环境不能直连,需要先手动把模型下载到本地,然后修改local_files_only或者直接把模型路径填进去。否则你会发现代码卡在下载那一步,半天没反应。

2.3 嵌入维度和存储约束

换模型之后要记住一个硬性约束:同一个collection里所有向量的维度必须一致,而且一旦collection创建,维度就固定了。如果你之前已经建过collection,再换嵌入模型,必须新建一个collection,不能直接塞不同维度的向量进去。

具体来说,all-MiniLM-L6-v2的维度是384,bge-small-zh-v1.5是512,bge-large-zh-v1.5是1024。切换模型后维度变了,Chroma会直接报维度不匹配的错误。实际项目里如果有人改了嵌入模型但忘了删旧的collection,十有八九会遇到这个报错。

另外存储空间也要心里有数。每个向量按维度乘4字节存储,100万条512维的中文文档向量大概占用2GB左右空间,加上原始文本和元数据,不能只按文本大小估算。

3. 第二步改造:中文分块要从字符切分升级到语义切分

3.1 为什么分块策略在中文里比英文更关键

嵌入模型换对了,中文检索已经从“随机捞针”进步到“大致能捞”。但接下来分块会决定检索的天花板。

英文分块切在单词边界上,即使切碎了,每个单词本身还保留着一定的语义独立性。中文不一样,句子里的信息密度很高,一个分块切错位置,比如把“总经理办公室”和“会议室预定规则”切在两个块里,检索“会议室预定”时就可能召回不到真正的上下文。

所以中文分块的核心不是满足字数上限,而是尽量保证一个语义完整的句子或段落不被腰斩。

3.2 一套适合中文的分块实现

LangChain自带的RecursiveCharacterTextSplitter默认分隔符是按英文习惯设计的,遇到中文文本会把整段话吃进去,导致chunk过大。正确做法是自定义分隔符,让它在中文标点处优先切分:

from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], chunk_size=500, chunk_overlap=50, length_function=len, )

这里separators的顺序很有讲究,从长到短排列:首先找段落级别的空行,然后是换行,再是句号和感叹号。只有当上一层找不到分隔符时,才会落到更细的标点,最后才按字符合并。这样尽量避免在句中被硬切。

还有一点,chunk_size这个参数在英文语境里通常用token数来定,但中文用len()按字符数算更直观。原因在于一般中文tokenizer对汉字的编码大约是1个字符等于1到2个token,按字符数设置500,实际占用的token数量正好是模型比较舒服的窗口范围。如果按token数设置,切出来的块对中文来说会显得很碎,白白增加存储量。

如果你对句子的完整性要求特别高,可以用正则先按。!?切分句子,再把若干句子合并到一个chunk里,手动控制最大长度。这种方式对做法律、金融这类长文档知识库特别有用,因为那些文档的一个条款跨好几行,按固定长度切很容易把“但是”和“除外”切到两个块里。

3.3 分块参数的实测推荐值

我在中文场景里试过几组参数,给你一个可以直接抄的配置基准:

  • chunk_size=500字符:适合大多数问答式知识库,单块信息量足够,检索定位也准。
  • chunk_overlap=50字符:保证关键词出现在两块交界处时不会漏掉。
  • chunk_size=300字符:适合问答型文档,比如FAQ,每块就是一个问答对,检索精度最高。
  • chunk_size=800字符:适合总结型、综述型文档,单块内容完整度更高,但检索时偶尔会带上无关内容。

要注意的是,分块不是小事。如果分块太小,检索到的上下文不够,大模型回答会支离破碎;如果分块太大,一个chunk里包含多个主题,检索时容易把不相关的信息混进来。建议先拿一小批真实文档跑一遍,人工检查被召回的chunk是否“对得上提问”,再决定要不要调参。

4. 第三步改造:检索后的二次排序决定问答质量

4.1 TopK和元数据过滤,先别急着加大召回

很多人以为检索不到想要的内容就疯狂调大TopK,从5调到50,结果召回来一堆不相关的分块,把大模型的注意力带偏了。中文场景里,embedding检索的排序质量不如英文稳定,所以正确思路是在小范围召回基础上叠加过滤和精排。

Chroma支持按元数据过滤,这个功能在中文知识库里特别实用。比如你的文档有章节、来源、标签字段,可以在查询时加上where条件缩小范围。举例来说:

vectorstore.similarity_search( query="服务器内存不足如何处理", k=10, filter={"source": "运维手册"} )

这样做比单纯调大TopK有效得多。先把候选集中到正确文档里,再谈排序问题。

4.2 用Reranker把“排前面的垃圾”踢出去

到了这一步,如果检索结果还是不够精准,可以考虑加一个reranker做第二次排序。思路很简单:第一次用Chroma的向量检索快速捞出比如50条候选,再用一个交叉编码器模型对每条候选和提问做更精细的相关性打分,最后取分最高的5条。

中文场景里推荐用BAAI/bge-reranker-base,它在中文检索排序上的表现明显好过单纯靠向量余弦相似度。实现过程也不复杂:

from langchain.retrievers.document_compressors import CrossEncoderReranker from langchain_community.cross_encoders import HuggingFaceCrossEncoder encoder = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base") compressor = CrossEncoderReranker(model=encoder, top_n=5)

这种方式增加了一次推理计算,检索耗时大概多一两百毫秒,但换来的是回答质量肉眼可见的提升。尤其是知识库里存在大量相似话题的中文文档时,Reranker的价值非常明显。

4.3 中文混合检索:配合分词器做BM25

向量检索擅长语义相似,BM25擅长关键词精确匹配。中文知识库里经常出现专业名词、型号、编号这类信息,比如“BGP”“OSPF”“X99”,这些词在向量空间里很容易被稀释。如果只靠向量检索,精确信息反而容易丢。

实际方案是在召回阶段同时跑两路:一路是Chroma向量检索,另一路是用jieba分词的BM25索引。然后把两路结果做简单融合。

import jieba from rank_bm25 import BM25Okapi tokenized_docs = [list(jieba.cut(doc)) for doc in all_docs] bm25 = BM25Okapi(tokenized_docs) bm25_scores = bm25.get_scores(list(jieba.cut(query)))

融合方式我试过最简单有效的是:先各取Top20,然后按排名加权合并,或者直接取两路结果的并集再交给Reranker。对中文专业文档来说,这种混合检索能把向量检索的“大意理解”和BM25的“精确匹配”结合起来,整体效果比单路检索稳定得多。

5. Ollama + LangChain + Chroma本地知识库的中文完整流水线

5.1 组件分工和架构

结合Ollama来做本地知识库,是目前比较轻量的一条路线。对方各干各的:

  • Ollama负责两件事:一是跑嵌入模型,比如拉取bge-m3;二是跑问答生成模型,比如qwen2.5。这样就不需要为嵌入单独配一个Python环境,也不用折腾CUDA。
  • LangChain负责流程编排:文档加载、分块、调用嵌入、写入Chroma、查询时把召回结果塞进提示词。
  • Chroma负责向量存储和检索,存的是中文文档切块编码后的向量。

这个组合的优势是不用自己写嵌入和生成的服务代码,全都有现成接口。

5.2 我实际跑通的最小配置

先拉模型:

ollama pull bge-m3 ollama pull qwen2.5:7b

然后完整跑一遍入库和问答的逻辑:

from langchain_community.embeddings import OllamaEmbeddings from langchain_community.vectorstores import Chroma from langchain_community.chat_models import ChatOllama # 1. 中文嵌入 embedding = OllamaEmbeddings(model="bge-m3") # 2. 中文分块 from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], chunk_size=500, chunk_overlap=50 ) # 3. 写入Chroma vectorstore = Chroma.from_documents( documents=splitter.split_documents(docs), embedding=embedding, persist_directory="./zh_kb" ) # 4. 检索并交给Ollama生成回答 retriever = vectorstore.as_retriever(search_kwargs={"k": 5}) llm = ChatOllama(model="qwen2.5:7b", temperature=0.3) from langchain.chains import RetrievalQA qa_chain = RetrievalQA.from_chain_type( llm=llm, retriever=retriever, return_source_documents=True ) answer = qa_chain.invoke({"query": "知识库里有没有提到Chroma的默认嵌入模型?"}) print(answer["result"])

这套流程跑通后,整个知识库就完全工作在中文语境下了,从嵌入到生成都不依赖外部API。

5.3 同一批中文问题改造前后的对比

我用一套包含十来篇中文技术文档的测试集,对比了改造前后的问答效果。同一个问题“默认嵌入模型为什么不适合中文”,改造前Chroma检索出的结果是片段,有的在讲英文tokenizer,有的在讲多模态,几乎没有一条直接命中;改造后检索出的几条都围绕默认嵌入模型展开。

问答差异更大。改造前大模型拿着不相干的上下文,只能给出一段泛泛的套话;改造后它能够明确指出默认模型是all-MiniLM-L6-v2、训练语料以英文为主,所以对中文语义捕捉不准。这就是检索质量直接决定问答质量的直观体现。如果你现在正卡在“知识库答非所问”这个阶段,优先查检索链路,别急着换大模型。

6. 那些和中文相关的边角坑:路径、编码、可视化

6.1 Windows中文路径导致持久化失败

在Windows下如果用户名是中文,或者你把Chroma的persist_directory放在一个含中文的路径里,偶尔会出现建库失败或者读取不到已有数据的情况。问题根源在于底层sqlite3在处理包含非ASCII字符的路径时,和Python的编码处理方式在部分环境里衔接不顺畅。

解决思路有两个:一个是工程上避免,直接把持久化目录放到纯英文路径下,比如D:\kb\chroma_db;另一个是如果没法改路径,就在连接时设置编码相关参数,或者在代码层面使用pathlib.Path以保证文件路径对象被正确处理。我建议优先选纯英文路径,不是不能解决,而是没必要让自己在环境问题上浪费时间。

6.2 导出CSV/JSON时的中文乱码

从Chroma里导出数据做分析时,中文乱码是常见问题。Chroma本身存的是向量和文本元数据,没有编码问题。乱码大多发生在你手动写json.dump或csv.writer时没指定编码。

写入CSV时最容易被坑:

import csv with open("export.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f) writer.writerow(["文本", "向量"])

这里建议用utf-8-sig而不是utf-8,因为Excel打开CSV时默认按ANSI解析,UTF-8无BOM会被识别成乱码。加一个BOM头就能避免。

JSON导入导出则统一用ensure_ascii=False,否则中文全变成\uXXXX转义序列:

with open("export.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)

6.3 用向量可视化检查中文是否被正确编码

如果你不确定自己的嵌入模型对中文是否真的有效,一个最直观的验证方式是把中文文档的向量降维后画出来看分布。随便挑几类主题,比如“网络配置”“咖啡制作”“编程语言”“旅游攻略”,每类放几十条文本,编码后用t-SNE或PCA降维到二维平面,再用matplotlib上色。

如果发现不同主题的向量在平面上各自成簇、边界清晰,说明嵌入模型已经把中文语义编码得相当好。如果所有点糊成一团、颜色混杂,说明嵌入模型还有问题,需要换更大的模型或者检查输入文本的清洗逻辑。

这里顺带提一句,matplotlib画图如果中文标签显示成方框,记得先设置字体:

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False

这个坑不算Chroma的问题,但凡是做中文数据可视化都会遇到,提前配好能省不少事。


个人体会:让Chroma支持中文,本质不是去改Chroma源码,而是把它默认链条里的英文假设全部替换成中文友好的组件。嵌入模型是重中之重,分块策略决定信息完整性,检索后的精排决定最终问答质量。这三层改完,中文知识库才算真正能用。如果你现在卡在“中文检索效果差”这个问题上,先别急着怀疑大模型,回去检查一下嵌入模型是不是还在用那个英文默认款。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询