数据截至 (上游 commit 081bc084a5bc)
知识库容器与双存储底座
30 秒导读: LLMWare 里,你不是把文件"上传到某个向量库",而是先造一个
Library——一个有名字的知识库容器。它把每个文件拆成一条条文本块(block)写进一个集合数据库,同时把原始文件原样留在磁盘上。这一章只讲这个容器和它脚下的双存储底座;解析怎么拆、向量怎么建、检索怎么查,分别在 02 / 03 / 04。
本章是 index.md 之后的第一站。读完你应能回答:一个 Library 到底"装"了什么?文本和原始文件为什么要分开存?换 SQLite / MongoDB / Postgres 时,上层代码为什么一行都不用改?
1. 这是什么:Library = 一个有名字的知识库容器
一句话定义: Library 是 LLMWare 对"一批被索引好的非结构化资料"的封装——你给它起个名字,往里加文件,它就成了一个可查询、可嵌入、可喂给问答的知识库。
源码开头的类注释把这层意思说得很直白:library 是"从解析后的文件中抽取的文本、表格、图像的一个索引化集合"(llmware/library.py:38 class Library)。
给谁用、解决什么问题: 假设你有一个文件夹,里面是几百份 PDF 合同、财报、会议记录。你想让 AI 基于这些资料回答问题。你需要一个东西来:把文件读进来、切成小块、记住每块从哪来、之后还能按关键词或语义找回来。Library 就是这个"东西"。
用起来什么样: 最小的一段真实调用长这样。
from llmware.library import Library
# 造一个叫 "contracts" 的知识库容器(已存在则直接加载)
lib = Library().create_new_library("contracts")
# 把一个文件夹里的所有文件塞进去——自动按扩展名路由到对应解析器
lib.add_files("/path/to/my_documents")
# 之后就能在这个 library 上做检索 / 装嵌入 / 喂给 Prompt
# 示意,非源码调用序列
三行里 发生的事:第一行造容器并在数据库里登记一张"身份证"(见 §3 的 library card);第二行把文件解析成文本块写进数据库、把原文件拷进这个 library 的磁盘目录。
一句话直觉: 把 Library 想成一个带索引卡片的档案柜。柜子里每个抽屉是一条文本块,卡片(library card)记着"这个柜子有多少份文件、多少块、装没装嵌入";而原始的纸质文件,复印一份塞进柜子旁边的箱子里备查。
2. 顶层全景:一个容器,两处存储
Library 本身几乎不存数据——它是个协调者。真正的数据落在两个地方:结构化的文本块进数据库,笨重的原始文件进磁盘。
add_files("/docs")
│
▼
┌───────────────────┐
│ Library │ 协调者:持有 library_name + 一堆磁盘路径
│ (library.py:38) │
└─────────┬─────────┘
│
┌─────────────┴──────────────┐
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ 集合数据库 │ │ library 磁盘目录 │
│ (文本块 blocks) │ │ (原始文件 + 产物) │
├────────────────┤ ├──────────────────┤
│ 每块一行: │ │ uploads/ 原文件副本 │
│ doc_ID/block_ID │ │ images/ 抽出的图 │
│ text / table │ │ datasets/ 训练集 │
│ content_type … │ │ embedding/ 索引缓存 │
│ 全文索引 (FTS) │ │ nlp/ output/ tmp/ │
└────────┬───────┘ └──────────────────┘
│ 经由
▼
CollectionRetrieval / CollectionWriter ← DB 抽象层(resources.py)
│ 按配置路由到三选一
┌────┼─────────┐
▼ ▼ ▼
SQLite Mongo Postgres
怎么读这张图: 从上往下是一次 add_files 的落盘路径;左右两列是"双存储"的两半——左边数据库存能查的文本,右边磁盘存留底的文件。中间那层 CollectionRetrieval/CollectionWriter 是让上层不关心到底用哪个数据库的关键。
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
Library | 知识库容器 / 协调者,持有名字与磁盘路径 | library.py:38 |
LibraryCatalog | 管理每个 library 的"身份证"library card | library.py:1286 |
CollectionRetrieval | 读文本块的统一入口,路由到具体 DB | resources.py:64 |
CollectionWriter | 写文本块 / 建表 / 建索引的统一入口 | resources.py:189 |
SQLiteRetrieval 等 | 三个后端的具体实现 | resources.py:1935 / 551 / 876 |
| library 磁盘目录 | 存原始文件副本与各类产物 | library.py:171-198 |
3. Library 到底"持有"什么
Library 对象本身很轻:它主要持有一个名字和一组磁盘路径。构造时这些路径全是 None(library.py:49 __init__),真正被填上是在造库或加载库的时候。
create_new_library 是显式构造入口(library.py:109)。它做三件事:
-
算出这个库的磁盘根目录并铺开子目录。 根目录是
.../accounts/<account>/<library_name>,底下建uploads/ images/ datasets/ nlp/ output/ tmp/ embedding/一整套(library.py:171-198)。uploads/就是原始文件副本的家——即"双存储"的磁盘那一半。 -
在数据库里登记一张 library card。 这是一条描述该库自身的元数据记录:嵌入状态、文档数、块数、页数、表数等。初始 entry 见
library.py:200-217,写入由LibraryCatalog(...).create_new_library_card(...)完成(library.py:220)。 -
为文本块建表并建全文索引。 若表还没建,就按 block schema 建表(
library.py:222-225);随后CollectionWriter(...).build_text_index()建全文索引(library.py:228)。
load_library(library.py:232)是"已存在则重新挂上"的入口——它不碰数据,只是把那组磁盘路径重新拼回来(library.py:259-277),让对象重新指向已有的库。
library card:知识库的身份证
library card 是一条存在数据库 library 表里的元数据记录,一个 library 一张。它不是文本块,而是"这个库的仪表盘"。schema 见 configs.py:911 _library_card:
| 字段 | 含义 |
|---|---|
library_name | 库名(同时是主键) |
embedding | 嵌入状态列表(装了哪些嵌入、模型、维度) |
documents / blocks | 文档数 / 文本块数 |
images / pages / tables | 抽出的图 / 页 / 表计数 |
unique_doc_id | 自增 doc_id 计数器,发号用 |
account_name | 归属账户 |
读卡片走 get_library_card(library.py:281),它转手问 LibraryCatalog().get_library_card(...)(library.py:1338)。卡片的增删改由 LibraryCatalog 那几个方法负责(create library.py:1365 / update library.py:1381 / delete library.py:1395)。
这里有个值得记的设计:add_files 判断"这次加了多少内容",不是自己数,而是加文件前后各读一次 card,做差(library.py:583 与 library.py:604-615)。card 因此既是元数据,也是进度账本。
4. 双存储底座:为什么文本进 DB、文件留磁盘
这是本章的核心。LLMWare 刻意把一份资料拆成两份存:
- 结构化文本块 → 集合数据库。 每个文件被解析成许多"块",每块是数据库里的一行,带上正文、类型、坐标、来源等字段。这份是用来查的——支持全文检索、按 doc/block 定位、附加向量。
- 原始文件 → library 磁盘目录。 解析时原文件被
shutil.copy拷进该库的uploads/(parsers.py:415一带,受copy_files_to_library开关控制,默认开)。这份是用来留底的——需要看原文、重解析、给用户回链时用。
为什么要分? 一句话:两者的访问模式完全不同。 文本块要被高频、细粒度地查询和排序,天生适合数据库的行存与索引;原始 PDF/DOCX 是大块二进制,查询里从不需要"搜 PDF 的字节",只在偶尔要回看原文时整份取用,放文件系统最省事也最省数据库。把二进制塞进 DB 只会拖慢索引、撑大库。分开存让**"可查的轻"和"留底的重"各得其所**。
4.1 文本块长什么样:block 数据模型
一条 block 的字段由 configs.py:880 _block 定义。摘几个关键的:
| 字段 | 作用 |
|---|---|
_id | 物理主键(PRIMARY KEY (_id),Postgres 为 bigserial 自增) |
doc_ID / block_ID | 逻辑地址:哪个文档的第几块——所有定位都靠这一对 |
content_type | 内容类型(text / table / image …) |
text_block / table_block | 正文文本 / 表格文本(SQL 库里改名避开保留字,读出时映射回 text / table) |
master_index | 页码等主索引(block_lookup 会把它映射成 page_num) |
file_source | 原始文件名,回链磁盘那一半的钥匙 |
text_search | 供全文索引用的检索列 |
embedding_flags | 标记该块是否已嵌入(见 03) |
注意 block 数据模型的诞生属于解析章 02;这里只把它当"数据库里的一行记录"来看。
4.2 主键的两种含义:_id vs (doc_ID, block_ID)
容易混。schema 里物理 PRIMARY KEY 是 _id(configs.py:909)。但上层代码几乎从不用 _id 找块,而是用 (doc_ID, block_ID) 这一对作为稳定的逻辑地址。
看 block_lookup(library.py:1256):它构造的过滤器就是这两键(library.py:1263):
# llmware/library.py:1263 —— 真实源码,精简展示
kv_dict = {"doc_ID": doc_id, "block_ID": block_id}
output = CollectionRetrieval(self.library_name,
account_name=self.account_name).filter_by_key_dict(kv_dict)
为什么用这对而不是 _id?因为 block_ID 在一份文档内是连续递增的,这让"上下文扩展"变得平凡:要取某块前后的文本,只需把 block_ID 减一 / 加一再查。expand_text_result_before(library.py:1210)正是递减 block_ID 回溯、expand_text_result_after(library.py:1233)正是递增 block_ID 前探,各自累计到 window_size 字符为止。_id 是数据库自增值,没有这种"文档内相邻"语义,所以承担不了这个角色。
4.3 原始文件那一半
磁盘目录树在造库时就铺好(library.py:171-198)。文本块里的 file_source 字段记着原始文件名,uploads/ 里躺着同名副本——两者一对上,就能从"检索命中的某块"回到"它出自哪份原文件"。这就是双存储的对齐关系:DB 里的逻辑地址 + 磁盘里的物理留底,靠 file_source / doc_ID 缝合。
5. 一套接口收编三种数据库
上层代码(Library、LibraryCatalog)从不直接碰 SQLite/Mongo/Postgres。它们只跟两个门面类打交道:读走 CollectionRetrieval,写走 CollectionWriter。
5.1 门面 + 路由
CollectionRetrieval(resources.py:64)构造时读一次全局配置 active_db,据此在内部实例化对应后端,存进 self._retriever(resources.py:84-97):
# llmware/resources.py:84-97 —— 真实源码,精简展示
if self.active_db == "mongo":
self._retriever = MongoRetrieval(self.library_name, ...)
if self.active_db == "postgres":
self._retriever = PGRetrieval(self.library_name, ...)
if self.active_db == "sqlite":
self._retriever = SQLiteRetrieval(self.library_name, ...)
之后 CollectionRetrieval 的每个方法都只是转发给 self._retriever 的同名方法——lookup、filter_by_key_dict、basic_query 等一律薄薄一层代理(resources.py:110-186)。CollectionWriter(resources.py:189)是写侧的镜像,同样按 active_db 选 MongoWriter/PGWriter/SQLiteWriter(resources.py:209-221)。
这就是为什么上层"换库零改动":block_lookup 里写的是 CollectionRetrieval(...).filter_by_key_dict(...),它压根不知道背后是不是 SQLite。三个后端只要方法名和返回形状一致,就能互换。
三个后端与各自角色:
| 后端 | 类 | 存储形态 | 适合场景 |
|---|---|---|---|
| SQLite | SQLiteRetrieval resources.py:1935 | 本地单文件 DB(resources.py:3080) | 零依赖、单机、默认起步 |
| MongoDB | MongoRetrieval resources.py:551 | 文档型集合 | 已有 Mongo、灵活 schema |
| Postgres | PGRetrieval resources.py:876 | 关系表 | 生产级 SQL、强一致 |
5.2 build_text_index:同一个动词,三种实现
Library 造库时调一次 CollectionWriter(...).build_text_index()(library.py:228)。这句在门面层只是转发(resources.py:227),但三个后端对"建全文索引"的理解完全不同:
| 后端 | build_text_index 做什么 | 源码 |
|---|---|---|
| SQLite | 空操作——索引在建表时就随 FTS5 虚拟表一并生成 | resources.py:2588 |
| Postgres | 在 ts 列上建一个 GIN 索引(CREATE INDEX ... USING GIN(ts)) | resources.py:1491 |
| MongoDB | 在 text_search 字段上建 Mongo text 索引 | resources.py:337 |
SQLite 的"空操作"值得多看一眼:它建表时用的不是普通 CREATE TABLE,而是 CREATE VIRTUAL TABLE ... USING fts5(...)(resources.py:2612),把每个字段都塞进一张全文搜索虚拟表。所以全文检索能力在建表那一刻就有了,build_text_index 自然无事可做(resources.py:2590 的注释也这么说)。相对地,Postgres 走的是普通表 + 单独 GIN 索引的经典两步。
这 就是抽象层的价值: 上层一句"建索引",底层是空操作、是 GIN、还是 Mongo text 索引,由后端自己决定,Library 不必知道。全文检索怎么用这些索引,在 04 展开。
6. 一次 add_files 走一遍(把三节串起来)
add_files(library.py:516)是把文件变成知识库内容的通用入口——不管是 PDF、DOCX 还是文本,都从这里进。高层次上它就三步(解析细节属于 02,这里只看"存"):
add_files(folder)
│
1. 读一次 library card → 记下 before 计数 (library.py:583)
│
2. 交给 Parser.ingest():
│ ├─ 原始文件 shutil.copy 进 uploads/ (磁盘那一半)
│ └─ 每块 write_new_parsing_record 进 DB (数据库那一半)
│
3. 再读一次 library card → after 计数,做差得增量 (library.py:604-615)
▼
返回 {docs_added, blocks_added, images_added, pages_added, tables_added, rejected_files}
一次调用同时喂饱了双存储的两半:文本块进数据库、原文件进磁盘;而 library card 靠前后做差,记下这次到底加了多少。装嵌入是之后的独立动作——install_new_embedding(library.py:783)把某个模型的向量叠加到已有文本块上,属于 03 的主题,不在本章展开。
7. 巧妙之处
- 容器轻、存储重。
Library对象几乎不持有数据,只持名字与路径;真正的重量在 DB 和磁盘。这让"造库/加载库"极廉价,一个进程里可以并存很多 library。 - 逻辑地址 (doc_ID, block_ID) 优于物理主键。 用文档内连续的
block_ID当地址,让"取上下文"退化成加减一次再查(expand_text_result_before/after),无需额 外的相邻关系表。 - 门面 + 配置路由,换库零改动。
CollectionRetrieval/CollectionWriter把三种数据库收敛成一套方法签名(resources.py:64/189),上层永远只写门面调用。 - 同一个动词、后端各自实现。
build_text_index在 SQLite 是空操作、Postgres 是 GIN、Mongo 是 text 索引——差异被封在后端里,上层无感。 - card 既是元数据也是账本。 用"加文件前后读两次 card 做差"来统计增量(
library.py:604-615),省去在解析流程里另设计数通道。
8. 边界与局限
- 原文件入 DB?不。 二进制原文件只落磁盘
uploads/,数据库里只有文本块与file_source引用;若磁盘目录被清掉,回看原文的能力就丢了(文本块仍在)。 - 换后端不迁数据。 抽象层保证的是代码可换,不是数据自动搬家;切
active_db后已有的库不会跟着迁移。 - 文本块写入依赖解析。 本章把 block 当"已经存在的一行"看待;它是怎么切出来、字段怎么填的,全在 02——本章不涉及。
- 向量与语义检索不在此层。
embedding_flags只是块上的一个标记位;向量存哪、怎么建、怎么查分别是 03 / 04 的事。
9. 横向对比
同 shelf 的 RAG 项目在"知识库容器"这件事上取舍各异。LLMWare 的特点是把'容器抽象'和'存储后端'彻底分层:Library 只谈容器语义,resources.py 里的门面把 SQLite/Mongo/Postgres 收成一套接口。很多轻量 RAG 库则直接绑定单一向量库、不区分"文本索引"和"向量索引"。LLMWare 反其道:先有一个以文本块为中心、可全文检索的关系/文档型底座,向量只是叠加在其上的一层(见 03),这让它在没有向量库时也能纯文本检索。
10. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 知识库容器主类 | llmware/library.py:38 | Library |
| 造库(铺目录 + 建卡 + 建索引) | llmware/library.py:109 | create_new_library |
| 加载已有库 | llmware/library.py:232 | load_library |
| 加文件通用入口(前后做差统计) | llmware/library.py:516 | add_files |
| 读 library card | llmware/library.py:281 | get_library_card |
| 叠加嵌入(向量,见 03) | llmware/library.py:783 | install_new_embedding |
| 按 (doc_id, block_id) 取块 | llmware/library.py:1256 | block_lookup |
| 向前 / 向后扩上下文 | llmware/library.py:1210 / 1233 | expand_text_result_before / expand_text_result_after |
| library card 元数据 CRUD | llmware/library.py:1286 | LibraryCatalog |
| card 读 / 建 / 改 | llmware/library.py:1338 / 1365 / 1381 | get_library_card / create_new_library_card / update_library_card |
| 读文本块的门面 + 路由 | llmware/resources.py:64 | CollectionRetrieval |
| 写文本块的门面(建表 / 建索引) | llmware/resources.py:189 | CollectionWriter |
| build_text_index 门面转发 | llmware/resources.py:227 | CollectionWriter.build_text_index |
| SQLite 后端(读) | llmware/resources.py:1935 | SQLiteRetrieval |
| MongoDB 后端(读) | llmware/resources.py:551 | MongoRetrieval |
| Postgres 后端(读) | llmware/resources.py:876 | PGRetrieval |
| SQLite FTS5 虚拟表建表 | llmware/resources.py:2612 | _build_sql_virtual_table_from_schema |
| SQLite build_text_index(空操作) | llmware/resources.py:2588 | SQLiteWriter.build_text_index |
| Postgres GIN 索引 | llmware/resources.py:1491 | PGWriter.build_text_index |
| Mongo text 索引 | llmware/resources.py:337 | MongoWriter.build_text_index |
| SQLite 单文件连接 | llmware/resources.py:3080 | _SQLiteConnect.connect |
| 原始文件拷入 uploads/ | llmware/parsers.py:415 | copy_files_to_library 分支 |
| block 记录 schema | llmware/configs.py:880 | LLMWareTableSchema._block |
| library card schema | llmware/configs.py:911 | LLMWareTableSchema._library_card |