1956 字
10 分钟
11 双存储架构:SQLite + ChromaDB 协作

🎯 一句话理解: ChromaDB 负责快速语义检索找碎片ID,SQLite 负责回查完整文档和上下文。各司其职,协作完成 RAG。

对应代码: dual_storage_demo.pymodels.py


📖 生活类比:图书馆系统#

组件类比作用
ChromaDB图书馆的索引卡片柜只记录”这本书讲什么主题”,快速找到相关书籍
SQLite图书馆的藏书仓库存放完整的书籍内容,供你阅读
Embedding给每本书贴上的主题标签把”这本书讲 Python”变成一串数字坐标

工作流程#

读者问:"有没有讲编程的书?"
查索引卡片(ChromaDB)→ 快速找到《Python入门》的卡片编号
去藏书仓库(SQLite)→ 根据编号取出完整书籍
把书的内容给读者

🤔 为什么需要双存储?#

ChromaDB 的局限性#

# 查询返回的只是碎片(80字),不是完整文档
results = collection.query(query_embeddings=[query_vec], n_results=3)
# → ["Python 是一种高级编程语言...", "Web 开发、数据分析...", "NumPy、Pandas..."]

问题

  • 碎片只有 80 字,信息不完整
  • 碎片之间没有上下文关联
  • 无法按时间、来源等条件过滤

SQLite 的局限性#

SELECT * FROM documents WHERE content LIKE '%Python%'

问题

  • 只能精确匹配关键词,不能语义搜索
  • 用户问”编程语言”找不到”Python”(因为没有精确包含这个词)

结论:两者互补#

需求用哪个原因
语义搜索(找意思相近的)ChromaDB向量相似度计算
查看完整文档SQLite存完整内容
按时间/来源过滤SQLite关系型查询
避免冗余存储两者协作ChromaDB 只存碎片

🏗️ 数据结构设计#

SQLite ORM 模型#

class Document(Base):
"""📚 完整文档(藏书仓库里的整本书)"""
__tablename__ = "documents"
id = Column(Integer, primary_key=True) # 自增ID
title = Column(String(200), nullable=False) # 文档标题
source = Column(String(500)) # 来源(文件路径/URL)
content = Column(Text, nullable=False) # 完整内容
created_at = Column(DateTime, default=datetime.now)
# 一本书有多个切片
chunks = relationship("DocumentChunk", back_populates="document",
cascade="all, delete-orphan")
class DocumentChunk(Base):
"""✂️ 文档切片(索引卡片)"""
__tablename__ = "document_chunks"
id = Column(Integer, primary_key=True)
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
chunk_index = Column(Integer, nullable=False) # 第几个切片(0, 1, 2...)
content = Column(Text, nullable=False) # 切片内容(80字左右)
embedding_id = Column(String(100), unique=True) # ⭐ 绳子①:对应 ChromaDB 的 ID
# 一个切片属于一本书
document = relationship("Document", back_populates="chunks")

ChromaDB Collection 结构#

Collection "document_chunks"
├── ids: ["doc1_chunk0", "doc1_chunk1", ...] ← 绳子①
├── embeddings: [[0.02, -0.01, ...], ...] ← 4096维向量
├── documents: ["Python 是一种高级...", ...] ← 碎片文本
└── metadatas: [ ← 绳子②
{"document_id": 1, "chunk_index": 0, "title": "Python..."},
...
]

🔗 两条”绳子”连接机制#

核心问题: ChromaDB 和 SQLite 是两个独立的数据库,它们怎么关联?

绳子①:embedding_id(字符串ID双向索引)#

SQLite document_chunks 表 ChromaDB Collection
┌──────────────────────────┐ ┌──────────────────────┐
│ embedding_id │ │ ids │
│ "doc1_chunk0" ──────────┼───→───│ "doc1_chunk0" │
│ "doc1_chunk1" ──────────┼───→───│ "doc1_chunk1" │
│ "doc2_chunk0" ──────────┼───→───│ "doc2_chunk0" │
└──────────────────────────┘ └──────────────────────┘

