Day 39 / 共 60 天 · 阶段6 记忆与知识

knowledge:把 PDF/CSV/文本喂给 Agent 的知识库

D38 的 RAG 是底座;今天在它上面搭"知识库"。knowledge/ 让你把 PDF、CSV、Excel、JSON、纯文本、字符串等静态资料预先灌进向量库,Agent 干活时能按查询检索相关片段。今天读三层:Knowledge(聚合多个知识源 + 一个存储)、BaseKnowledgeSource(各类源的共同基类,负责切块 chunk)、KnowledgeStorage(把 chunk 塞进 D38 的 ChromaDBClient)。还会看 source_type 字符串怎么被解析成具体的源类型。

📍 你在 60 天里的位置(阶段6 记忆与知识 · 共 8 天)
D33 记忆总览 D34 unified_memory D35 recall/encoding D36 短/长/entity D37 scope 作用域 D38 RAG D39 knowledge D40 embedding/存储
💡 先用一个类比兜住今天 知识库像给 Agent 配一本可以随时翻的参考书。你把整本书(PDF/CSV…)交给它,它先把书撕成一页页的小片段(chunk)、给每片拍张"语义照片"(嵌入)存进档案柜(向量库)。Agent 干活时遇到问题,就拿问题去档案柜里找最相关的几页塞进 prompt 参考。和记忆的区别:记忆是 Agent 自己写的日记(动态、会淡忘会合并),知识库是别人给的教科书(静态、你灌什么就是什么)。
L01

痛点:怎么让 Agent 用上"我的资料"

🤔 痛点大模型只知道训练时见过的通用知识,不知道你公司的内部文档、产品手册、私有数据。你想让 Agent 基于这些资料回答,但资料可能几百页——全塞进 prompt 既超窗口又烧钱。怎么让 Agent "需要哪段看哪段",而不是把整本书背下来?
💡 一句话本质 知识库 = RAG 的标准套路:入库时把资料切成 chunk → 嵌入 → 存向量库;用时把查询嵌入 → 搜出最相关的几个 chunk → 只把这几段塞进 prompt。knowledge/ 把"各种格式的资料怎么读、怎么切"抽象成 BaseKnowledgeSource,把"存哪、怎么搜"委托给 D38 的 RAG。你只管声明"我有哪些源",切块/嵌入/存/搜全自动。
L02

Knowledge:聚合多个源 + 一个存储

Knowledgeknowledge/knowledge.py:88)就是"一堆源 + 一个存储"的组合:

# knowledge/knowledge.py:88(节选)
class Knowledge(BaseModel):
    sources: Annotated[list[BaseKnowledgeSource], BeforeValidator(_resolve_knowledge_sources)] = ...
    storage: BaseKnowledgeStorage | None = Field(default=None)
    embedder: ... = None
    collection_name: str | None = None

    def __init__(self, collection_name, sources, embedder=None, storage=None, **data):
        super().__init__(**data)
        if storage is not None:
            self.storage = storage
        else:
            from crewai.knowledge.storage.factory import resolve_knowledge_storage
            custom = resolve_knowledge_storage(embedder, collection_name)   # 先问工厂
            self.storage = custom if custom is not None else KnowledgeStorage(
                embedder=embedder, collection_name=collection_name)         # 默认
        self.sources = sources

    def add_sources(self) -> None:
        for source in self.sources:
            source.storage = self.storage    # ★把存储注入每个源
            source.add()                     # ★每个源自己去切块+存

    def query(self, query, results_limit=5, score_threshold=0.6) -> list[SearchResult]:
        return self.storage.search(query, limit=results_limit, score_threshold=score_threshold)
sources + storage一个 Knowledge 管多个知识源(可以既有 PDF 又有字符串),共用一个存储(一个向量库集合)。
storage 工厂兜底没传 storage 就先问全局工厂 resolve_knowledge_storage(可插拔,和 memory 的 factory 同款模式),没命中再建默认 KnowledgeStorage
add_sources 注入 + 触发★把 storage 塞给每个源,再调 source.add()——具体怎么读文件、怎么切块由源自己实现,Knowledge 只负责编排。
query 直接转发检索就是转发给 storage.search(D38 的 RAG 检索),默认取 5 条、阈值 0.6。
数据结构:Knowledge = 多个源 + 一个存储 Knowledge PDFSource CSVSource StringSource KnowledgeStorage 每个源 chunk 后都存进同一个 storage(同一个向量库集合)
图注:一个 Knowledge 聚合多种源,共用一个 KnowledgeStorage(一个集合),检索时统一查这个集合。
L03

source_type:字典自动解析成具体源类型

sources 字段上挂了个 BeforeValidator,能把字典按 source_type 解析成对应类(knowledge/knowledge.py:23):

