1. 从一次召回翻车说起:Chroma 和 FAISS 到底差在哪
向量库这个词听起来很玄,其实它干的事很朴素:把文本变成一串数字(向量),存起来,然后你拿一个问题也变成一串数字,去里面找“数字长得最像”的那几条文本。检索召回就是这一步“找最像”的过程。Chroma 和 FAISS 是本地跑 RAG 时最常被拿来对比的两个选择,一个像自带收纳盒的储物柜,一个像只给你索引图纸的纯引擎。
我最初做本地知识库时,用 Chroma 跑通了 demo,换成 FAISS 后召回结果却对不上,排查半天才发现是两者在“距离度量”和“持久化方式”上的默认行为不同。这篇就围绕这个差异展开:先讲清楚 Chroma 与 FAISS 在检索召回链路里的定位区别,再给出用 TaoToken 统一 Key 的config.toml骨架,最后用同一批文档分别灌进两个库,跑一次召回对比,让你在本地就能复现。
适合谁看:已经会用 LangChain 做文档切分、想搞清楚向量库选型的人;手里有多个模型 Key、想统一走一个 API 通道的人;以及被as_retriever的search_type参数绕晕的人。全文命令和配置都可直接复制,环境是 Python 3.10 + LangChain 0.2 以上。
2. TaoToken 前置:统一 Key 与 API 通道
在讲向量库之前,得先把模型调用这条线理顺。因为检索召回链路里有两个地方要调模型:一是 embedding 模型把文本转成向量,二是召回后可能还要用 LLM 做答案生成。如果每个环节都单独配 Key,配置文件会散得到处都是。
TaoToken 在这里的角色是统一入口:你拿一个 Key,通过它的 API 通道去调不同的模型,embedding 和 chat 都能走同一个base_url。这样config.toml里只需要维护一份凭证,换模型时改model字段就行,不用动 Key。
你需要先拿到自己的 API Key,入口在控制台的 API Keys 页面:
https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite拿到 Key 后,API 的基础地址是https://taotoken.net/api,注意这个地址不带查询参数,直接作为base_url使用。如果你用的是 OpenAI 兼容的 SDK,通常需要在末尾补/v1,具体以接入文档为准:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite注意:Key 只存在本地配置文件或环境变量里,不要写进会提交到 Git 的代码。下面
config.toml里的 Key 字段建议用环境变量占位,运行时再注入。
3. 可复制配置:config.toml 骨架与两个向量库的接入
这一节是全文的核心。我先把config.toml的骨架给出来,它把模型通道和向量库参数分开管理,Chroma 和 FAISS 各自一个 section,切换时只改active_store一个值。
3.1 config.toml 完整骨架
# config.toml # 模型通道:统一走 TaoToken [llm] api_key = "${TAOTOKEN_API_KEY}" # 从环境变量读取 base_url = "https://taotoken.net/api/v1" chat_model = "deepseek-chat" embedding_model = "bge-base-zh-v1.5" # 也可换成通道支持的其它 embedding # 文本切分参数 [splitter] chunk_size = 500 chunk_overlap = 80 separator = "\n\n" # 向量库选择:chroma 或 faiss [store] active_store = "chroma" # Chroma 配置:自带持久化目录 [store.chroma] persist_directory = "./db/chroma" collection_name = "kb_demo" distance = "cosine" # 余弦相似度 # FAISS 配置:索引文件 + 映射文件 [store.faiss] index_path = "./db/faiss/index.faiss" docstore_path = "./db/faiss/index.pkl" distance = "euclidean" # FAISS 默认欧氏距离 # 检索召回参数 [retriever] search_type = "similarity" # similarity / mmr / similarity_score_threshold top_k = 5 score_threshold = 0.5 fetch_k = 20 # mmr 时的候选池大小这个骨架的关键设计是:[llm]只认一个base_url,embedding 和 chat 共用;[store]用active_store做开关;两个子 section 各自描述自己的持久化路径。下面分别看两个库怎么读这份配置。
3.2 读取配置并构建 embedding
# common.py import os import tomllib from langchain_openai import OpenAIEmbeddings, ChatOpenAI def load_config(path: str = "config.toml") -> dict: with open(path, "rb") as f: cfg = tomllib.load(f) # 注入环境变量 cfg["llm"]["api_key"] = os.environ["TAOTOKEN_API_KEY"] return cfg def build_embeddings(cfg: dict) -> OpenAIEmbeddings: return OpenAIEmbeddings( openai_api_key=cfg["llm"]["api_key"], openai_api_base=cfg["llm"]["base_url"], model=cfg["llm"]["embedding_model"], ) def build_llm(cfg: dict) -> ChatOpenAI: return ChatOpenAI( openai_api_key=cfg["llm"]["api_key"], openai_api_base=cfg["llm"]["base_url"], model=cfg["llm"]["chat_model"], temperature=0, )tomllib是 Python 3.11 起内置的,3.10 可以用tomli替代,读法一样。embedding 和 chat 都指向同一个base_url,这就是统一 Key 的意义:换模型只改model字段。
3.3 Chroma 接入:自带持久化
Chroma 的特点是“开箱即用”,它自己管理存储目录,你不需要关心索引文件长什么样。
# store_chroma.py from langchain_chroma import Chroma from common import load_config, build_embeddings def build_chroma(splits): cfg = load_config() embeddings = build_embeddings(cfg) c = cfg["store"]["chroma"] vectorstore = Chroma.from_documents( documents=splits, embedding=embeddings, persist_directory=c["persist_directory"], collection_name=c["collection_name"], collection_metadata={"hnsw:space": c["distance"]}, ) return vectorstore def load_chroma(): cfg = load_config() embeddings = build_embeddings(cfg) c = cfg["store"]["chroma"] return Chroma( persist_directory=c["persist_directory"], collection_name=c["collection_name"], embedding_function=embeddings, )注意collection_metadata={"hnsw:space": "cosine"}这一行,它决定了 Chroma 用余弦相似度。如果你不写,默认是 L2 距离,召回排序会和预期不一致——这是我踩过的坑之一。
3.4 FAISS 接入:索引与文档分开存
FAISS 只负责向量索引,文档原文和 ID 映射要另外存。LangChain 的封装帮你把这两件事绑在一起,但落盘时是两个文件。
# store_faiss.py import os from langchain_community.vectorstores import FAISS from common import load_config, build_embeddings def build_faiss(splits): cfg = load_config() embeddings = build_embeddings(cfg) f = cfg["store"]["faiss"] os.makedirs(os.path.dirname(f["index_path"]), exist_ok=True) vectorstore = FAISS.from_documents(splits, embedding=embeddings) vectorstore.save_local( folder_path=os.path.dirname(f["index_path"]), index_name="index", ) return vectorstore def load_faiss(): cfg = load_config() embeddings = build_embeddings(cfg) f = cfg["store"]["faiss"] return FAISS.load_local( folder_path=os.path.dirname(f["index_path"]), embeddings=embeddings, index_name="index", allow_dangerous_deserialization=True, # 本地可信文件才开 )allow_dangerous_deserialization=True是因为 FAISS 的 docstore 用 pickle 存,加载时会反序列化。只在你确认文件来源可信时开启,生产环境要谨慎。
3.5 统一检索器构建
两个库都通过as_retriever暴露检索接口,参数名一致,所以可以写一个工厂函数。
# retriever_factory.py from common import load_config from store_chroma import load_chroma from store_faiss import load_faiss def get_retriever(): cfg = load_config() r = cfg["retriever"] store = cfg["store"]["active_store"] if store == "chroma": vs = load_chroma() elif store == "faiss": vs = load_faiss() else: raise ValueError(f"unknown store: {store}") kwargs = {"k": r["top_k"]} if r["search_type"] == "mmr": kwargs["fetch_k"] = r["fetch_k"] if r["search_type"] == "similarity_score_threshold": kwargs["score_threshold"] = r["score_threshold"] return vs.as_retriever(search_type=r["search_type"], search_kwargs=kwargs)到这里,切换向量库只需要改config.toml里的active_store,代码一行不动。
4. 验证请求:同一批文档跑两种召回
配置写完了,得验证它真的能召回。我准备了三段关于向量库的短文本,分别灌进 Chroma 和 FAISS,然后用同一个问题去查,看返回结果和分数。
4.1 准备文档并入库
# ingest.py from langchain_core.documents import Document from store_chroma import build_chroma from store_faiss import build_faiss docs = [ Document(page_content="Chroma 是一个自带持久化的向量库,适合快速搭建本地知识库。"), Document(page_content="FAISS 是 Facebook 开源的相似度搜索库,只负责索引,文档要另外存。"), Document(page_content="检索召回的质量取决于 embedding 模型和切分策略,而不只是向量库本身。"), ] build_chroma(docs) build_faiss(docs) print("ingest done")运行:
export TAOTOKEN_API_KEY="你的Key" python ingest.py预期输出ingest done,同时./db/chroma和./db/faiss目录下会出现文件。
4.2 召回对比脚本
# query_compare.py from retriever_factory import get_retriever question = "FAISS 和 Chroma 有什么区别?" retriever = get_retriever() results = retriever.invoke(question) for i, doc in enumerate(results, 1): print(f"[{i}] {doc.page_content}")把config.toml的active_store改成chroma跑一次,再改成faiss跑一次。两次都能返回三条文档,但排序可能不同:Chroma 配了 cosine,FAISS 默认 euclidean,对短文本来说差异不大,但文档一多、向量维度一高,距离度量的影响就会显现。
4.3 换检索策略再验一次
把search_type改成mmr,fetch_k设成 3,再跑一次。MMR 会在相关性和多样性之间做平衡,返回的结果不会全是同一主题的重复内容。这一步能验证你的config.toml里fetch_k参数确实被读进去了。
[retriever] search_type = "mmr" top_k = 2 fetch_k = 3如果返回条数变成 2,说明top_k生效;如果结果之间差异变大,说明 MMR 在起作用。
5. 本篇常见错排查
5.1 Chroma 召回结果和 FAISS 对不上
最常见的原因是距离度量不一致。Chroma 默认 L2,FAISS 默认也是 L2,但如果你在 Chroma 里配了hnsw:space=cosine而 FAISS 没配,两边排序就会不同。解决办法是在config.toml里显式声明各自的distance,并确保 embedding 做了归一化(余弦相似度要求向量归一化)。
5.2 FAISS load_local 报反序列化错误
报错信息类似ValueError: The de-serialization relies on loading a pickle file。这是 LangChain 的安全限制,需要在load_local里加allow_dangerous_deserialization=True。但要注意,这个参数只对你自己生成的索引文件开,不要加载来源不明的 pkl。
5.3 embedding 调用返回 401 或 404
先检查base_url是否带了/v1。TaoToken 的 API 根地址是https://taotoken.net/api,OpenAI 兼容 SDK 通常需要https://taotoken.net/api/v1。如果 401,检查环境变量TAOTOKEN_API_KEY是否真的注入到了进程里,可以用echo $TAOTOKEN_API_KEY确认。
5.4 as_retriever 的 k 参数不生效
search_kwargs里的k会被search_type影响。比如similarity_score_threshold模式下,如果所有文档分数都低于阈值,返回可能是空的,看起来像k没生效。先把score_threshold调低到 0.2 试试,确认链路通了再往上调。
5.5 切换 active_store 后仍读旧库
Chroma 和 FAISS 的持久化路径不同,但如果你改了persist_directory却没删旧目录,Chroma 可能会读到旧 collection。排查方法是打印vectorstore._collection.count(),看文档数是否和你灌入的一致。
6. 继续往下走:把召回接进对话链路
检索召回跑通后,下一步通常是把它接到 LLM 上做 RAG 问答。这时候你可以用同一个config.toml里的[llm]段构建 chat 模型,把 retriever 返回的文档拼进 prompt。想先单独验证模型通道是否通,可以直接在模型对话页面试一条:
https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite如果你打算长期跑编码类或 Agent 类任务,反复调 embedding 和 chat,可以考虑 Coding Plan,它更适合高频调用的场景:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite接入过程中遇到 Key 或通道配置问题,接入文档里有各语言的示例:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite最后留一个实用建议:Chroma 和 FAISS 的选型不用纠结太久。本地小规模知识库、想少写持久化代码,用 Chroma;需要精细控制索引类型、或者向量规模上到百万级,用 FAISS。真正影响召回质量的,往往是切分策略和 embedding 模型,而不是这两个库本身。把config.toml的active_store留成开关,两边都跑一遍对比,比看十篇评测都管用。