格式: "doc{文档ID}_chunk{碎片序号}" 用途: 精确匹配、删除操作

绳子②:metadatas(查询时直接返回关联信息)#

# ChromaDB 查询返回时,metadatas 直接告诉你去 SQLite 找谁
{
"metadatas": [[{
"document_id": 1, # ← 去 SQLite 查 documents.id = 1
"chunk_index": 0, # ← 去 SQLite 查 document_chunks.chunk_index = 0
"title": "Python编程语言介绍"
}]]
}

用途: 查询返回时快速定位 SQLite 数据,无需解析字符串

关键澄清#

metadatas 标签是手动构造的,不是通过 ID 去数据库查出来的!

# ✅ 标签来自已有的 Python 变量
chroma_metadatas.append({
"document_id": doc.id, # ← doc 对象早就有了
"chunk_index": idx, # ← 循环变量
"title": title # ← 函数参数
})
# ❌ 不是这样:
# metadatas = ChromaDB.去SQLite查(doc.id).拿到标题()

💻 完整代码流程#

存入阶段#

def add_document(title: str, content: str, source: str = ""):
db = SessionLocal()
try:
# 1. 存入 SQLite:完整文档
doc = Document(title=title, content=content, source=source)
db.add(doc)
db.commit() # 先提交,拿到 doc.id
db.refresh(doc) # 刷新,获取自增ID
# 2. 文本切片
chunks = split_text(content, chunk_size=80, overlap=10)
chroma_ids = []
chroma_embeddings = []
chroma_documents = []
chroma_metadatas = []
# 3. 逐片处理
for idx, chunk_text in enumerate(chunks):
# 3a. 存入 SQLite:切片信息
chunk = DocumentChunk(
document_id=doc.id,
chunk_index=idx,
content=chunk_text,
embedding_id=f"doc{doc.id}_chunk{idx}" # 绳子①
)
db.add(chunk)
# 3b. 生成向量
vec = get_embedding(chunk_text)
# 3c. 准备 ChromaDB 数据
chroma_ids.append(f"doc{doc.id}_chunk{idx}")
chroma_embeddings.append(vec)
chroma_documents.append(chunk_text)
chroma_metadatas.append({ # 绳子②
"document_id": doc.id,
"chunk_index": idx,
"title": title
})
# 4. 提交 SQLite 切片
db.commit()
# 5. 存入 ChromaDB:向量
collection.add(
ids=chroma_ids,
embeddings=chroma_embeddings,
documents=chroma_documents,
metadatas=chroma_metadatas
)
finally:
db.close()

查询阶段#

def semantic_search(query: str, n_results: int = 3):
# 1. 查询转成向量
query_vec = get_embedding(query)
# 2. ChromaDB 快速检索
chroma_results = collection.query(
query_embeddings=[query_vec],
n_results=n_results,
include=["metadatas", "distances"]
)
# 3. 解析结果 → 去 SQLite 回查完整信息
db = SessionLocal()
try:
for i in range(n_results):
metadata = chroma_results["metadatas"][0][i]
doc_id = metadata["document_id"] # 绳子②给的
chunk_idx = metadata["chunk_index"] # 绳子②给的
# 查 SQLite
chunk = db.query(DocumentChunk).filter(
DocumentChunk.document_id == doc_id,
DocumentChunk.chunk_index == chunk_idx
).first()
document = db.query(Document).filter(
Document.id == doc_id
).first()
finally:
db.close()

📊 数据流全景图#

存入时#

用户上传文档
┌─────────────────┐
│ 1. 存入 SQLite │ ← 完整文档(title, content, source)
│ Document 表 │
└─────────────────┘
┌─────────────────┐
│ 2. 文本切片 │ ← split_text() 切成 80 字片段(含重叠)
└─────────────────┘
┌─────────────────┐ ┌─────────────────┐
│ 3a. 切片存SQLite │ │ 3b. 向量存Chroma │
│ DocumentChunk │ │ collection.add │
│ (content,text) │ │ (embedding) │
└─────────────────┘ └─────────────────┘
│ │
└──────────┬────────────┘
用 embedding_id 关联
("doc1_chunk0")

