跳到主要内容

知识库容器与双存储底座

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 cardlibrary.py:1286
CollectionRetrieval读文本块的统一入口,路由到具体 DBresources.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)。它做三件事:

  1. 算出这个库的磁盘根目录并铺开子目录。 根目录是 .../accounts/<account>/<library_name>,底下建 uploads/ images/ datasets/ nlp/ output/ tmp/ embedding/ 一整套(library.py:171-198)。uploads/ 就是原始文件副本的家——即"双存储"的磁盘那一半。

  2. 在数据库里登记一张 library card。 这是一条描述该库自身的元数据记录:嵌入状态、文档数、块数、页数、表数等。初始 entry 见 library.py:200-217,写入由 LibraryCatalog(...).create_new_library_card(...) 完成(library.py:220)。

  3. 为文本块建表并建全文索引。 若表还没建,就按 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:583library.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. 一套接口收编三种数据库

上层代码(LibraryLibraryCatalog)从不直接碰 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 的同名方法——lookupfilter_by_key_dictbasic_query 等一律薄薄一层代理(resources.py:110-186)。CollectionWriter(resources.py:189)是写侧的镜像,同样按 active_dbMongoWriter/PGWriter/SQLiteWriter(resources.py:209-221)。

这就是为什么上层"换库零改动":block_lookup 里写的是 CollectionRetrieval(...).filter_by_key_dict(...),它压根不知道背后是不是 SQLite。三个后端只要方法名和返回形状一致,就能互换。

三个后端与各自角色:

后端存储形态适合场景
SQLiteSQLiteRetrieval resources.py:1935本地单文件 DB(resources.py:3080)零依赖、单机、默认起步
MongoDBMongoRetrieval resources.py:551文档型集合已有 Mongo、灵活 schema
PostgresPGRetrieval 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
Postgrests 列上建一个 GIN 索引(CREATE INDEX ... USING GIN(ts))resources.py:1491
MongoDBtext_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:38Library
造库(铺目录 + 建卡 + 建索引)llmware/library.py:109create_new_library
加载已有库llmware/library.py:232load_library
加文件通用入口(前后做差统计)llmware/library.py:516add_files
读 library cardllmware/library.py:281get_library_card
叠加嵌入(向量,见 03)llmware/library.py:783install_new_embedding
按 (doc_id, block_id) 取块llmware/library.py:1256block_lookup
向前 / 向后扩上下文llmware/library.py:1210 / 1233expand_text_result_before / expand_text_result_after
library card 元数据 CRUDllmware/library.py:1286LibraryCatalog
card 读 / 建 / 改llmware/library.py:1338 / 1365 / 1381get_library_card / create_new_library_card / update_library_card
读文本块的门面 + 路由llmware/resources.py:64CollectionRetrieval
写文本块的门面(建表 / 建索引)llmware/resources.py:189CollectionWriter
build_text_index 门面转发llmware/resources.py:227CollectionWriter.build_text_index
SQLite 后端(读)llmware/resources.py:1935SQLiteRetrieval
MongoDB 后端(读)llmware/resources.py:551MongoRetrieval
Postgres 后端(读)llmware/resources.py:876PGRetrieval
SQLite FTS5 虚拟表建表llmware/resources.py:2612_build_sql_virtual_table_from_schema
SQLite build_text_index(空操作)llmware/resources.py:2588SQLiteWriter.build_text_index
Postgres GIN 索引llmware/resources.py:1491PGWriter.build_text_index
Mongo text 索引llmware/resources.py:337MongoWriter.build_text_index
SQLite 单文件连接llmware/resources.py:3080_SQLiteConnect.connect
原始文件拷入 uploads/llmware/parsers.py:415copy_files_to_library 分支
block 记录 schemallmware/configs.py:880LLMWareTableSchema._block
library card schemallmware/configs.py:911LLMWareTableSchema._library_card