1. 收藏夹为什么会变成“数字坟墓”
1.1 一个几乎人人都有的困境
先问一个问题:你的浏览器收藏夹里现在有多少条链接?三百?一千?还是多到你自己都不敢点开看?
我自己的情况是,攒了大概四年多,一千两百多条。书签栏早就塞满了,后来只能靠文件夹分类硬撑,什么“技术”“工具”“待读”“灵感”“以后再看”……结果就是每个文件夹点开都是一屏接一屏,真正回头翻的概率无限趋近于零。收藏这个动作本身只花两秒钟,但收藏之后再也没有第二次打开,这些链接就安安静静躺在那里,成了名副其实的“数字坟墓”。
这个问题的本质不是“我收藏得太多”,而是收藏这个动作和检索这个动作之间断开了。浏览器自带的收藏夹搜索只能匹配标题和网址,可我收藏一篇文章的时候,脑子里记的是“那篇讲数据库索引优化的”“那个把图片压缩讲得很透的”“某天看到的一个特别好的配色方案”。标题里根本没有这些词,搜索自然搜不到。于是收藏夹越攒越大,可用性越来越低,最后干脆放弃。
我试过很多办法:定期清理、只留精华、换用在线书签服务、用笔记软件手动摘录……每一种都坚持不过两周。清理的时候舍不得删,在线服务用着用着就懒得同步,手动摘录更是反人性。直到后来我换了个思路——与其管理收藏,不如把收藏变成一座能随时检索的私人知识库。这就是 clawsqlite-knowledge 这个项目诞生的起点。
1.2 这个项目到底做了什么
一句话概括:它把我散落在各处的收藏链接、网页正文、随手记的片段,统一抓取下来,存进一个本地数据库,再配上一层全文检索和语义搜索的能力,让我可以用自然语言去问“我之前存过的那篇讲缓存穿透的文章在哪”。
它解决的核心问题有三个。第一是聚合,把浏览器书签、稍后读列表、笔记软件里的碎片统一到一个地方。第二是可检索,不只是搜标题,而是搜正文内容,甚至搜我当时的批注。第三是可离线、可掌控,所有数据存在本地一个文件里,不依赖任何在线服务,不怕服务关停,也不怕隐私泄露。
适合谁来参考?我觉得三类人最合适。一类是和我一样收藏夹爆炸、又不想放弃积累的人;一类是喜欢自己动手、对数据主权有要求的开发者;还有一类是正在做个人知识管理、想找一个轻量可落地方案的普通用户。哪怕你完全不懂编程,只要愿意跟着步骤走一遍,也能把这套东西跑起来。
1.3 为什么选 SQLite 而不是别的
这里要重点说一下技术选型,因为这是整个项目的地基。市面上做知识库的方案很多,为什么我最后落在 SQLite 上?
第一,单文件、零运维。整个知识库就是一个.db文件,复制走就是完整备份,扔到任何一台机器上都能打开。对比那些需要跑服务、配数据库、管账号的方案,SQLite 的部署成本几乎为零。我实测下来,一千多条记录加上全文索引,文件也就几十兆,完全在个人可控范围内。
第二,全文检索能力被严重低估。很多人以为 SQLite 只是个“小数据库”,其实它内置的 FTS5 扩展做全文检索非常能打。它支持分词、前缀匹配、短语查询、布尔组合,配合触发器还能做到内容更新时索引自动同步。对于个人知识库这种数据量级,性能绰绰有余。
第三,和脚本语言配合极顺。Python 标准库直接带sqlite3,不需要装任何驱动。抓取、清洗、入库、检索,整条链路用 Python 一个语言就能写完,没有跨语言调用的心智负担。
提示:如果你的收藏量在十万条以上,或者需要多人同时写入,SQLite 可能就不是最优解了。但对个人知识库这个场景,它几乎是性价比最高的选择。
2. 整体架构与核心思路拆解
2.1 从链接到知识库的四层结构
我把整个系统拆成四层,每一层职责单一,方便单独调试和替换。
第一层是采集层,负责把各种来源的链接和内容抓下来。浏览器书签导出的是 HTML 文件,稍后读服务一般有导出接口,笔记软件里的碎片我手动整理成文本。这一层的输出统一成“一条记录 = 标题 + 网址 + 正文 + 标签 + 收藏时间”。
第二层是清洗层,负责把抓下来的网页正文从一堆 HTML 标签里剥出来。这一步很关键,因为原始 HTML 里全是导航、广告、脚本,直接入库会让检索结果充满噪音。我用的是正文提取的思路:先去掉 script、style、nav、footer 这些明显不是正文的标签,再根据文本密度定位主体区域。
第三层是存储层,也就是 SQLite。这里有两张核心表:一张存原始记录,一张存全文索引。两者通过 rowid 关联,内容更新时用触发器同步。
第四层是检索层,对外提供两种查询方式:一种是关键词全文检索,走 FTS5;另一种是语义搜索,把查询和文档都转成向量做相似度匹配。前者快而准,后者能处理“我记不清关键词”的情况。
2.2 为什么采集和清洗要分开
一开始我是把抓取和清洗写在一起的,抓到网页直接提取正文入库。后来发现这样调试特别痛苦:某篇文章提取失败了,我根本不知道是抓取阶段就没拿到内容,还是清洗阶段把正文误删了。
拆开之后好处立竿见影。采集层只负责“把原始 HTML 存下来”,哪怕清洗逻辑以后改了,原始数据还在,可以重新跑一遍清洗,不用重新抓。清洗层只负责“从 HTML 里提取正文”,可以单独拿一个 HTML 文件反复测试提取规则,不用每次都联网。
这个思路其实和数据处理里的“分层存储”是一个道理:原始层保真,加工层可重跑。对于个人项目来说,这种设计能省下大量重复劳动。
2.3 全文检索和语义搜索怎么配合
很多人会纠结:到底用全文检索还是用向量搜索?我的答案是两个都要,但分工明确。
全文检索(FTS5)擅长精确匹配。比如我记得文章里有“缓存穿透”这个词,直接搜就能命中,速度快,结果准。它的短板是处理不了同义表达,我搜“缓存击穿”就找不到只写了“缓存穿透”的文章。
语义搜索(向量相似度)擅长模糊匹配。我把查询“怎么防止缓存被大量请求打穿”转成向量,它能找到语义相近的文档,哪怕原文用的是完全不同的词。它的短板是可能召回一些语义相近但实际不相关的内容,而且需要额外的向量计算。
我的做法是:默认走全文检索,当全文检索结果为空或明显偏少时,自动降级到语义搜索。这样既保证了大多数情况下的速度和精度,又兜住了“记不清关键词”的场景。
2.4 数据表设计的取舍
核心表结构我改过三版,最后定下来是这样:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| records | 存原始记录 | id, title, url, content, tags, created_at |
| records_fts | 全文索引 | title, content, tags(虚拟表) |
| embeddings | 存向量 | record_id, vector, model_name |
第一版我把标签直接塞进 content 字段,结果检索标签的时候会把正文里偶然出现的词也带出来。第二版拆了标签表,但查询要 join,写起来啰嗦。第三版干脆把 tags 作为一个独立字段放进 FTS 索引,既保留了结构化,又能被全文检索覆盖。
向量单独存一张表,是因为向量的生成成本高,而且模型可能更换。把模型名一起存下来,以后换模型时可以按模型名筛选,重新生成。
注意:FTS5 虚拟表不支持直接 UPDATE,内容变更要走“删除再插入”或者用触发器。我一开始直接 UPDATE 主表,发现索引没同步,搜出来的还是旧内容,排查了半天才反应过来。
3. 核心细节解析与实操要点
3.1 浏览器书签怎么导出和解析
浏览器书签导出很简单,在书签管理器里找“导出书签”,会得到一个 HTML 文件。这个文件的结构是嵌套的<DL><DT>列表,每个书签是一个<A>标签,带HREF和ADD_DATE属性。
解析的时候有个坑:文件夹层级是有意义的。我早期直接把所有<A>标签平铺提取,结果丢失了“这个链接当初被我放在哪个分类下”的信息。后来改成递归遍历,把文件夹路径拼成标签,比如“技术/数据库/索引优化”,这样每个链接天然带上了我当初的分类意图。
from bs4 import BeautifulSoup def parse_bookmarks(html_path): with open(html_path, 'r', encoding='utf-8') as f: soup = BeautifulSoup(f.read(), 'html.parser') results = [] def walk(dl, path): for dt in dl.find_all('dt', recursive=False): a = dt.find('a') if a: results.append({ 'title': a.get_text(strip=True), 'url': a.get('href'), 'tags': '/'.join(path), 'created_at': a.get('add_date') }) sub_dl = dt.find('dl') if sub_dl: folder = dt.find('h3') new_path = path + [folder.get_text(strip=True)] if folder else path walk(sub_dl, new_path) walk(soup.find('dl'), []) return results这段代码的关键在recursive=False,只处理当前层级的dt,子文件夹交给递归。如果不加这个参数,find_all会把所有层级的dt都抓出来,导致重复。
3.2 网页正文提取的实用策略
正文提取没有万能方案,我的策略是“够用就好”。对于大部分文章类网页,下面这套规则能覆盖八成以上:
- 删掉
script、style、noscript、iframe、nav、footer、header、aside标签。 - 找所有
<p>标签,按文本长度排序,取长度总和最大的那个容器作为正文区。 - 如果找不到合适的容器,退而求其次,把所有
<p>标签的文本拼起来。
def extract_content(html): soup = BeautifulSoup(html, 'html.parser') for tag in soup(['script', 'style', 'noscript', 'iframe', 'nav', 'footer', 'header', 'aside']): tag.decompose() candidates = {} for p in soup.find_all('p'): text = p.get_text(strip=True) if len(text) < 20: continue parent = p.parent candidates.setdefault(id(parent), {'node': parent, 'len': 0}) candidates[id(parent)]['len'] += len(text) if not candidates: return ' '.join(p.get_text(strip=True) for p in soup.find_all('p')) best = max(candidates.values(), key=lambda x: x['len']) return best['node'].get_text(separator='\n', strip=True)这里有个经验:文本长度阈值设 20 个字符。太短的<p>往往是版权声明、导航文字,过滤掉能显著提升正文纯度。这个阈值可以根据你的语料调整,中文内容可以适当调低到 15。
3.3 FTS5 索引的建立与同步
建 FTS5 表的时候,分词器的选择很关键。SQLite 默认的unicode61分词器对中文是按字切分的,效果一般。如果条件允许,可以用simple分词器配合外部分词,或者直接用trigram分词器做三元组匹配。
CREATE VIRTUAL TABLE records_fts USING fts5( title, content, tags, content='records', content_rowid='id', tokenize='trigram' );content='records'表示这是一个外部内容表,索引的数据来自 records 表,不额外存一份正文,省空间。content_rowid='id'指定关联字段。
同步用触发器:
CREATE TRIGGER records_ai AFTER INSERT ON records BEGIN INSERT INTO records_fts(rowid, title, content, tags) VALUES (new.id, new.title, new.content, new.tags); END; CREATE TRIGGER records_ad AFTER DELETE ON records BEGIN INSERT INTO records_fts(records_fts, rowid, title, content, tags) VALUES ('delete', old.id, old.title, old.content, old.tags); END; CREATE TRIGGER records_au AFTER UPDATE ON records BEGIN INSERT INTO records_fts(records_fts, rowid, title, content, tags) VALUES ('delete', old.id, old.title, old.content, old.tags); INSERT INTO records_fts(rowid, title, content, tags) VALUES (new.id, new.title, new.content, new.tags); END;提示:
trigram分词器要求 SQLite 版本在 3.34 以上。如果你的环境版本较低,用unicode61也能跑,只是中文检索体验会打折扣。可以先查一下SELECT sqlite_version();确认。
3.4 向量搜索的轻量实现
语义搜索不一定非要上重型方案。我的做法是用一个轻量的句向量模型,把每条记录的标题加正文前 500 字转成向量,存进 embeddings 表。查询时把查询语句也转成向量,算余弦相似度,取 top 10。
向量存成二进制 blob,用struct打包 float 数组。一千多条记录的向量计算,在普通笔记本上几秒钟就能跑完,完全不需要 GPU。
import struct import numpy as np def vector_to_blob(vec): return struct.pack(f'{len(vec)}f', *vec) def blob_to_vector(blob): n = len(blob) // 4 return np.array(struct.unpack(f'{n}f', blob)) def cosine_sim(a, b): return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))这里有个细节:向量一定要归一化后再存。归一化之后,余弦相似度就退化成点积,计算更快,而且不用担心数值溢出。我一开始没归一化,算相似度的时候偶尔出现 nan,排查了好久。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
整个项目用 Python 3.9 以上就能跑,依赖不多:
pip install beautifulsoup4 requests numpy如果要用句向量模型,再加一个:
pip install sentence-transformersSQLite 本身是 Python 标准库自带的,不用额外装。但要注意,系统自带的 SQLite 版本可能偏低,建议用sqlite3.sqlite_version确认一下,低于 3.34 的话考虑升级 Python 或者单独编译 SQLite。
4.2 数据库初始化脚本
建库脚本我单独写成一个文件,方便重复执行。用CREATE TABLE IF NOT EXISTS保证幂等,重复跑不会报错。
import sqlite3 def init_db(db_path='knowledge.db'): conn = sqlite3.connect(db_path) cur = conn.cursor() cur.execute(''' CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, url TEXT UNIQUE, content TEXT, tags TEXT, created_at TEXT, fetched_at TEXT DEFAULT CURRENT_TIMESTAMP ) ''') cur.execute(''' CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5( title, content, tags, content='records', content_rowid='id', tokenize='trigram' ) ''') cur.execute(''' CREATE TABLE IF NOT EXISTS embeddings ( record_id INTEGER PRIMARY KEY, vector BLOB, model_name TEXT, FOREIGN KEY(record_id) REFERENCES records(id) ) ''') conn.commit() return connurl UNIQUE这个约束很重要,它天然防止了重复收藏。我用INSERT OR IGNORE入库,同一条链接重复抓取也不会产生冗余记录。
4.3 完整入库流程
入库流程分四步:读书签、抓正文、清洗、写库。抓正文的时候一定要加超时和异常处理,不然一个死链能把整个流程卡住。
import requests from datetime import datetime def fetch_and_store(conn, bookmark): url = bookmark['url'] if not url or not url.startswith('http'): return try: resp = requests.get(url, timeout=10, headers={ 'User-Agent': 'Mozilla/5.0 (compatible; KnowledgeBot/1.0)' }) resp.encoding = resp.apparent_encoding content = extract_content(resp.text) except Exception as e: print(f'抓取失败 {url}: {e}') content = '' cur = conn.cursor() cur.execute(''' INSERT OR IGNORE INTO records (title, url, content, tags, created_at) VALUES (?, ?, ?, ?, ?) ''', ( bookmark['title'], url, content, bookmark['tags'], datetime.fromtimestamp(int(bookmark['created_at'])).isoformat() if bookmark.get('created_at') else None )) conn.commit()实测下来,一千多条链接抓完大概需要十几分钟,主要时间花在网络请求上。建议加个time.sleep(0.5)控制频率,既礼貌又不容易被限流。
4.4 检索功能的实现
检索入口我做了两个函数,一个走 FTS,一个走向量,外面包一层调度逻辑。
def search_fts(conn, query, limit=10): cur = conn.cursor() cur.execute(''' SELECT r.id, r.title, r.url, r.tags, snippet(records_fts, 1, '【', '】', '...', 20) AS snip FROM records_fts JOIN records r ON r.id = records_fts.rowid WHERE records_fts MATCH ? ORDER BY rank LIMIT ? ''', (query, limit)) return cur.fetchall()snippet函数是 FTS5 自带的,能把命中的关键词用指定符号标出来,还能控制上下文长度。这个功能对知识库特别友好,一眼就能看到命中位置,不用点进去才知道是不是自己要的。
调度逻辑:
def search(conn, query, limit=10): results = search_fts(conn, query, limit) if len(results) >= 3: return results return search_semantic(conn, query, limit)阈值设 3 是个经验值。全文检索命中 3 条以上,说明关键词匹配得不错,没必要再跑向量。少于 3 条,说明可能关键词没对上,降级到语义搜索兜底。
4.5 参数选择与性能实测
几个关键参数我做了对比测试,结果如下:
| 参数 | 取值 | 效果 |
|---|---|---|
| 正文截断长度 | 500 / 1000 / 2000 字 | 500 字足够语义匹配,2000 字提升有限但向量计算变慢 |
| FTS 返回条数 | 10 / 20 / 50 | 10 条覆盖大部分需求,20 条以上噪音明显增多 |
| 向量维度 | 384 / 768 | 384 维在个人知识库场景下精度足够,速度快一倍 |
| 抓取超时 | 5 / 10 / 20 秒 | 10 秒是平衡点,5 秒误杀太多,20 秒等待太久 |
最终我定下来的是:正文截断 500 字、FTS 返回 10 条、向量 384 维、超时 10 秒。这套参数在我的数据量下,单次检索响应时间在 50 毫秒以内,体验非常流畅。
5. 常见问题与排查技巧实录
5.1 抓取阶段的典型问题
问题一:中文乱码。很多网页的Content-Type里声明的编码和实际编码不一致,直接用resp.text会得到一堆乱码。解决办法是用resp.apparent_encoding让 requests 自己猜,或者手动指定resp.encoding = 'utf-8'。我实测下来,apparent_encoding的准确率在九成以上。
问题二:动态渲染页面抓不到内容。有些页面正文是 JavaScript 渲染出来的,requests 拿到的 HTML 里根本没有正文。这种情况要么换用能执行 JS 的方案,要么直接放弃这类页面。我的选择是放弃,因为个人知识库里这类页面占比很低,不值得为它引入重型依赖。
问题三:死链和超时。收藏夹里总有那么几条链接已经失效了。一定要加timeout和try-except,把失败记录下来,不要让它中断整个流程。我单独建了一张failed_urls表,记录失败原因和重试次数,方便后续排查。
5.2 检索阶段的典型问题
问题一:搜不到明明存在的内容。先检查分词器。如果用unicode61,中文是按字切的,搜“数据库”可能匹配不到“数据”和“库”分开的情况。换成trigram后这个问题基本消失。再检查索引是否同步,可以手动跑一次INSERT INTO records_fts(records_fts) VALUES('rebuild');重建索引。
问题二:搜索结果排序不合理。FTS5 默认按rank排序,但rank的计算方式和你的预期可能不一样。如果想让标题命中优先于正文命中,可以用bm25函数自定义权重:
SELECT *, bm25(records_fts, 10.0, 1.0, 5.0) AS score FROM records_fts WHERE records_fts MATCH ? ORDER BY score;三个数字分别对应 title、content、tags 的权重。标题权重给高一点,符合“标题命中更相关”的直觉。
问题三:向量搜索返回不相关结果。大概率是向量没归一化,或者模型不适合中文。检查归一化,换一个中文语料训练过的句向量模型,效果会明显改善。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 中文乱码 | 编码声明不一致 | 用 apparent_encoding |
| 正文为空 | 动态渲染 | 检查 HTML 源码是否含正文 |
| 搜不到内容 | 索引未同步 | 重建 FTS 索引 |
| 排序不合理 | rank 权重问题 | 用 bm25 自定义权重 |
| 向量结果差 | 未归一化或模型不适配 | 归一化 + 换中文模型 |
| 入库重复 | 缺 UNIQUE 约束 | 给 url 加 UNIQUE |
| 抓取卡死 | 无超时 | 加 timeout 和异常处理 |
5.4 几个踩过的坑和独家技巧
坑一:直接 UPDATE 主表导致索引不同步。这个前面提过,FTS5 外部内容表不会自动感知主表变化,必须靠触发器。我建议在建表阶段就把触发器一起建好,别等到出问题再补。
坑二:向量存成 JSON 字符串。一开始我图省事,把向量json.dumps存成文本,结果一千条记录的相似度计算要好几秒。改成二进制 blob 后,同样的计算降到几百毫秒。存储格式对性能的影响比想象中大得多。
技巧一:给高频查询建缓存。我发现自己经常搜同样的几个词,于是在内存里加了一个简单的 LRU 缓存,命中缓存的查询直接返回,响应时间降到微秒级。
技巧二:定期做一次全量重建。索引用久了可能会有碎片,检索性能下降。我写了个定时任务,每周跑一次rebuild,把 FTS 索引和向量全部重新生成一遍。实测重建后检索速度能恢复不少。
技巧三:标签体系不要过度设计。我一开始想搞一套很精细的分类,结果维护成本太高。后来改成“文件夹路径直接当标签”,零额外成本,检索时还能按路径前缀过滤,反而更实用。
6. 后续可以怎么扩展
这套东西跑通之后,能扩展的方向其实不少。我目前在做的是把笔记软件里的碎片也接进来,让知识库覆盖更全。另一个想法是加一个简单的 Web 界面,用浏览器直接查,不用每次开终端。
还有一个我觉得挺有价值的方向:给每条记录加一个“最近访问时间”字段,检索时把最近看过的内容权重调高。因为人的记忆是有时效性的,刚看过的内容往往更相关。这个改动很小,但对检索体验的提升可能很明显。
如果你也想动手,我的建议是先从最小可用版本开始:只做书签导入 + 正文抓取 + FTS 检索,跑通之后再逐步加向量、加界面、加缓存。别一上来就追求大而全,个人项目最容易死在“想太多、做太少”上。