查询时#

用户提问 "什么是Python?"
┌─────────────────┐
│ 1. 转 Embedding │ ← get_embedding(query) → 4096维向量
└─────────────────┘
┌─────────────────┐
│ 2. ChromaDB检索 │ ← 返回 metadatas(含 document_id, chunk_index)
│ (快速语义匹配) │
└─────────────────┘
┌─────────────────┐
│ 3. SQLite查详情 │ ← 根据 document_id 查 Document(完整文档)
│ (获取完整信息) │ ← 根据 chunk_index 查 DocumentChunk(碎片详情)
└─────────────────┘
┌─────────────────┐
│ 4. 拼Prompt给LLM │ ← "根据以下文档片段回答:[片段1][片段2]"
└─────────────────┘

📋 collection.add() 四参数速查表#

参数类型参与相似度计算?作用
idslist[str]唯一标识,绳子①连接 SQLite
embeddingslist[list[float]]唯一参与语义相似度计算(余弦距离)
documentslist[str]查询返回时展示给用户看
metadataslist[dict]绳子②,存 document_id 用于回查 SQLite

核心规则: 四个参数按数组下标一一对应

ids[0] ←→ embeddings[0] ←→ documents[0] ←→ metadatas[0] 属于同一个碎片
ids[1] ←→ embeddings[1] ←→ documents[1] ←→ metadatas[1] 属于同一个碎片

⚠️ 常见错误#

错误原因正确做法
ChromaDB 返回了碎片但 SQLite 查不到embedding_id 不一致确保两边用同样的 ID 格式
切片内容断裂chunk_size 太大或没有 overlap设置合理的 overlap(10-20%)
相似度都很低查询和文档主题完全不相关检查文档内容是否覆盖查询主题
删除文档时只删 SQLiteChromaDB 残留垃圾数据两边都要删
metadatas 放错字段把 document_id 当 ids 用metadatas 只是标签,不是主键
碎片太小不够回答只返回单个碎片取 N 个碎片 + 前后邻居拼接

📋 速查表#

双存储架构 = SQLite(关系型)+ ChromaDB(向量型)
SQLite 负责:
- 存完整文档(Document.title, .content, .source)
- 存切片内容(DocumentChunk.content)
- 存元数据和关联关系(外键、时间戳)
- 支持关系型查询(按时间、来源过滤)
ChromaDB 负责:
- 存向量(Embedding,4096维)
- 语义检索(余弦相似度计算)
- 快速返回相关 ID 和 metadatas
关联方式(两条绳子):
绳子①: embedding_id = "doc{doc_id}_chunk{chunk_idx}"(同名字符串)
绳子②: metadatas = {"document_id": id, "chunk_index": idx}(查询返回)

✅ 检查点#

  • 你能解释为什么需要双存储(而不是只用一个数据库)吗?
  • 你知道存入时数据怎么分流到两个数据库吗?
  • 你知道查询时两个数据库怎么协作吗?
  • 你能说出两条”绳子”分别在哪里、怎么用的吗?
  • 你理解 metadatas 标签是手动构造的,还是查出来的?
  • 你能修改 split_text()chunk_size 看看效果变化吗?

🚀 下一步#

  • FastAPI + Chroma 最小原型: 把双存储封装成 API 接口(POST /semantic-search
  • 手搓最小 RAG 闭环: 切片 → Embedding → 存储 → 检索 → 拼 Prompt → 调 LLM
  • 优化检索策略: 不只用单个碎片,取”碎片 + 前后邻居”增强上下文
11 双存储架构:SQLite + ChromaDB 协作
https://enkiud.com/posts/course-11/
作者
Enkidu
发布于
2026-01-11
许可协议
CC BY-NC-SA 4.0