
做 LLM 应用落地时很多人第一反应是上重型数据库MySQL、PostgreSQL甚至顺手就把 Redis、向量数据库全搭起来。但真到了私有化部署、本地知识库、个人助手这种场景你会发现光环境搭建就能耗掉你半天时间更别提后续的迁移、备份和运维。我最近在做一个基于 LLM 的 Wiki 知识库项目最终的存储方案选的是文件型数据库 SQLite跑完整个流程之后最大的感受是轻量级方案被严重低估了。这篇文章就把我在项目中踩过的坑、验证过的表结构设计、以及从 MySQL 切到 SQLite 的完整过程全部摊开讲。1. 为什么是 SQLite大模型应用的数据底座选型思路1.1 LLM 应用落地时数据到底存在哪里先聊一个被很多人忽略的问题大模型应用本身是“无状态”的模型不管你之前聊了什么、上传了什么文档、生成了哪些内容它的每一次推理都只认当前 Prompt。但一个真正能落地的 LLM 应用恰恰需要大量的“状态数据”来支撑知识库原始文档和分块后的文本片段这是 RAG 检索的基础Embedding 向量不管是存在独立字段还是单独的表都需要持久化用户会话历史和 Token 消耗记录用于多轮对话、计费和审计异步任务状态比如文档解析进度、向量化任务是否完成系统配置和元数据比如当前用的模型名、Prompt 模板版本、知识库索引状态。这些数据有个共同特点单机规模不大、结构相对明确、查询模式简单。拿我做的 LLM Wiki 知识库来说存量文档大概 2000 篇分块后约 3 万条Embedding 向量按 768 维计算总存储量也就几百 MB。这种量级用 MySQL 不是不行但为了这几百 MB 的数据去维护一个数据库服务、处理账号权限、做备份恢复属于典型的杀鸡用牛刀。SQLite 在这里的优势是它是一个文件不需要独立的服务进程你的应用进程直接读写这个文件。部署时只需要确保这个文件存在迁移时把这个文件拷走就行。配合 LLM 应用中常见的“本地优先”需求这种文件型存储几乎是最省心的底座。1.2 SQLite 相比 MySQL、Redis、向量数据库的定位差异很多开发者对 SQLite 的印象停留在“手机上的小数据库”或者“玩具项目才用”但实际上它在服务端的适用性远比想象中广。我用一个实际对比来说明维度SQLiteMySQL/PostgreSQLRedis专用向量数据库部署方式嵌入式无独立服务独立服务需要安装配置独立服务需要内存规划独立服务通常需要容器化数据规模适合单机 GB 级以内适合海量数据、高并发适合缓存、热数据适合百万级以上向量备份迁移直接拷贝文件mysqldump / binlogRDB / AOF通常有专属备份工具事务能力ACID 完整支持ACID 完整支持有限视产品而定运维成本几乎为零需要监控、账号、权限管理需要内存、淘汰策略管理需要了解索引参数、容量规划从 LLM 应用的角度看真正需要 Redis 的场景是你要扛高并发实时问答需要把热点上下文缓存到内存里真正需要专用向量数据库的场景是你有千万级向量需要按相似度做实时检索。但个人知识库、企业内部工具、私有化部署的助手类应用并发量通常就是个位数到几十数据量在百万级 chunk 以内SQLite 完全扛得住而且向量检索也有办法做后面详细讲。还有一个很实际的考量LLM 应用经常要打包交付给客户客户环境可能是 Windows、Linux 或者国产化服务器你不可能要求对方先装好一套 MySQL 再跑你的应用。SQLite 只要带上一个 .db 文件放哪都能跑这个特性在 ToB 交付场景里是硬通货。1.3 什么场景严格别用 SQLite这话也得说清楚SQLite 不是万能的。我见过有人硬把高并发写入场景塞给 SQLite结果天天报 database is locked。如果你遇到以下情况请果断换 PostgreSQL 或者 MySQL大量并发写入比如每秒几十次以上的写入请求多个进程同时写同一个库分布式部署多个节点需要共享同一份实时数据SQLite 的文件锁机制跨不了机器横向扩容需求数据量快速增长单文件超过几十 GB 后备份和查询都会有压力复杂 SQL 依赖比如需要同时跑多个联结查询和窗口函数SQLite 语法支持有限。一句话总结SQLite 适合“单机、中小数据量、读多写少、部署轻量”的 LLM 应用它解决的是“快速落地”和“省心运维”的问题而不是“无限扩容”的问题。2. 核心表结构设计从文档到向量的完整链路2.1 文档表、Chunk 表、Embedding 表的字段设计LLM Wiki 知识库的核心链路是文档入库 → 文本分块 → 向量化 → 检索召回。所以表结构至少要覆盖这四个环节。我最终的建表 SQL 如下你可以直接抄-- 文档表存原始文件的元信息 CREATE TABLE IF NOT EXISTS documents ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, source_path TEXT NOT NULL, file_type TEXT DEFAULT md, doc_meta TEXT DEFAULT {}, -- JSON 格式扩展字段 created_at TEXT DEFAULT (datetime(now, localtime)), updated_at TEXT DEFAULT (datetime(now, localtime)) ); -- 分块表存切好的文本块 CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY AUTOINCREMENT, doc_id INTEGER NOT NULL, chunk_index INTEGER NOT NULL, content TEXT NOT NULL, token_count INTEGER DEFAULT 0, content_hash TEXT UNIQUE, -- 去重用 FOREIGN KEY (doc_id) REFERENCES documents(id) ON DELETE CASCADE ); -- 向量表存每个 chunk 对应的 embedding CREATE TABLE IF NOT EXISTS embeddings ( id INTEGER PRIMARY KEY AUTOINCREMENT, chunk_id INTEGER NOT NULL UNIQUE, model_name TEXT NOT NULL, -- 记录是哪个模型生成的向量 dimension INTEGER NOT NULL, vector BLOB NOT NULL, -- 二进制存储占用小、读取快 created_at TEXT DEFAULT (datetime(now, localtime)), FOREIGN KEY (chunk_id) REFERENCES chunks(id) ON DELETE CASCADE ); -- 会话表存对话历史用于多轮检索和多轮问答 CREATE TABLE IF NOT EXISTS conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, -- user / assistant content TEXT NOT NULL, key_info TEXT DEFAULT {}, -- 会话主题、用户标识等 tokens_used INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime(now, localtime)) ); CREATE INDEX IF NOT EXISTS idx_chunks_doc_id ON chunks(doc_id); CREATE INDEX IF NOT EXISTS idx_conversations_session ON conversations(session_id, created_at);几个字段的设计思路我展开讲。embedding.vector我用 BLOB 而不是 TEXT。很多教程让你把向量直接存成 JSON 字符串方便调试但实际跑起来你会发现768 维的 float 向量转成 JSON 后一个向量轻松超过 3KB磁盘占用翻了四倍以上而且 JSON 解析本身有 CPU 开销。BLOB 存储配合 Python 的struct或者numpy.ndarray.tobytes()读写大小只有原始数据大小性能也更好。调试时你可以写一个函数把 BLOB 转成 JSON 临时看不要为了调试牺牲线上性能。chunks.content_hash是很多初做知识库的人容易忽略的字段。文档重复导入、定时同步时如果每次都重新分块重新向量化浪费 Token 是小事更重要的是会产生大量重复内容影响检索质量。用 content_hash 做 UNIQUE 约束入库前先算一下哈希能直接跳过已经存在的 chunk实测下来重复导入场景的入库时间缩短了 70% 以上。2.2 Token 三点模型key、query、value 如何影响存储设计最近网上有个很形象的说法LLM 的 Token 本质上是三个点——key我是谁、query我在找什么、value我能提供什么。这个模型对存储设计其实很有指导意义。key 对应的是会话、用户、主题等元数据决定“这段数据归属在哪个上下文”query 对应的是用户输入的问题、检索请求决定“需要从存储里找回什么”value 对应的是模型生成的答案、知识库原文、工具返回的结果决定“最终能提供什么内容”。落到表结构上conversations表的session_id和key_info就是 key 的落地多轮对话时你要按 session 维度把历史记录捞出来组装 Prompt用户当前的问题就是 query你要通过全文检索或向量检索去chunks.content和embeddings.vector里找匹配项模型生成的回答、检索到的原文片段就是 value要完整存下来供后续审计和微调用。这个模型最实用的地方在于当你设计存储时可以先问自己“这条数据属于 key、query 还是 value”然后决定它该进哪张表、该建什么索引。比如我早期把用户画像直接塞进对话内容字段导致查询时全文索引把大量无意义词召回检索质量明显下降——后来单独抽了 key_info 字段存用户标签问题直接解决。2.3 向量检索的几种 SQLite 实现方式SQLite 原生没有向量索引但 LLM 知识库又离不开相似度检索怎么解决我实测下来有三条路第一种是使用sqlite-vec扩展。这是 Alex Garcia 开源的 SQLite 向量搜索插件安装后在 SQLite 里直接创建虚拟表vec0支持 top-K 相似度检索。用法大致如下CREATE VIRTUAL TABLE vec_docs USING vec0( chunk_id INTEGER PRIMARY KEY, embedding FLOAT[768] ); -- 查询 top 10 相似 chunk SELECT chunk_id, distance FROM vec_docs WHERE embedding MATCH ? ORDER BY distance LIMIT 10;注意vec0表存的是向量本身原始文本还是要存在chunks表里查到chunk_id后再回表取内容。这种方案性能最好百万级向量也能在几十毫秒内返回但要求你编译安装扩展对部分部署环境不太友好。第二种是纯 Python 计算余弦相似度。把向量从数据库读出来用 NumPy 批量算相似度矩阵适合数据量在几万条以内的场景。代码很简单import sqlite3 import numpy as np conn sqlite3.connect(wiki.db) # 读取所有 chunk_id 和向量 BLOB rows conn.execute(SELECT chunk_id, vector FROM embeddings).fetchall() ids [r[0] for r in rows] vectors np.array([np.frombuffer(r[1], dtypenp.float32) for r in rows]) # query_vector 由同一个 embedding 模型生成 scores vectors query_vector / (np.linalg.norm(vectors, axis1) * np.linalg.norm(query_vector)) top_indices scores.argsort()[-10:][::-1]实测 3 万条向量、768 维单次检索大约 150ms 到 300ms对知识库问答场景完全够用。好处是零额外扩展、跨平台直接跑缺点是不能实时增量检索新向量入库后需要重新读取但你可以在写入时维护一个内存向量列表写一次更新一次。第三种是结合 FTS5 全文索引做混合检索。SQLite 内置的 FTS5 扩展支持全文检索把content字段建全文索引后用MATCH做关键词匹配。RAG 场景里纯粹向量检索对专有名词、人名、编号容易失手混合检索用 BM25 做关键词召回再用向量排序融合效果明显更好。FTS5 建表方式CREATE VIRTUAL TABLE chunks_fts USING fts5(content, contentchunks, content_rowidid);保持chunks表和 FTS 索引同步可以在chunks表上建触发器或者在全量导入后手动执行INSERT INTO chunks_fts(chunks_fts) VALUES(rebuild)。3. 实操从零搭建一个 LLM Wiki 知识库3.1 建库建表PRAGMA 参数调优是关键我自己最开始建库的时候直接sqlite3 wiki.db然后就把表建了结果跑了几天发现两个问题一是并发读写经常锁库二是数据量稍大后写磁盘特别慢。后来仔细看了 SQLite 官方文档才发现PRAGMA 参数没调。建完表后强烈建议立即执行这几条 PRAGMAPRAGMA journal_mode WAL; PRAGMA synchronous NORMAL; PRAGMA busy_timeout 5000; PRAGMA wal_autocheckpoint 1000; PRAGMA cache_size -64000; -- 约 64MB 缓存逐条解释。journal_mode WAL是 SQLite 应对并发读写的核心手段。默认的 rollback journal 模式下写事务会阻塞所有读操作改成 WALWrite-Ahead Logging模式后写操作先追加到-wal文件读操作可以继续读主库文件读写并发能力大幅提升。这也是解决database is locked的第一把钥匙。synchronous NORMAL能在 WAL 模式下把每次提交的磁盘 fsync 次数降下来。WAL 模式下的NORMAL安全性依然很高断电最多丢最近几次事务不会损坏主库文件。对知识库这种非金融级数据性能收益明显。busy_timeout 5000是给锁冲突设置一个等待窗口。当多个连接同时写同一个库时后到的连接会等待而不是立即报错。配合 WAL实际项目里我没再遇到database is locked。但要提醒这个参数是“缓解”不是“根治”如果写入量大到 WAL 文件持续膨胀还是要从应用层做写入队列。3.2 在宝塔面板安装 SQLite 及可视化工具 DB4S很多人问“宝塔面板怎么安装 SQLite”这里有个认知误区SQLite 不是独立服务它在绝大多数情况下是作为“库”被 PHP、Python、Node.js 调用的。宝塔面板的 PHP 环境默认就启用了 pdo_sqlite 和 sqlite3 扩展Python 环境里sqlite3是标准库不需要额外安装Node.js 里安装better-sqlite3或node:sqlite模块即可。如果你需要在服务器上直接敲sqlite3命令来做日常管理那按系统包管理器装一下就行。Debian/Ubuntuapt update apt install sqlite3验证安装sqlite3 --version如果需要可视化管理推荐 DB4SDB Browser for SQLite开源、跨平台Windows/Linux/macOS 都有安装包。它能直接打开.db、.sqlite、.sqlite3文件查看表结构、执行 SQL、导入导出数据比命令行直观太多。在服务器没图形界面的时候你可以把 .db 文件下载到本地用 DB4S 打开改完再传回去比在命令行里折腾方便很多。还有一个方便的技巧用宝塔面板的文件管理功能可以直接双击下载 .db 文件到本地配合 DB4S 进行远程调试不用额外装 phpMyAdmin 类工具。3.3 文档入库与分块的完整流程库表建好之后真正的核心步骤是把文档切开、向量化、写库。这里我给出一个简化但完整的 Python 流程可以直接跑import sqlite3 import hashlib import json from pathlib import Path conn sqlite3.connect(wiki.db) conn.execute(PRAGMA journal_modeWAL) def split_text(text, chunk_size500, overlap50): 简单的滑动窗口分块实际项目可以用语义分块 chunks [] for i in range(0, len(text), chunk_size - overlap): chunks.append(text[i:i chunk_size]) return chunks def add_document(file_path, titleNone): path Path(file_path) text path.read_text(encodingutf-8) title title or path.stem # 1. 插入文档表 cur conn.execute( INSERT INTO documents(title, source_path, file_type) VALUES (?,?,?), (title, str(path), path.suffix.lstrip(.)) ) doc_id cur.lastrowid # 2. 分块并插入 for idx, chunk in enumerate(split_text(text)): content_hash hashlib.sha256(chunk.encode(utf-8)).hexdigest() # 检查重复 existed conn.execute( SELECT id FROM chunks WHERE content_hash?, (content_hash,) ).fetchone() if existed: continue conn.execute( INSERT INTO chunks(doc_id, chunk_index, content, content_hash) VALUES (?,?,?,?), (doc_id, idx, chunk, content_hash) ) conn.commit() return doc_id add_document(docs/llm_guide.md)这段代码里有几个细节要注意。分块参数chunk_size和overlap不是拍脑袋定的。过小的 chunk 会导致检索时上下文不完整过大的 chunk 会超出模型的 Token 上限。我实测下来中文场景chunk_size500字符约 250 Token 左右加 overlap 后约 280 Token是检索质量和 Token 消耗的平衡点英文场景可以适当加大到 800-1000 字符。overlap 的作用是把被切断的句子尽可能恢复出来一般设为 chunk_size 的 10%-20% 就行。上面为了简洁没有写向量化的步骤实际项目中你应该在插入 chunk 后调用你的 Embedding 模型生成向量然后将 BLOB 写入embeddings表。向量模型要与检索时用的模型保持一致否则余弦相似度毫无意义。这也是很多工程出 bug 的根源入库用 text-embedding-v3检索时换成了另一个模型召回率惨不忍睹。3.4 从 MySQL 迁移到 SQLite 的注意事项很多团队早期用 MySQL 存知识库数据后面想切到 SQLite 做私有化部署。这个迁移我有发言权踩过不少坑。最直接的办法是用 DB4S 的导入功能从 MySQL 导出 CSV再用 DB4S 导入 SQLite。但这样做有个致命问题自增主键和类型映射容易出错。MySQL 的INT AUTO_INCREMENT对应 SQLite 的INTEGER PRIMARY KEY AUTOINCREMENT后者会自动处理但如果你导出的 CSV 里带了自增主键的值插入时非常容易出现主键冲突。更稳的做法是分两步走。第一步用 Python 同时连 MySQL 和 SQLite逐表读取数据并写入import sqlite3 import mysql.connector mysql_conn mysql.connector.connect(hostlocalhost, userroot, passwordxxx, databasellm_wiki) sqlite_conn sqlite3.connect(wiki.db) tables [documents, chunks, embeddings, conversations] for table in tables: rows mysql_conn.cursor(dictionaryTrue).fetchall() for row in rows: # 根据目标表动态生成 INSERT 语句 placeholders ,.join([?] * len(row)) columns ,.join(row.keys()) sqlite_conn.execute( fINSERT INTO {table} ({columns}) VALUES ({placeholders}), list(row.values()) ) sqlite_conn.commit()第二步检查documents表里id是否与chunks.doc_id对应上。MySQL 迁移最常见的问题是自增主键重置后外键关联对不上。SQLite 的外键默认不强制开启你需要在连接时执行PRAGMA foreign_keys ON这样插入时就能立刻发现关联错误。还有一个类型映射细节MySQL 的DATETIME迁移到 SQLite 建议直接用 TEXT 类型存储 ISO 格式字符串查询时用datetime()函数处理MySQL 的TINYINT(1)对应 SQLite 的 INTEGER 0/1。不要在 SQLite 里试图用DATETIME这种类型名SQLite 最终都识别为 NUMERIC排序和比较反而不如 TEXT 稳定。4. 常见问题与排查技巧实录4.1 database is locked 到底怎么解决这个错误是我在项目初期最头疼的问题。排查思路要从三个层面看应用层面检查是否有多线程/多进程同时写同一个数据库文件。SQLite 的锁粒度是文件级别的两个连接同时写必然有一个要等。解决方案是在应用层做一个单例写入队列或者使用better-sqlite3Node.js这类同步库避免异步交叉写。配置层面确认是否开启了 WAL 模式和 busy_timeout。有的人开了 WAL 但仍然报锁多半是 busy_timeout 没设默认是 0 秒遇到锁立即报错。设置为 5000ms 后等待 5 秒内如果锁释放就能正常写入。设计层面检查事务是否过大或过长。比如一次事务插入 10 万条 chunk事务持续十几秒其他写连接全被堵住。建议分批提交每 500-1000 条提交一次同时避免在一个事务里同时写多个表。4.2 SQLite 文件用什么工具打开这个问题几乎每周都有人问。除了前面说的 DB4S还有几类工具值得推荐VS Code 装 SQLite Viewer 或 SQLite Explorer 插件边写代码边看数据最方便DBeaver 社区版功能接近 Navicat支持连接 SQLite 文件适合习惯数据库 IDE 的人Python 环境跑一句sqlite3命令行适合服务器上快速查看宝塔面板用户可以把文件下载到本地用 DB4S或者直接用面板里的“文件编辑”功能直接看文本内容仅限非二进制内容。打开 .db 文件的通用原则复制一份再打开不要直接拿生产库去各种工具里乱试。有些工具写入时自动改 PRAGMA 参数可能导致线上库异常。我的习惯是先把.db文件拷贝到临时目录改完确认没问题再覆盖回去。4.3 C# / VB.NET 如何打开和操作 SQLite搜索热词里频繁出现 C# 和 VB.NET 打开 SQLite 数据库说明桌面端应用对接 LLM 知识库的需求很常见。在 .NET 生态里推荐使用Microsoft.Data.Sqlite它是微软官方维护的 SQLite 驱动跨平台且性能稳定。安装方式dotnet add package Microsoft.Data.SqliteC# 打开并查询的例子using Microsoft.Data.Sqlite; var connectionString Data Sourcewiki.db;ModeReadWriteCreate; using var connection new SqliteConnection(connectionString); connection.Open(); var command connection.CreateCommand(); command.CommandText SELECT id, title FROM documents WHERE title LIKE pattern; command.Parameters.AddWithValue(pattern, %知识库%); using var reader command.ExecuteReader(); while (reader.Read()) { Console.WriteLine(${reader.GetInt32(0)}: {reader.GetString(1)}); }VB.NET 的写法大同小异连接串和命令对象都是一样的只是语法改成 VB 风格Using conn As New SqliteConnection(Data Sourcewiki.db) conn.Open() Dim cmd As SqliteCommand conn.CreateCommand() cmd.CommandText SELECT id, title FROM documents WHERE title LIKE pattern cmd.Parameters.AddWithValue(pattern, %知识库%) Dim reader As SqliteDataReader cmd.ExecuteReader() While reader.Read() Console.WriteLine(reader(title).ToString()) End While End Using还有一个容易踩坑的点如果目标机器是 32 位系统必须确保引用的 SQLite 原生库是 x86 版本否则启动时会报“无法加载 DLL”。用 Microsoft.Data.Sqlite 时NuGet 包会自动包含对应运行库但还是要在发布前用 32 位环境实测一遍。4.4 ONNX 部署 LLM 模型时SQLite 的落地配合最近很火的 ONNX 部署 LLM 场景其实也绕不开存储问题。ONNX Runtime 只负责模型推理不负责状态管理。你用 ONNX 部署一个本地问答模型时依然需要把知识库问题、模型输入输出、Token 统计存起来。SQLite 在这里特别合适因为它不需要额外的推理服务依赖纯本地应用里几行代码就把数据落盘。我的一个实操经验是在 ONNX 推理进程里把每个请求的输入文本、模型输出、推理耗时、Token 数写入conversations表。这样后续做 Prompt 优化、模型效果分析时可以直接用 SQL 统计不同 Prompt 模板的平均耗时和输出长度不用自己造日志系统。这个做法对调优帮助特别大。另外一个建议是如果在 ONNX 模型里做 embedding可以在本地用 SQLite 做一个小型缓存库输入文本做哈希命中的直接返回不重复推理。实测在常见问题上命中率能达到 30%-50%推理时间显著下降而这个缓存的实现只需要一张表CREATE TABLE IF NOT EXISTS embed_cache ( text_hash TEXT PRIMARY KEY, text TEXT NOT NULL, vector BLOB NOT NULL, model_name TEXT NOT NULL, created_at TEXT DEFAULT (datetime(now, localtime)) );4.5 混合检索优化 LLM 知识库召回效果的实测技巧最后分享一个检索质量优化的思路。只做向量检索的知识库在遇到精确关键词、产品型号、人名时召回经常不准。我实测的效果是用 BM25 全文检索召回 top-100用向量检索召回 top-100然后把两组结果按 RRFReciprocal Rank Fusion合并最终效果比单纯用任一种方法好 15%-30%。RRF 的核心公式是score sum(1 / (k rank_i))k 一般取 60。实现起来很简单在chunks_fts和embeddings上分别查询后在 Python 里做一次分数合并再按分数排序取 top-10。这个方法不需要改造表结构只是前后增加两次查询加一次排序性价比极高。如果你的知识库内容以技术文档、代码示例、专业术语为主强烈建议加上这一层。个人使用体验总结这是我做 LLM 知识库项目以来踩了最多的坑、也收获最大的一段经历。SQLite 在 LLM 应用里不是“过渡方案”而是一个真正能落地的生产级选择前提是你把表结构设计好、把 PRAGMA 参数调对、把并发写入控制住。它的省心程度远超我的预期部署时不用装数据库服务交付时只拷一个文件备份时直接复制整个链路没有任何额外运维负担。如果你正打算做一个本地知识库、私有化问答助手或者轻量级 RAG 应用我建议你直接从 SQLite 起步先用文件型存储把业务逻辑跑通等数据量和并发量真的撑不住了再迁移到 PostgreSQL。大多数情况下你会发现根本走不到迁移那一步。