# knowledge/knowledge.py:23
_KNOWN_SOURCES: dict[str, type[BaseKnowledgeSource]] = {
    "string": StringKnowledgeSource, "docling": CrewDoclingSource,
    "csv": CSVKnowledgeSource, "excel": ExcelKnowledgeSource,
    "json": JSONKnowledgeSource, "pdf": PDFKnowledgeSource,
    "text_file": TextFileKnowledgeSource,
}

# knowledge/knowledge.py:34
def _resolve_knowledge_sources(value: Any) -> Any:
    if not isinstance(value, list): return value
    resolved = []
    for idx, item in enumerate(value):
        if isinstance(item, dict):
            tag = item.get("source_type")
            cls = _KNOWN_SOURCES.get(tag)            # 按 source_type 找类
            if cls is None:
                raise ValueError(f"Unknown source_type={tag!r} at index {idx}: "
                                 f"expected one of {sorted(_KNOWN_SOURCES)}")
            resolved.append(cls.model_validate(item))   # 用对应类校验/构造
        else:
            resolved.append(item)                    # 已是实例,原样
    return resolved
_KNOWN_SOURCES 注册表字符串标签 → 源类的映射表。七种内建源:string/pdf/csv/excel/json/text_file/docling。
BeforeValidator 解析让你能用 {"source_type":"pdf","file_paths":[...]} 这种纯字典(比如从 YAML 配置来)声明知识源,框架自动实例化成 PDFKnowledgeSource——配置友好。
未知标签报清晰错拼错 source_type 时直接列出所有合法值,不让你猜。
实例直接放行如果你传的已经是源实例(不是字典),原样保留——字典和实例两种写法都支持。
大白话这和 D39 我们见过的 memory 判别器、以及 D38 rag 后端选择是同一个套路:"用一个字符串标签在注册表里查出该用哪个类"。好处是配置可以纯用字典/YAML 写,代码里也不用一堆 if-else 判断类型。加新格式的源,只要往 _KNOWN_SOURCES 注册一行。
L04

BaseKnowledgeSource:所有源的公共"切块"逻辑

所有源的基类(knowledge/source/base_knowledge_source.py:16),核心是切块和保存:

# knowledge/source/base_knowledge_source.py:16(节选)
class BaseKnowledgeSource(BaseModel, ABC):
    chunk_size: int = 4000                    # 每块最多 4000 字符
    chunk_overlap: int = 200                  # 相邻块重叠 200 字符
    chunks: list[str] = Field(default_factory=list)
    chunk_embeddings: list[np.ndarray] = Field(default_factory=list, exclude=True)
    storage: BaseKnowledgeStorage | None = Field(default=None)

    @abstractmethod
    def validate_content(self) -> Any: ...    # 各源自己实现:怎么读
    @abstractmethod
    def add(self) -> None: ...                # 各源自己实现:读→切→存

    def _chunk_text(self, text: str) -> list[str]:
        return [text[i : i + self.chunk_size]                     # ★滑动窗口切块
                for i in range(0, len(text), self.chunk_size - self.chunk_overlap)]

    def _save_documents(self) -> None:
        if self.storage is not None:
            self.storage.save(self.chunks)    # 交给 KnowledgeStorage
        else:
            raise ValueError("No storage found to save documents.")
chunk_size=4000 / overlap=200每块 4000 字符,相邻块重叠 200 字符——重叠是为了防止把一句话/一个概念从中间切断,边界处两块都能覆盖到。
_chunk_text 滑窗★步长是 chunk_size - chunk_overlap = 3800:切一块 4000、往后跳 3800、再切 4000……于是每两块有 200 重叠。一行列表推导搞定。
抽象方法 validate/add基类不知道怎么读 PDF/CSV——把"读"留给子类实现,只提供公共的"切块 + 存"。模板方法模式。
_save_documents切好的 chunks 交给注入进来的 storage.save——源不关心存哪,存的事归 KnowledgeStorage。
⚠️ 边界:chunk_overlap ≥ chunk_size 会死循环 切块步长是 chunk_size - chunk_overlap。如果你手贱把 chunk_overlap 设得 ≥ chunk_size(比如 size=1000、overlap=1000),步长就是 0 甚至负数——range(0, len, 0) 直接 ValueError: range() arg 3 must not be zero,负步长则永远前进不了。源码没显式拦这个,所以务必保证 overlap 明显小于 size(默认 200 < 4000 是安全的)。这是配置类参数常见的"两个值有隐含大小关系"的坑。
L05

文件源加载三步:处理路径 → 校验 → 读入

最简单的 StringKnowledgeSourceknowledge/source/string_knowledge_source.py:8):

