forked from fangtianchen/algonotes_rag
8.4 KiB
8.4 KiB
🗄️ 三层存储详解
本文档详细描述 AlgoNotes RAG 的三层存储结构设计,包括文件层、关系层和向量层的职责、数据格式与关键设计决策。
📊 总览
graph LR
A["笔记.md"] --> B["FileStore"]
B -- 原始文件 --> C["📁 data/files/"]
B -- 读取内容 --> D["MarkdownHeaderTextSplitter"]
D -- 分块 + chunk_count --> E["SQLStore.insert()"]
E --> F["🗄️ data/sql_db/notes.db"]
D -- 分块 + note_id --> G["VectorStore.insert()"]
G --> H["🧬 data/chroma_db/"]
| 层级 | 存储后端 | 物理路径 | 存储内容 |
|---|---|---|---|
| 文件层 | 文件系统 | data/files/ |
原始 .md 笔记文件 |
| 关系层 | SQLite | data/sql_db/notes.db |
笔记元数据索引 |
| 向量层 | Chroma | data/chroma_db/ |
文本分块 + Embedding 向量 |
📄 文件层(FileStore)
职责
- 笔记原始.md 文件的保存、读取、更新、删除
- 文件系统操作的安全防护
文件平铺结构
data/files/
├── fenwick.md # 普通笔记
├── segment-tree.md
├── fenwick_20260616_143022.md # 文件名冲突时自动添加时间戳后缀
├── dijkstra.md
└── ...
所有笔记文件平铺在同一目录下,无子文件夹,文件名即唯一标识。
文件名冲突策略
当 save("fenwick.md") 时如果文件已存在,自动在 stem 后追加 _YYYYMMDD_HHMMSS 后缀:
fenwick.md (已存在)
↓ save("fenwick.md", new_content)
fenwick_20260616_143022.md (自动重命名后保存)
安全防护
_resolve() 方法对每个传入的文件名做路径穿越检测:
def _resolve(self, filename: str) -> Path:
path = (self._dir / filename).resolve()
if not str(path).startswith(str(self._dir.resolve())):
raise ValueError(f"Path traversal detected: {filename}")
return path
阻止 ../../etc/passwd 或绝对路径绕过。
API 一览
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
save(filename, content) |
文件名, 内容字符串 | str 绝对路径 |
保存新文件,冲突时自动重命名 |
update(filename, content) |
文件名, 内容字符串 | str 绝对路径 |
覆盖写入,不存在则创建 |
read(filename) |
文件名 | str | None |
读取内容,不存在返回 None |
delete(filename) |
文件名 | bool |
删除文件 |
list_files() |
— | list[str] |
列出所有文件名 |
exists(filename) |
文件名 | bool |
检查文件是否存在 |
compute_hash(filename) |
文件名 | str |
计算 SHA256 hex digest |
🗃️ 关系层(SQLStore)
职责
- 存储笔记的结构化元数据(文件名、标题、标签、时间戳等)
- 提供按条件检索笔记的能力
数据库配置
| 项目 | 值 |
|---|---|
| 数据库引擎 | SQLite 3 |
| 存储路径 | data/sql_db/notes.db |
| Journal 模式 | WAL(写不阻塞读) |
| Row 工厂 | sqlite3.Row(查询结果可转 dict) |
表结构
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL UNIQUE,
filepath TEXT NOT NULL,
title TEXT,
tags TEXT,
source_url TEXT,
type TEXT DEFAULT 'note',
author TEXT,
content_hash TEXT NOT NULL,
ingested_at TEXT NOT NULL,
updated_at TEXT,
chunk_count INTEGER DEFAULT 0,
file_size INTEGER
);
字段说明
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | 自增主键 |
filename |
TEXT | NOT NULL, UNIQUE | 原始文件名(如 fenwick.md) |
filepath |
TEXT | NOT NULL | 文件系统实际路径(含冲突后缀) |
title |
TEXT | — | 从 Markdown 提取的标题 |
tags |
TEXT | — | 逗号分隔的标签(如 "树状数组,模板") |
source_url |
TEXT | — | 来源 URL(从网页导入时) |
type |
TEXT | DEFAULT 'note' | 笔记类型:note/solution/template |
author |
TEXT | — | 笔记作者 |
content_hash |
TEXT | NOT NULL | SHA256 文件内容哈希 |
ingested_at |
TEXT | NOT NULL | 入库时间(YYYY-MM-DD HH:MM:SS) |
updated_at |
TEXT | — | 最后更新时间 |
chunk_count |
INTEGER | DEFAULT 0 | 分块数(向量化后回填) |
file_size |
INTEGER | — | 文件字节大小 |
内容哈希策略
- 哈希在
insert()和update()内部自动计算,调用方无需传入 - 通过
FileStore.compute_hash(filename)委托文件层读取 update()中如果更新了filepath,自动从相同filename重算哈希
关键设计决策
update()白名单机制:只允许更新{filepath, title, tags, source_url, chunk_count, file_size},防止意外修改核心字段- 动态 SET 子句:只更新实际传入的字段,而不是全字段覆盖
- 先查后改:
update()和delete()先检查记录是否存在,返回bool表示是否实际生效
API 一览
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
insert(filename, filepath, ...) |
文件名, 路径, 可选元数据 | int id |
插入新笔记记录 |
get(note_id) |
主键 ID | dict | None |
按 ID 查询 |
get_by_filename(filename) |
文件名 | dict | None |
按文件名查询 |
update(note_id, **fields) |
主键 ID, 字段键值 | bool |
更新指定字段 |
update_metadata(note_id, ...) |
主键 ID, 元数据字段 | bool |
轻量更新元数据(不重算 hash) |
delete(note_id) |
主键 ID | bool |
删除记录 |
list_all() |
— | list[dict] |
列出所有笔记(按入库时间倒序) |
search_by_tags(keyword) |
关键词 | list[dict] |
按标签模糊搜索(LIKE) |
close() |
— | — | 关闭数据库连接 |
标签搜索说明
tags 字段以逗号分隔的字符串存储,搜索使用 SQL LIKE:
SELECT * FROM notes WHERE tags LIKE '%并查集%';
MVP 折中:简单易实现,但无法做精确标签匹配。后续可改为 JSON 数组字段或关联表。
🧬 向量层(VectorStore)
职责
- 笔记文本分块的 Embedding 与存储
- 语义相似度检索
设计要点
| 项目 | 规划 |
|---|---|
| 向量库 | Chroma(langchain_chroma.Chroma) |
| 持久化 | data/chroma_db/(persist_directory) |
| Embedding 模型 | Qwen3-Embedding-4B via Gitee.AI |
| Collection 名称 | algonotes |
| Metadata 约束 | 仅支持 str / int / float / bool,必须扁平 |
分块策略对存储的影响
摄入管线使用 MarkdownHeaderTextSplitter 按标题层级分块,每个 Chunk 独立存入 Chroma,metadata 自动注入:
metadata = {
"source": filename, # str — 源文件名
"ingested_at": timestamp, # str — 入库时间
"chunk_index": i, # int — 块序号
"tags": "树状数组,数据结构", # str — 逗号分隔标签(可选)
"header_h1": "...", # str — 一级标题(如有)
"header_h2": "...", # str — 二级标题(如有)
}
Chroma 注意事项
- 持久化路径:统一使用相对路径
data/chroma_db/,由config.toml的store.chroma_dir指定 - 重启加载:
Chroma(persist_directory="./data/chroma_db")自动加载已有数据 - 维度一致性:更换 Embedding 模型时,必须先删除旧 collection 再重建,否则会因维度冲突报错
- 不可直接操作:禁止直接修改
chroma_db/目录内容,必须通过 Chroma API
🔗 跨层数据流
⚙️ 配置参考
[store]
chroma_dir = "data/chroma_db" # Chroma 持久化目录
files_dir = "data/files" # 原始笔记存储目录
sqlite_path = "data/sql_db/notes.db" # SQLite 数据库路径
三个路径均相对于项目根目录(或可配绝对路径)。
🔐 安全与边界
| 层级 | 防护措施 |
|---|---|
| 文件层 | _resolve() 路径穿越检测,阻止 ../ 逃逸 |
| 关系层 | update() 白名单字段,阻止修改 id/filename/ingested_at |
| 向量层 | 通过 Chroma API 操作,禁止直接修改目录 |