一个 Python 开发者的 RAG 入门实战总结
大家好,我是一名有 Python 基础的开发者。最近两周我从零开始学习 RAG(检索增强生成),并亲手搭建了一个能问答的 Demo。过程中踩过不少坑,也收获了很多"原来如此"的时刻。
今天总结成 10 条经验,分享给想入门 RAG 的你。
1. RAG 流程其实就 5 步
刚开始看各种文章,被"索引""检索""生成"这些术语搞得晕头转向。后来自己写代码才发现,RAG 的核心流程其实就是 5 步:
📄 文档加载 → ✂️ 文本切分 → 🔢 向量化 → 🔍 检索 → 💬 生成回答
用代码表示就是这样简单:
python
# 伪代码,但逻辑是真实的
documents = load_documents("docs/") # 1. 加载
chunks = split_text(documents, size=500) # 2. 切分
vectors = embed_model.encode(chunks) # 3. 向量化
db.add(vectors, chunks) # 3. 存储
# 查询时
query_vector = embed_model.encode(["用户问题"]) # 4. 向量化问题
results = db.search(query_vector, top_k=3) # 4. 检索
answer = llm.generate(context=results, query) # 5. 生成
我的思考 :不要被术语吓到。RAG 的本质就是"把文档切成小块,用向量表示,检索相关块,让 LLM 基于这些块回答"。理解了这个核心,再看各种优化技巧就清晰多了。
2. Embedding 不是魔法,就是"给词一个坐标"
Embedding 是我一开始最难理解的概念。什么"高维空间""稠密向量",听得一头雾水。
后来我想通了:Embedding 就是给每个词/句子一个"数字坐标",让意思相近的内容,坐标也接近。
python
# 例子:用 SentenceTransformer 生成 Embedding
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('all-MiniLM-L6-v2')
texts = [
"机器学习是人工智能的核心",
"深度学习是机器学习的子领域",
"今天天气不错"
]
vectors = model.encode(texts)
print(vectors.shape) # (3, 384) → 3 个句子,每个 384 维
这 384 个数字,就是这个句子的"坐标"。语义相近的句子,坐标也接近。
我的思考:不要纠结"每个数字代表什么"。就像你不需要知道 GPS 坐标的 x/y 具体怎么算的,只需要知道"坐标相近 = 位置相近"就够了。
3. 余弦相似度:只看方向,不看长度
检索的核心是计算"两个向量有多像"。最常用的是余弦相似度。
公式看起来吓人:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> cosine ( A , B ) = A ⋅ B ∥ A ∥ × ∥ B ∥ \text{cosine}(A, B) = \frac{A \cdot B}{\|A\| \times \|B\|} </math>cosine(A,B)=∥A∥×∥B∥A⋅B
其实用人话说就是:忽略长度,只看两个向量的"方向夹角"。
python
from sklearn.metrics.pairwise import cosine_similarity
A = [1, 2, 3]
B = [2, 4, 6] # 是 A 的 2 倍,方向相同
sim = cosine_similarity([A], [B])
print(sim[0][0]) # 1.0 → 完全相同方向
| 相似度 | 含义 |
|---|---|
| 1.0 | 方向完全相同 |
| 0.8-0.9 | 非常相似 |
| 0.5-0.7 | 中等相似 |
| <0.5 | 不太相关 |
我的思考:为什么不用欧氏距离?因为文本的"长度"不代表什么。"你好"和"你好吗"欧氏距离可能很远,但语义很接近。余弦相似度刚好忽略了长度这个干扰因素。
4. 文本切分:不切分真的不行
我一开始偷懒,想把整篇文档直接向量化。结果有两个问题:
- LLM 上下文不够用:一篇 10 万字的文档,直接塞进 Prompt 会超限
- 检索精度极差:用户问一个小问题,返回整篇文档,噪音太大
python
# ❌ 错误做法
chunks = ["整篇 10 万字的技术文档"]
# ✅ 正确做法
chunks = [
"第一章:机器学习基础...",
"第二章:深度学习入门...",
"第三章:实战案例..."
]
我的思考:切分的本质是把"文档级检索"变成"段落级检索"。就像图书馆不是给你整本书,而是帮你复印相关的那几页。
5. chunk_size:从 500 开始,然后测试
关于切分多大,我查了很多资料,最后总结出一个实用策略:
| 文档类型 | 推荐 chunk_size |
|---|---|
| FAQ/问答 | 200-300 |
| 通用文档 | 500(推荐起点) |
| 技术报告 | 800-1000 |
| 代码文件 | 按函数/类切分 |
python
# 我的测试代码
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每块 500 字符
chunk_overlap=50, # 重叠 50 字符
separators=["\n\n", "\n", "。", "!", "?", " ", ""]
)
chunks = splitter.split_text(long_text)
我的思考:没有"最佳"chunk_size,只有"最适合你场景"的。我的建议是先设 500,然后用 10-20 个测试问题验证,看哪个值召回率最高。
6. chunk_overlap:防止信息被"拦腰切断"
overlap 是相邻 chunk 之间的重叠部分。一开始我觉得这是浪费,后来发现真不是。
vbnet
原文:"机器学习是人工智能的核心。深度学习是机器学习的子领域。"
❌ 没有 overlap:
chunk_1: "机器学习是人工智能"
chunk_2: "的核心。深度学习是"
chunk_3: "机器学习的子领域"
✅ 有 overlap(重叠 50 字):
chunk_1: "机器学习是人工智能的核心。深度"
chunk_2: "核心。深度学习是机器学习的子"
chunk_3: "子领域。深度学习是机器学习的"
我的思考 :overlap 的核心作用是防止关键信息被切分。我设置的规则是:overlap 至少包含 1-2 个完整句子,一般是 chunk_size 的 10-20%。
7. 检索不到正确答案?先查这 4 个地方
我遇到最多的问题是:"检索结果完全答非所问"。后来总结了一个诊断清单:
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| 检索结果完全无关 | 切分丢失上下文 | 带上标题/父级信息 |
| 结果"有点相关但不是答案" | chunk 太碎了 | 调大 chunk_size |
| 专业术语检索不到 | Embedding 模型问题 | 用领域模型或加同义句 |
| 口语问法检索不到 | 表达方式不匹配 | Query 改写或加 FAQ |
一个真实案例:
arduino
用户问:"这个手机防水吗?"
文档写的是:"IP68 级防尘防水"
❌ 直接检索:相似度很低
✅ 解决:在文档里加一句"这个手机支持防水,等级是 IP68"
我的思考:RAG 不是"建好就能用",需要不断测试和优化。我建了一个 Excel,记录 20 个测试问题和每次调整后的效果,迭代几次后准确率从 60% 提升到 85%。
8. Embedding 模型怎么选?中文就用 BGE-M3
Embedding 模型有很多选择,我的建议是:
| 场景 | 推荐模型 |
|---|---|
| 中文为主 | BGE-M3(免费开源) |
| 追求效果 + 预算够 | Voyage API |
| 已在用 OpenAI 生态 | text-embedding-3-small |
| 数据敏感/本地部署 | BGE-M3 |
python
# 用 BGE-M3 的例子
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('BAAI/bge-m3')
vectors = model.encode(["你好,世界"])
我的思考:不要一开始就追求"最好"的模型。我一开始纠结选哪个模型,浪费了一周。后来直接用 BGE-M3,效果够用,省下的时间用来优化切分和检索策略,收益更大。
9. 维度不是越高越好
Embedding 向量有 384 维、1536 维、3072 维等选择。我一开始觉得越高越好,后来发现不是。
| 维度 | 优点 | 缺点 |
|---|---|---|
| 384 | 快、省空间 | 语义区分能力一般 |
| 1536 | 平衡 | 适中 |
| 3072 | 语义区分细腻 | 慢、贵、存储大 |
一个直观的对比:
bash
1536 维 → 6 KB / 条
3072 维 → 12 KB / 条
100 万条数据:6 GB vs 12 GB
OpenAI 价格:$0.02 vs $0.13 / 100万 token(贵 6.5 倍)
我的思考 :维度是"精度"和"成本"的权衡。我的建议:不确定就用 1536 维,够用且成本可控。
10. 相似度阈值:不要硬用一刀切
我一开始设了个阈值 0.7,高于这个值才返回结果。后来发现有问题:
arduino
严格匹配场景(客服问答):0.85-0.90 才回答,否则说"没找到"
推荐系统场景:0.65 就可以召回,让用户自己筛选
后来我改用"Top K + 重排序"策略:
python
# 伪代码
results = db.search(query, top_k=10) # 先召回 10 条
reranked = reranker.rank(query, results) # 用精排模型重排
final = reranked[:5] # 取前 5 条给 LLM
我的思考:阈值不是不能用,但更好的做法是"召回多一些,用重排序模型精排"。重排序模型的判断比向量检索准确得多,因为它能把 query 和 document 一起编码。
总结:给初学者的 3 条建议
学完这两周,我最大的收获是:
- 不要等"完全学会"再动手。我第 1 天就借助AI写出了能跑的 Demo,后面边做边学,让AI帮我理解代码和底层原理。
- 先跑通流程,再优化细节。RAG 的 5 步流程很简单,先让它能工作,再调 chunk_size、换 Embedding 模型。
- 用数据说话,不要凭感觉。建个测试集,记录每次调整的效果,迭代几次后效果会明显提升。
RAG 不难,难的是开始动手。希望这篇总结能帮你少走弯路。
python
# 附上RAG源代码 naive_rag
"""
Naive RAG Pipeline 实现
基于 learn.txt 文本文件的简单检索增强生成示例
RAG (Retrieval-Augmented Generation) 检索增强生成:
1. 将文档分块并向量化存储到向量数据库
2. 用户提问时,先检索相关文档块
3. 将检索到的上下文和问题一起交给大模型生成回答
核心流程:文档处理 → 向量化 → 存储 → 检索 → 生成
"""
import os
from typing import List, Tuple
from dotenv import load_dotenv
# 向量化和向量存储
from sentence_transformers import SentenceTransformer # HuggingFace 的句子嵌入模型,用于将文本转换为向量
import chromadb # 轻量级向量数据库,用于存储和检索向量
# 大模型接口
from openrouter import OpenRouter # OpenRouter API 客户端,用于访问各种大语言模型
# 加载环境变量(从 .env 文件读取 API 密钥等配置)
load_dotenv()
# ============================================================
# 1. 默认配置参数
# ============================================================
# 文本分块默认配置
DEFAULT_CHUNK_SIZE = 1000 # 每个文本块的字符数(控制分块大小,影响检索精度)
DEFAULT_CHUNK_OVERLAP = 100 # 相邻块之间的重叠字符数(避免信息被切断,保持上下文连贯性)
# 检索默认配置
DEFAULT_TOP_K = 3 # 检索时返回最相关的 K 个文档块(数量影响上下文长度和信息量)
# Embedding 模型默认配置
DEFAULT_EMBEDDING_MODEL = "all-MiniLM-L6-v2" # 句子变换器模型,将文本转为 384 维向量
# 英文场景推荐,中文可换用 "paraphrase-multilingual-MiniLM-L12-v2"
# 向量数据库默认配置
DEFAULT_DB_PATH = "./chroma_dbnaive1" # ChromaDB 数据持久化存储路径
DEFAULT_COLLECTION_NAME = "learn_documents1" # 文档集合名称,用于分类管理不同来源的文档
# ============================================================
# 2. 文本分块函数
# ============================================================
import nltk
from nltk.tokenize import sent_tokenize
# 下载必要的nltk资源(只需要运行一次)
try:
nltk.data.find('tokenizers/punkt')
except LookupError:
nltk.download('punkt')
def chunk_text(text: str, chunk_size: int, chunk_overlap: int) -> List[str]:
"""
将文本分割成指定大小的块,支持块间重叠,保持句子完整性
参数:
text: 要分块的文本
chunk_size: 每个文本块的最大字符数
chunk_overlap: 相邻块之间的重叠字符数
返回:
List[str]: 分块后的文本列表
"""
if not text:
return []
# 首先将文本分割成句子
sentences = sent_tokenize(text)
if not sentences:
return [text]
chunks = []
current_chunk = []
current_chunk_length = 0
for sentence in sentences:
sentence_length = len(sentence)
# 如果句子本身就超过了块大小,特殊处理
if sentence_length > chunk_size:
# 保存当前块
if current_chunk:
chunks.append(' '.join(current_chunk))
current_chunk = []
current_chunk_length = 0
# 将超长句子分割成更小的块
start = 0
while start < sentence_length:
end = min(start + chunk_size, sentence_length)
# 尽量在标点处分割
if end < sentence_length:
# 查找最后一个标点符号
punctuation = ['.', '!', '?', ';', '。', '!', '?', ';']
last_punc = -1
for p in punctuation:
pos = sentence.rfind(p, start, end)
if pos > last_punc:
last_punc = pos
if last_punc != -1:
end = last_punc + 1
chunks.append(sentence[start:end])
# 处理重叠
if end < sentence_length:
start = max(end - chunk_overlap, start + 1)
else:
start = end
continue
# 计算添加当前句子后的总长度(包括空格)
new_length = current_chunk_length + sentence_length + (1 if current_chunk else 0)
# 如果添加后超过限制,完成当前块并开始新块
if new_length > chunk_size:
# 保存当前块
chunks.append(' '.join(current_chunk))
# 处理重叠:从当前块末尾开始取句子,直到达到重叠大小
overlap_chars = 0
overlap_sentences = []
# 从后往前遍历当前块的句子
for sent in reversed(current_chunk):
sent_len_with_space = len(sent) + 1 # +1 for space
if overlap_chars + sent_len_with_space > chunk_overlap:
break
overlap_chars += sent_len_with_space
overlap_sentences.insert(0, sent)
# 开始新块,包含重叠句子
current_chunk = overlap_sentences
current_chunk_length = sum(len(s) + 1 for s in current_chunk) - 1 if current_chunk else 0
# 添加当前句子到块
current_chunk.append(sentence)
current_chunk_length = current_chunk_length + sentence_length + (1 if current_chunk_length > 0 else 0)
# 添加最后一个块
if current_chunk:
chunks.append(' '.join(current_chunk))
return chunks
# ============================================================
# 3. 向量数据库操作
# ============================================================
class NaiveRAG:
def __init__(self,
db_path: str = DEFAULT_DB_PATH,
collection_name: str = DEFAULT_COLLECTION_NAME,
embedding_model: str = DEFAULT_EMBEDDING_MODEL,
chunk_size: int = DEFAULT_CHUNK_SIZE,
chunk_overlap: int = DEFAULT_CHUNK_OVERLAP,
top_k: int = DEFAULT_TOP_K):
"""
初始化 RAG 系统
参数:
db_path: 向量数据库存储路径
collection_name: 文档集合名称
embedding_model: 用于生成向量的模型名称
chunk_size: 文本分块大小
chunk_overlap: 文本分块重叠大小
top_k: 默认检索返回的文档块数量
"""
# 存储配置参数
self.db_path = db_path
self.collection_name = collection_name
self.embedding_model = embedding_model
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
self.top_k = top_k
# 初始化模型
print(f"🔄 加载 Embedding 模型: {embedding_model}")
self.embed_model = SentenceTransformer(embedding_model)
# 初始化向量数据库
print(f"🔄 初始化向量数据库: {db_path}")
self.client = chromadb.PersistentClient(path=db_path)
self.collection = self.client.get_or_create_collection(collection_name)
print(f"✅ 初始化完成,当前文档块数: {self.collection.count()}")
def index_document(self, file_path: str, replace_existing: bool = False) -> int:
"""
索引文档:读取、分块、向量化、存储
参数:
file_path: 要索引的文档路径
replace_existing: 如果文档已存在,是否替换现有索引
返回:
int: 索引的文档块数
异常:
FileNotFoundError: 当文件不存在时
IOError: 当文件读取失败时
Exception: 当索引过程中发生其他错误时
"""
# 输入验证
if not file_path:
raise ValueError("文件路径不能为空")
# 读取文档
print(f"📄 读取文档: {file_path}")
try:
with open(file_path, 'r', encoding='utf-8') as f:
text = f.read()
except FileNotFoundError:
raise FileNotFoundError(f"❌ 文件不存在: {file_path}")
except IOError as e:
raise IOError(f"❌ 文件读取失败: {str(e)}")
# 检查是否已存在该文档的索引
doc_name = os.path.basename(file_path)
existing_count = 0
try:
# 查询是否存在该文档的索引
existing_results = self.collection.get(where={"source": doc_name})
existing_count = len(existing_results.get("ids", []))
except Exception:
# 如果查询失败,假设文档不存在
existing_count = 0
if existing_count > 0:
if replace_existing:
print(f"🔄 文档 '{doc_name}' 已存在 {existing_count} 个块,正在替换...")
# 删除现有索引
try:
self.collection.delete(where={"source": doc_name})
print("🗑️ 已删除现有索引")
except Exception as e:
raise Exception(f"❌ 删除现有索引失败: {str(e)}")
else:
print(f"⚠️ 文档 '{doc_name}' 已存在 {existing_count} 个块,跳过索引")
return 0
# 分块
chunks = chunk_text(text, self.chunk_size, self.chunk_overlap)
print(f"📦 分块完成: {len(chunks)} 个块")
# 生成 embedding
print("🔄 生成向量...")
try:
embeddings = self.embed_model.encode(chunks).tolist()
except Exception as e:
raise Exception(f"❌ 向量生成失败: {str(e)}")
# 生成 ID
ids = [f"{doc_name}_chunk_{i}" for i in range(len(chunks))]
# 存入向量库
try:
self.collection.add(
embeddings=embeddings,
documents=chunks,
ids=ids,
metadatas=[{"source": doc_name} for _ in chunks]
)
except Exception as e:
raise Exception(f"❌ 向量存储失败: {str(e)}")
print(f"✅ 索引完成,总块数: {self.collection.count()}")
return len(chunks)
def retrieve(self, query: str, top_k: int = None) -> List[Tuple[str, float]]:
"""
检索相关文档块
返回: [(文档块, 相似度分数), ...]
参数:
query: 查询文本
top_k: 返回的相关文档块数量(默认为初始化时设置的值)
返回:
List[Tuple[str, float]]: 文档块和相似度分数的列表
异常:
ValueError: 当查询为空或 top_k 无效时
Exception: 当检索过程中发生错误时
"""
# 输入验证
if not query:
raise ValueError("查询文本不能为空")
# 使用默认值如果top_k为None
if top_k is None:
top_k = self.top_k
if top_k <= 0:
raise ValueError("top_k 必须大于 0")
try:
# 查询向量化
query_embedding = self.embed_model.encode([query]).tolist()
# 检索
results = self.collection.query(
query_embeddings=query_embedding,
n_results=top_k,
include=["documents", "distances"]
)
# 整理结果
retrieved = []
for doc, dist in zip(results["documents"][0], results["distances"][0]):
retrieved.append((doc, dist))
return retrieved
except Exception as e:
raise Exception(f"❌ 检索失败: {str(e)}")
def build_context(self, query: str, top_k: int = None) -> str:
"""
构建上下文字符串
参数:
query: 查询文本
top_k: 使用的相关文档块数量(默认为初始化时设置的值)
返回:
str: 构建好的上下文字符串
异常:
ValueError: 当查询为空时
Exception: 当构建上下文过程中发生错误时
"""
# 输入验证
if not query:
raise ValueError("查询文本不能为空")
# 使用默认值如果top_k为None
if top_k is None:
top_k = self.top_k
try:
results = self.retrieve(query, top_k)
context = "\n\n---\n\n".join([doc for doc, _ in results])
return context
except Exception as e:
raise Exception(f"❌ 上下文构建失败: {str(e)}")
def query(self, query: str, top_k: int = None) -> dict:
"""
执行完整查询:检索 + 返回结果
参数:
query: 查询文本
top_k: 返回的相关文档块数量(默认为初始化时设置的值)
返回:
dict: 查询结果,包含查询文本、上下文、距离和上下文文本
异常:
ValueError: 当查询为空时
Exception: 当查询过程中发生错误时
"""
# 输入验证
if not query:
raise ValueError("查询文本不能为空")
# 使用默认值如果top_k为None
if top_k is None:
top_k = self.top_k
try:
results = self.retrieve(query, top_k)
return {
"query": query,
"contexts": [doc for doc, _ in results],
"distances": [dist for _, dist in results],
"context_text": "\n\n---\n\n".join([doc for doc, _ in results])
}
except Exception as e:
raise Exception(f"❌ 查询失败: {str(e)}")
def clear(self):
"""
清空向量库
异常:
Exception: 当清空向量库过程中发生错误时
"""
try:
self.client.delete_collection(self.collection_name)
self.collection = self.client.get_or_create_collection(self.collection_name)
print("🗑️ 向量库已清空")
except Exception as e:
raise Exception(f"❌ 清空向量库失败: {str(e)}")
# ============================================================
# 4. 简单的 LLM 接口(可选)
# ============================================================
def generate_answer_with_context(query: str, context: str, model: str = "openai/gpt-5.4") -> str:
"""
使用上下文生成回答
通过 OpenRouter 调用大模型
参数:
query: 用户的问题
context: 用于回答问题的上下文信息
model: 使用的大语言模型名称(默认为 openai/gpt-5.4)
返回:
str: 大模型生成的回答
异常:
ValueError: 当 OPENROUTER_API_KEY 环境变量未设置时
Exception: 当调用大模型时发生错误时
"""
# 检查 API 密钥是否存在
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
raise ValueError("请设置 OPENROUTER_API_KEY 环境变量")
# 构建提示词模板
prompt = f"""基于以下上下文回答问题。如果上下文中没有相关信息,请说明。
上下文:
{context}
问题:{query}
回答:"""
with OpenRouter(api_key=api_key) as client:
response = client.chat.send(
model=model, # 使用函数参数传入的模型
messages=[
{"role": "user", "content": prompt}
]
)
return response.choices[0].message.content
# ============================================================
# 5. 主程序
# ============================================================
def main():
"""
主程序入口,演示 NaiveRAG 的基本功能
包括:初始化、索引文档、测试查询和交互式查询
"""
try:
# 初始化 RAG
rag = NaiveRAG()
# 索引文档
doc_path = os.path.join(os.path.dirname(__file__), "chatWithNavar-2.md")
if os.path.exists(doc_path):
rag.index_document(doc_path)
else:
print(f"❌ 文件不存在: {doc_path}")
return
except Exception as e:
print(f"❌ 初始化或索引失败: {str(e)}")
return
# 测试查询
print("\n" + "="*60)
print("🔍 测试查询")
print("="*60)
# queries = [
# "Gabriel Petersson 的学习方法是什么?",
# "什么是递归式知识填充?",
# "Unknown Unknowns 是什么意思?",
# "四象限学习框架有哪些?"
# ]
# for q in queries:
# try:
# print(f"\n📌 问题: {q}")
# print("-" * 40)
# result = rag.query(q, top_k=2)
# for i, (ctx, dist) in enumerate(zip(result["contexts"], result["distances"])):
# print(f"\n[相关块 {i+1}] (距离: {dist:.4f})")
# print(ctx[:200] + "..." if len(ctx) > 200 else ctx)
# print("\n" + "="*60)
# except Exception as e:
# print(f"❌ 查询失败: {str(e)}")
# print("\n" + "="*60)
# 交互式查询
print("\n🤖 进入交互模式 (输入 'quit' 退出)")
print("-" * 40)
while True:
try:
user_query = input("\n请输入问题: ").strip()
if user_query.lower() == 'quit':
break
if not user_query:
continue
result = rag.query(user_query, top_k=3)
print(f"\n📚 检索到 {len(result['contexts'])} 个相关块:")
print("-" * 40)
for i, (ctx, dist) in enumerate(zip(result["contexts"], result["distances"])):
print(f"\n[块 {i+1}] (相似度距离: {dist:.4f})")
print(ctx[:300] + "..." if len(ctx) > 300 else ctx)
# 调用大模型生成回答
print("\n🤖 AI 回答:")
print("-" * 40)
answer = generate_answer_with_context(user_query, result["context_text"])
print(answer)
except KeyboardInterrupt:
print("\n\n👋 再见!")
break
except Exception as e:
print(f"❌ 交互查询失败: {str(e)}")
print("请检查您的输入或系统配置后重试")
if __name__ == "__main__":
main()
参考资料:
- LangChain 文档:python.langchain.com/
- BGE-M3 模型:huggingface.co/BAAI/bge-m3
- ChromaDB 向量数据库:docs.trychroma.com/
(完)