# knowledge/source/string_knowledge_source.py:8(节选)
class StringKnowledgeSource(BaseKnowledgeSource):
    source_type: Literal["string"] = "string"
    content: str = Field(...)
    def validate_content(self) -> None:
        if not isinstance(self.content, str):
            raise ValueError("StringKnowledgeSource only accepts string content")
    def add(self) -> None:
        new_chunks = self._chunk_text(self.content)   # 切
        self.chunks.extend(new_chunks)
        self._save_documents()                        # 存

文件类源走 BaseFileKnowledgeSourceknowledge/source/base_file_knowledge_source.py:44)三步初始化:

# knowledge/source/base_file_knowledge_source.py:44
def model_post_init(self, _: Any) -> None:
    self.safe_file_paths = self._process_file_paths()   # ① 规整路径(相对→知识目录下)
    self.validate_content()                             # ② 校验文件存在/是文件
    self.content = self.load_content()                  # ③ 抽象:各源读各自格式

# text_file_knowledge_source.py:12 —— 一个具体实现
def load_content(self) -> dict[Path, str]:
    content = {}
    for path in self.safe_file_paths:
        path = self.convert_to_path(path)
        with open(path, "r", encoding="utf-8") as f:
            content[path] = f.read()
    return content
StringSource 最简直接拿 content 切块存。是理解流程的最小例子:切 → extend → save,三行。
_process_file_paths把相对路径转成 knowledge/ 目录下的绝对路径(convert_to_path),还兼容废弃的单数 file_path 字段(发警告引导用 file_paths)。
validate_content 检查存在文件不存在直接 FileNotFoundError + 友好提示"把资料放进 knowledge 目录"——早失败、给明路。
load_content 抽象PDF 用 pdf 库读、CSV 用 csv 读、txt 直接 f.read()——各源实现自己的读法,读完统一是 dict[Path,str],后续切块一视同仁。
控制流:知识源入库流水线(add_sources) load_content 读 PDF/CSV/txt _chunk_text 4000/重叠200 storage.save KnowledgeStorage ChromaDBClient upsert(D38) 向量库 读取→切块→交存储→RAG upsert;嵌入在 ChromaDB 内部完成
图注:知识源入库 = 读原文 → 切块 → KnowledgeStorage.save → 落到 D38 的 ChromaDBClient.upsert。
L06

KnowledgeStorage:知识库对接 RAG 客户端

KnowledgeStorageknowledge/storage/knowledge_storage.py:22)是"知识库"和"D38 RAG 客户端"之间的适配器:

# knowledge/storage/knowledge_storage.py:37(节选)
@model_validator(mode="after")
def _init_client(self) -> Self:
    if self.embedder:
        embedding_function = build_embedder(self.embedder)     # D40 的嵌入工厂
        config = ChromaDBConfig(embedding_function=cast(..., embedding_function))
        self._client = create_client(config)                   # 造一个 ChromaDBClient
    return self

def _get_client(self) -> BaseClient:
    return self._client if self._client else get_rag_client()  # 实例专属 or 全局共享

def save(self, documents: list[str]) -> None:
    if not documents: return
    client = self._get_client()
    collection_name = f"knowledge_{self.collection_name}" if self.collection_name else "knowledge"
    client.get_or_create_collection(collection_name=collection_name)
    rag_documents: list[BaseRecord] = [{"content": doc} for doc in documents]   # chunk→BaseRecord
    client.add_documents(collection_name=collection_name, documents=rag_documents)
_init_client有自定义 embedder 就建一个实例专属的 ChromaDBClient(用这个嵌入模型);否则 _get_client 回退到全局共享 client。
collection_name 加前缀★集合名统一加 knowledge_ 前缀(knowledge_crew 等)——和别的用途的集合区分开,避免撞名(类比 memory 的 scope 隔离)。
chunk → BaseRecord把每个字符串 chunk 包成最简 {"content": doc}——D38 的 _prepare_documents_for_chromadb 会给它算内容哈希当 doc_id,重灌幂等。
search 同款search:59)多个查询串会 " ".join 成一句再搜,返回 D38 的 SearchResult。异常时记日志返回空列表而不抛——知识检索失败不该让 Agent 崩。
💡 设计取舍①:dimension mismatch 时为什么翻译成友好错误? save 的 except 里专门判 "dimension mismatch":124),把底层向量库的晦涩报错翻译成可操作的指引:"你可能混用了不同嵌入模型,试试 crewai reset-memories -a"。这和 memory 的 EmbeddingDimensionMismatchError(D33/D40)是同一种关怀:向量库最容易踩的坑就是"换了嵌入模型但没重建库",维度对不上。与其把底层库的原始异常直接抛给用户(看不懂),不如识别这个高频错、给出确切的修复命令。好错误信息 = 少一次求助。
L07

crew 集成与 embedder 的序列化

crew 用 knowledge_sources 一键建知识库(crew.py:675):

# crew.py:675(节选)
@model_validator(mode="after")
def create_crew_knowledge(self) -> Crew:
    if self.knowledge_sources:
        if isinstance(self.knowledge_sources, list) and all(
                isinstance(k, BaseKnowledgeSource) for k in self.knowledge_sources):
            self.knowledge = Knowledge(
                sources=self.knowledge_sources, embedder=self.embedder,
                collection_name="crew")
            self.knowledge.add_sources()      # ★建 crew 时就把资料灌进去
    return self

而 embedder 字段有个巧妙的序列化处理(knowledge/knowledge.py:69):

# knowledge/knowledge.py:69
def _serialize_embedder_spec(value: Any) -> dict[str, Any] | None:
    if value is None: return None
    if isinstance(value, BaseEmbeddingsProvider): return value.model_dump(mode="json")
    if isinstance(value, dict): return value
    if isinstance(value, type) and issubclass(value, BaseEmbeddingsProvider):
        raise TypeError(f"Cannot checkpoint embedder class ...: "     # ★类不能序列化
            "build_embedder requires an instance or ProviderSpec dict, not a class.")
    raise TypeError(f"Cannot serialize embedder of type {type(value).__name__}: ...")
create_crew_knowledge建 crew 时(model_validator after)若给了 knowledge_sources,就建 Knowledge(collection_name="crew")立刻 add_sources 灌库——资料在 kickoff 前就绪。
embedder 序列化器knowledge 能进检查点,embedder 得能序列化:None/provider 实例/dict 都能转成 JSON dict;但传"类"(而非实例)会明确报错——因为 build_embedder 要实例或 spec,类没法重建。
报错即指引又是"与其让它悄悄坏,不如立刻抛带修复建议的错"——让你把 provider 类换成实例。
agent 也能有自己的 knowledge_sources(agent 级知识库),crew 级和 agent 级独立,靠不同的 collection_name 分开——和 memory 用 scope 隔离异曲同工,只是这里用"集合名"这个更粗的粒度。
L08

取舍 + 今日小结

💡 设计取舍②:为什么固定按"字符数"切块,而不是按语义/句子切? _chunk_text 是最朴素的定长字符窗口 + 固定重叠,不看句子边界、不看段落。更"聪明"的做法是按句子/段落/语义边界切(如 docling 源就做结构化解析)。为什么基类用最笨的定长?因为它零依赖、对任何文本都适用、结果可预测——4000 字符一块就是一块,不需要分句模型、不会因为解析失败而崩。重叠 200 字符则廉价地缓解了"切断概念"的问题。把"够用的通用切法"放基类当默认,把"高级语义切块"留给专门的源(docling)——简单默认 + 高级可选,不为少数场景给所有源加负担。代价是定长切可能切断句子,但对多数 RAG 检索影响不大(嵌入仍能抓住大意 + 重叠兜底)。

👶 小白:知识库和记忆,Agent 实际用的时候有啥不一样?

👨‍🏫 老师:知识库是只读的参考资料——你灌进去,Agent 检索着用,它不会往里写。记忆是可读可写的经验——Agent 边干边记、还会合并更新。用途上:产品手册、公司制度、领域文档 → 知识库;"上次这个客户偏好 X""这个方案试过不行" → 记忆。检索机制上:知识库是纯 RAG(D38 距离转分 + 阈值),记忆是复合评分 + 自适应 Flow(D35)。一个是"查书",一个是"回忆"。

🧠 今天你应该能回答

  • 知识库和记忆有什么本质区别?各用于什么?
  • Knowledge 怎么聚合源和存储?add_sources 做了什么?
  • source_type 注册表怎么把字典变成具体源?
  • 切块的 size/overlap 怎么配合?滑窗步长是多少?
  • KnowledgeStorage 怎么对接 D38 的 RAG?集合名为什么加前缀?
  • 为什么基类用定长切块而不是语义切块?

✋ 10 分钟动手

P=lib/crewai/src/crewai/knowledge
sed -n '23,63p'   $P/knowledge.py                       # source_type 分派
sed -n '16,62p'   $P/source/base_knowledge_source.py    # 切块 + 保存
sed -n '105,137p' $P/storage/knowledge_storage.py       # save 对接 RAG
python -c "
from crewai.knowledge.source.string_knowledge_source import StringKnowledgeSource
s = StringKnowledgeSource(content='CrewAI 是多 Agent 编排框架。'*500, chunk_size=1000, chunk_overlap=100)
print('切成', len(s._chunk_text(s.content)), '块')
"
明日预告 · Day 40:记忆和知识库都要"把文字变向量"和"把向量存下来"。明天收官阶段6:读 rag/embeddings/ 的 provider 工厂(十几种嵌入模型怎么统一),和 LanceDBStorage 的存储细节(提交冲突重试、compaction、维度校验)。
← Day 38 RAG Day 40 · embedding/存储 →