你有没有过这种经历:想找回一个上周看过的网页,只记得内容大意是“讲解如何用Docker部署Nginx反向代理”,但完全不记得网址、标题、甚至大概的访问时间。传统浏览器历史记录只能按域名和标题做关键词匹配,你去翻历史记录,翻了几十页也找不到,最后只能放弃,再搜索一遍。做过一次之后我就一直想,为什么历史记录不能用自然语言来查?“我上周看过的那个讲微服务服务网格的文章”、“那个对比几种消息队列的视频页面”,这类描述如果能让浏览器历史直接理解,该省多少时间。
围绕这个需求,我业余做了一个语义化浏览历史检索(Semantic Browsing History Retrieval)小项目,核心思路是把浏览过的页面内容转换成向量语义,存储到本地向量库中,检索时用自然语言描述进行语义相似度匹配,而不是机械地做关键词比对。在这篇文章里,我会把整套方案的选型逻辑、实现细节、踩过的坑和优化思路完整写出来,适合对本地知识管理、向量检索、浏览器扩展开发感兴趣的朋友参考。
1. 内容整体设计与思路拆解
1.1 为什么传统历史记录检索体验这么差
浏览器自带的History功能本质是一个“记录簿”:它保存了访问过的URL、页面标题、访问时间戳、访问次数这几个字段。Chromium内核的浏览器还提供chrome.history接口给扩展开发者调用,能拿到的信息也无非是这几个维度。
问题恰恰出在这里。传统检索依赖的是字符串匹配,查询词和页面标题、URL之间必须是“字面命中”的关系。你搜“Docker”,标题里没有Docker这个单词就搜不到;你搜“容器化部署”,标题里写的是“Containerize your app”也搜不到。中文场景尤其痛苦,标题经常是营销文案,比如“震惊!原来还能这样做”,你根本没法靠关键词定位。
做这个项目之前我简单统计过自己的浏览习惯:日常访问的页面中,技术文档、技术博客、Stack Overflow问答占了很大比例,而这些页面的标题通常是直接、朴素的描述性文字,正文内容远比标题更有区分度。这让我意识到,如果能把页面正文纳入索引,并且用语义向量来表征“内容含义”,检索召回率和体验会完全不一样。
1.2 语义检索的整体架构选型
整个系统的核心链路可以拆成四段:数据采集、内容解析、文本嵌入、向量检索。
数据采集层负责拿到用户访问过哪些页面。最直接的方式是开发一个浏览器扩展,监听标签页更新事件,记录URL和页面正文。内容解析层负责把HTML文档里的正文抽出来,过滤掉导航栏、广告、页脚等噪音。文本嵌入层将正文内容切分成合适的片段,调用嵌入模型生成向量。向量检索层负责把向量存起来,并在查询时做相似度计算。
在这个架构里有一个关键决策:嵌入和检索在哪里做。是调用云端大模型API,还是完全本地跑。我的做法是混合方案:嵌入模型用本地的轻量模型,向量库用本地文件型的库,这样所有数据不出本机,私密性有保障,也不会产生API费用。如果对精度有更高要求,也可以换云端闭源嵌入模型,后面会讲具体的替换方法。
架构上还有一个容易被忽略但是很重要的点:历史记录的时序信息。页面访问时间是一个天然的时间轴,用户查询时往往带着时间记忆,比如“上周”、“昨天”、“前几天”。所以检索系统不能只做向量语义检索,还要支持时间过滤条件。我在设计时把时间戳作为结构化元数据单独存储,查询时可以先按时间范围粗筛,再在粗筛结果里做向量相似度排序,也可以直接全量检索后按时间加权,两种策略各有适用场景。
1.3 关键技术选型对比与理由
技术选型我纠结最久的是向量存储。当时在FAISS、Chroma、Qdrant、sqlite-vec之间来回对比。我的需求很明确:单机使用、数据量不大(个人年浏览记录量级最多几十万条)、零运维、方便迁移备份。
FAISS是Meta出品的向量检索库,性能极其强悍,但它是单纯的内存索引库,持久化需要自己管理索引文件和ID映射,对个人项目来说有点重。Chroma是专为AI应用设计的向量数据库,接口简单,自带持久化,原生支持元数据过滤,写起来很舒服,但底层存储目录在版本更新时偶尔会遇到兼容性问题。Qdrant要跑独立服务,个人单机场景杀鸡用牛刀了。sqlite-vec是SQLite的向量检索扩展,轻量到极致,直接嵌入到现有SQLite数据库里,对于“浏览器历史”这种天然适合关系模型的数据,能在同一张表里管理URL、时间戳和向量,我也很心动。
最终我选了Chroma。理由是语义化检索功能需要频繁迭代查询和调试,Chroma的Python接口清晰,元数据过滤用起来顺手,而且可以方便地导出数据做分析。sqlite-vec的定位更偏生产环境嵌入,后续如果要做成轻量桌面应用,我可能会迁移到它。另外需要说明的是,最近很火的semantic kernel这个编排框架,我很早就关注过,这个项目里没有直接使用它,因为semantic kernel的强项是把大模型能力和应用程序编排在一起,而这个项目里的“语义能力”只是单一的嵌入和相似度检索,用框架反而多余。不过它的Function Calling和Memory插件设计思路,对后续扩展这个项目的问答能力很有参考价值。
这里直接给一张对比表,方便大家选型时参考:
| 方案 | 部署难度 | 持久化 | 元数据过滤 | 适合场景 |
|---|---|---|---|---|
| FAISS | 中 | 需手动管理 | 弱 | 百万级以上纯向量检索 |
| Chroma | 低 | 自带 | 强 | 个人知识库、原型验证 |
| Qdrant | 高(需服务) | 自带 | 强 | 团队级、分布式场景 |
| sqlite-vec | 低 | 自带 | 中 | 轻量嵌入式桌面应用 |
1.4 嵌入模型的选择与对比
嵌入模型是整个系统的“理解力”来源。模型选择直接决定了语义检索的上限。如果嵌入模型本身理解能力差,后面向量检索做得再好也白搭。
我对比过几类方案:OpenAI的text-embedding-3-small是云端API方案,效果很好,但数据要传到第三方服务器,隐私方面我不太放心,而且个人长期使用还有API费用。HuggingFace上的开源模型,比如BAAI/bge-small-zh-v1.5是专门针对中文优化的轻量模型,输出维度512维,本地运行速度快,单条文本嵌入耗时在CPU上能控制在几十毫秒左右,对个人项目来说完全够用。还有一个选择是text2vec-base-chinese,中文效果也不错,但模型体积比bge大不少,加载速度慢。
最终我选了bge-small-zh-v1.5。它兼顾了精度和速度,512维的向量在本地检索时计算量可以忽略不计,而且MIT协议开源,商用也没有限制。如果是纯英文场景,用all-MiniLM-L6-v2会更轻巧。中文为主、偶尔夹杂英文的技术内容,bge系列表现更稳健。
提示:嵌入模型和检索是两套逻辑。嵌入是“把文本变成向量”的编码过程,检索是“在向量空间里找最近邻居”的匹配过程。这两个环节可以分别优化,不一定绑定同一个模型供应商。
2. 核心细节解析与实操要点
2.1 页面内容的有效采集与正文解析
浏览器扩展获取当前页面HTML非常容易,通过document.documentElement.outerHTML就能拿到。但原始HTML直接扔给嵌入模型是灾难:页面里导航、侧栏、页脚、广告、脚本标签带来的噪音,会严重污染语义向量。
正文抽取我用了两个方案叠加。第一层是用@mozilla/readability库,这是Firefox Reader View背后的开源解析库,能从杂乱HTML里提取出干净的正文内容,返回标题和纯文本。实测下来对大多数技术博客和文档站效果很好,准确率很高。第二层是针对Readability抽取失败的情况做兜底:如果抽取结果文本太短(比如少于200字),就退回到用正则去除<script>、<style>、<nav>标签,然后从剩余文本里取最长文本块。
这里有一个我之前踩过的坑:很多页面是动态渲染的,扩展在tabs.onUpdated事件里立刻抓取HTML时,页面正文可能还没加载出来。解决办法是延迟采集,等页面complete状态后再等900毫秒,或者监听页面load事件结束后再执行抽取。我实际用的是“监听tabs.onUpdated的status变成complete后延迟1秒”的策略,简单有效。对于SPA单页应用,这种策略仍然可能漏掉路由切换后的内容,后续可以优化成监听页面标题变化来触发采集。
关于采集范围,我用了一个排除清单:浏览器内部页面(chrome://、edge://)、扩展商店页面、无正文内容的页面(视频播放页、图片查看页等)。这能避免向量库里塞进大量无意义内容,降低检索信噪比。
2.2 文本分块策略与参数设定
文本嵌入模型几乎都有输入长度限制。bge-small-zh的默认最大序列长度是512个token,按中文字符比例换算大概对应1000个汉字左右。一篇技术博客动辄几千字,必须做切分。
切分策略我对比过两种:固定长度切分和语义段落切分。固定长度切分最简单,按字符数硬切,缺点是会把一句话从中间截断,导致相邻片段语义不连贯。语义段落切分依赖标题和空行来定位段落边界,边界更自然,但实现复杂度高。
我最后用的是“标题感知的滑动窗口切分”:先把正文按换行符拆成块,然后以Markdown标题(#、##开头行)或两个连续换行作为段落边界,将相近的小段聚合成长度接近800字符的片段,并且相邻片段之间保留80字符的重叠。这个“重叠”参数很关键,它避免了语义刚好落在两个片段的边界而被截断的问题。800字符这个参数是经验值,比模型上限小一截,又能保证一个片段里包含足够多的语义信息。
分块之后的每个片段,我会带上原页面的URL、标题作为元数据,这样检索到某个片段时,能直接追溯到来源页面。同时为了避免同一个页面的多个片段在检索结果里重复占位,我在查询结果返回时会按URL做一次去重聚合,只保留匹配度最高的那个片段作为该页面的代表。
2.3 向量化与存储的工程细节
嵌入计算我用的是sentence-transformers库加载本地模型,调用方式非常直观。第一次加载模型时会把模型权重读入内存,bge-small-zh的权重文件约95MB,内存占用不高。实际嵌入计算我加了一个信号量限制并发数,避免多个页面同时采集时CPU被打满。
向量存储选择了Chroma的PersistentClient模式,数据持久化到本地目录。Chroma的Collection会默认按余弦距离计算相似度。
存储结构上,我给每个片段设计了这么几个元数据字段:url、title、visit_time(时间戳)、created_at(采集时间)、chunk_index(片段序号)。其中visit_time是检索时做时间过滤的硬条件,chunk_index用于调试时观察页面哪些位置的片段更容易被命中。Chroma可以按元数据过滤,这正好满足我之前提到的“先时间粗筛再向量精排”的方案。
这里要特别说一个容易踩的坑:Chroma的ID必须唯一,而且不能为空。如果你用URL做ID,同一个页面多次访问、多个片段就会冲突。我的做法是用“URL+时间戳+序号”的哈希值作为ID,保证每条片段的唯一性。另外,给同一个页面反复插入相同内容会导致库膨胀,所以在写入前会先按URL查询一下已存在的片段数量,如果页面内容没有变化,就直接跳过。
2.4 检索查询的语义化处理流程
语义检索的查询端是实现“自然语言查历史”的核心。用户输入一句话,比如“那篇介绍async/await原理的文章”,系统处理流程分三步:
第一步,对查询文本做嵌入,得到查询向量。第二步,在向量库里做相似度检索,找出TopK个最相近的片段。这个阶段可以把时间过滤条件加进去,比如用户再选择一个时间范围“最近7天”,就可以在Chroma里用where={"visit_time": {"$gte": 起始时间戳}}来过滤。第三步,对返回结果做过滤和聚合:因为同一个页面的多个片段可能同时命中,我会按URL去重,取片段得分最高的作为页面代表,再按得分排序输出结果。
得分标准是一个值得打磨的细节。Chroma默认返回的distance是余弦距离,数值越小代表越相似。但是不同查询词的“绝对距离”没有统一可比性,不能直接设一个固定阈值说distance小于0.3就是可信结果。我实践中发现更稳妥的做法是:只看TopK的相对排序,不要盲目相信绝对阈值。但是为了减少无意义结果,可以设一个宽松的兜底阈值,比如distance大于0.8的基本可以认定不相关。
检索结果展示时,我会把命中的片段文本片段显示出来,让用户一眼看到为什么这个页面被检索到,这比只显示标题要直观得多。对于片段里命中的关键词或相关语句,我会高亮显示,这需要一个本地的文本匹配逻辑,因为除了向量命中外,还要在片段中定位“最可能相关的句子”来做展示。
3. 实操过程与核心环节实现
3.1 浏览器扩展端:用TypeScript编写采集器
浏览器扩展是数据采集的入口。我用TypeScript加Vite搭建了一个Chrome Extension MV3项目。清单文件是manifest.json,核心配置如下:
{ "manifest_version": 3, "name": "Semantic History Collector", "version": "0.1.0", "permissions": ["tabs", "storage", "history"], "host_permissions": ["<all_urls>"], "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" } ] }关键点在于tabs权限和<all_urls>的主机权限,没有这两项无法读取页面标题和内容。内容脚本在document_idle时注入,这样能保证绝大多数页面已经完成基础渲染。
采集逻辑写在后台Service Worker里。用tabs.onUpdated监听页面状态变化,当changeInfo.status是complete时触发采集。但这里有个坑:Service Worker在MV3里是事件驱动、随时可能休眠的,耗时操作(比如等1秒再采集、调用嵌入接口)必须自己管理好生命周期。我的做法是用chrome.storage.session暂存待处理队列,Service Worker被唤醒后从队列里取任务继续处理,避免因为休眠丢失数据。
拿到页面HTML之后,我在内容脚本里直接调用new XMLHttpRequest()请求当前页面的DOM字符串,然后把HTML、URL、标题通过chrome.runtime.sendMessage发给后台。后台收到之后,先做URL过滤,再做正文抽取,最后把干净文本发给本地嵌入服务。
3.2 本地嵌入服务:用FastAPI封装语义能力
我这里没有把嵌入模型直接跑在浏览器扩展里,而是单独开了一个本地FastAPI服务。理由有三点:浏览器扩展的JS环境加载Python模型非常麻烦;分离出来之后可以独立测试、独立升级嵌入模型;将来如果想把语义能力扩展到其他应用(比如文件检索、笔记检索),可以直接复用这个服务。
服务端核心代码很简单:
from fastapi import FastAPI from pydantic import BaseModel from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-small-zh-v1.5") app = FastAPI() class EmbedRequest(BaseModel): text: str @app.post("/embed") def embed(req: EmbedRequest): vec = model.encode(req.text, normalize_embeddings=True).tolist() return {"vector": vec}注意我加了一个normalize_embeddings=True参数,这是向量检索里的一个关键细节。归一化之后,所有向量的模长都变成1,此时余弦相似度和内积计算结果完全一致,查询时直接用点积距离即可。这样做既方便Chroma存储,也更有利于某些索引类型的性能优化。
嵌入服务的性能优化方面,我加了请求缓存:用一个字典缓存“文本哈希->向量”的映射,重复文本直接返回缓存结果,省去重复计算。实测下来技术博客页面经常有重复的代码片段和模板文本,缓存命中率还不低。
3.3 向量写入与查询:Chroma的完整使用流程
写入和查询是数据流的两端,我把核心逻辑抽成一个独立模块history_store.py,方便复用。
写入时,接收嵌入服务返回的向量、页面元数据、片段文本,一起写入Chroma:
import chromadb client = chromadb.PersistentClient(path="./history_db") collection = client.get_or_create_collection( name="browsing_history", metadata={"hnsw:space": "cosine"} ) def add_chunk(doc_id, text, embedding, metadata): collection.add( ids=[doc_id], embeddings=[embedding], documents=[text], metadatas=[metadata] )查询时,同样的嵌入模型先处理查询文本,再从Chroma取TopK:
def search(query, time_range=None, top_k=20): query_vec = embed_text(query) where = {} if time_range: where["visit_time"] = {"$gte": time_range[0]} results = collection.query( query_embeddings=[query_vec], n_results=top_k, where=where if where else None ) return results这里有个关于时间过滤的性能细节:Chroma实现元数据过滤的效率不算高,如果历史数据量达到几十万条,where过滤加上向量检索可能会有几百毫秒延迟。个人项目能接受,但可以考虑先全量检索TopK,再用时间字段做后过滤,前提是能接受“超出时间范围的结果偶尔串入”的小噪声。两种方案我都试过,最终保留的是“先时间过滤再检索”,因为它不影响向量检索的Recall,只是速度略慢。
3.4 检索效果评估:一个非正式但有效的实验
为了验证这整套方案是否真的比传统历史记录好用,我做了个小实验。挑选了15个自己过去一个月内真实访问过的页面,针对每个页面写了一条语义化查询描述(刻意避开标题关键词),然后分别用这套语义检索系统和浏览器自带历史搜索去检索,看谁能更快找到目标页面。
测试里印象最深的一个case是:我查“那篇讲MySQL索引失效的案例文章”,目标页面标题是《一次SQL慢查询的排查记录》,标题里没有“MySQL”、“索引”任何关键词。浏览器历史记录完全搜不到,语义检索却能在Top3以内命中。这个case充分说明了语义检索的差异化价值——它检索的是“内容含义”,而不是“表面文字”。
当然语义检索也不是银弹。在测试中有一类case失败比较明显:查询描述太抽象、太笼统,比如“那篇看了让人很受启发的文章”,没有具体实体词,嵌入模型会给到一个泛泛的向量方向,TopK结果相关性很差。这类抽象查询,我认为未来如果要支持得更完善,可能需要引入用户反馈学习,比如用户点了一条结果之后,把这个查询和结果对应关系记录下来,累积成个性化重排依据。
3.5 核心参数速查表
我把这个项目里几个最重要的经验参数整理成表格,方便你直接抄作业:
| 参数项 | 推荐值 | 说明 |
|---|---|---|
| 文本切分长度 | 800字符 | 留足模型token余量 |
| 切片重叠长度 | 80字符 | 避免语义截断 |
| 嵌入模型 | bge-small-zh-v1.5 | 中文场景性价比高 |
| 向量维度 | 512 | 对应bge模型 |
| TopK召回 | 20 | 先粗召回再精排 |
| 相似度兜底阈值 | 0.8(余弦距离) | 超过直接丢弃 |
| 采集延迟 | 1秒 | 等待动态页面渲染完成 |
| 正文最小长度 | 200字符 | 低于此值视为无正文 |
4. 常见问题与排查技巧实录
4.1 页面正文总是抽不出来或内容为空
这是开发过程中最常遇到的问题。排查时首先要确认采集到的HTML是否完整。很多情况下是扩展权限没开,或者页面有反爬机制(比如Cloudflare人机校验),内容脚本根本执行不到。我遇到过一个案例:某个技术网站把所有正文放在iframe里,Readability默认不处理iframe内部内容,导致抽取结果为空。
解决方案有几个方向:一是升级提取策略,针对iframe内容做二次抓取;二是对该网站单独写一个解析规则;三是放弃完全自动化,改用用户手动选择“把这个页面加入语义库”的按钮。我自己的做法是接受一部分页面抽取失败,因为这些页面本身占比不大,对检索效果影响有限。真正常用的技术网站,Readability的准确率已经很高了。
4.2 嵌入服务偶发超时或内存占用过高
sentence-transformers首次加载模型耗时较长,而且如果多个请求并发访问,内存会激增。我遇到过一次本地服务因为并发过高直接被OOM killer干掉的情况。
解决办法是给服务加并发控制。我用的是FastAPI的Semaphore信号量,限制同时只能处理2个嵌入请求。另一个优化是模型加载后常驻内存,不要每次新建实例。还有,如果机器配置较低,可以考虑换用一个更轻量的模型,比如paraphrase-multilingual-MiniLM-L12-v2,它在CPU上跑得更快,内存占用更小,中文效果也不错。
4.3 为什么查询出来的结果“感觉不相关”
这个问题的根源往往不在检索算法,而在文本切分和嵌入模型本身。如果切分出来的片段里噪音太多,或者片段跨度过大包含多个主题,嵌入向量的语义就会被稀释。比如一个页面讲“Docker部署”和“K8s原理”两个主题,如果被切进同一个片段,查询“K8s Service的概念”时,这个片段的向量方向会被Docker内容拉偏,影响匹配精度。
优化方向是切分策略要更“语义化”,比如基于标题把文章先拆成章节,再在每个章节内部做切分。这又回到2.2节提到的“标题感知切分”,它的重要性在实践中会被放大。另外一个可行的手段是提高重排序阶段的精度,引入粗排加精排的两阶段检索:先用bge向量快速找出Top50候选,再用一个更强的cross-encoder(比如bge-reranker-base)对候选逐条打分排序。实测两阶段检索比单阶段效果提升明显,最大的代价是查询响应时间增加几百毫秒,个人场景可接受。
4.4 数据体积增长后查询变慢
当向量库里的片段数超过几万条之后,查询延迟会从个位数毫秒增长到几十毫秒甚至上百毫秒,主要瓶颈在暴力检索。虽然Chroma底层用的是HNSW索引,默认设置已经能做到近似最近邻检索,但数据量大了以后索引构建和查询都会变慢。
优化手段有以下几种:一是合理设置HNSW参数,例如ef_search可以调小来加快查询,但会牺牲少量准确率;M参数(邻居连接数)调大能提升召回但会增加内存。二是定期清理低价值页面,比如把超过一年且从未被命中的页面从库里移出,控制数据总量。三是按时间做分库,不同年份的数据放到不同的Collection,查询时按需选择。对于个人项目,每年数据量撑死十几万条,前两种手段就已经非常够用了。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 采集不到任何数据 | 扩展权限未授予、Service Worker休眠 | 检查manifest权限、用session queue恢复任务 |
| 抽取正文为空 | 页面在iframe里、动态渲染未完成 | 延迟采集、针对站点写规则 |
| 向量库越来越大 | 页面内容重复写入 | 写入前按URL去重 |
| 查询结果总是不准 | 切分粒度太粗、噪音过多 | 优化切分策略、引入reranker |
| 查询延迟高 | 数据量大、HNSW参数不匹配 | 调小ef_search、定期清理旧数据 |
| 服务OOM | 并发嵌入请求过多 | 加信号量限制并发、换轻量模型 |
5. 可扩展方向与后续优化设想
项目跑通到现在,我已经用了近两个月。除了日常找历史网页变得非常高效之外,我越来越觉得“语义化浏览历史”只是整个“个人语义记忆”体系的一个起点。如果把这个思路扩展到文件内容、浏览器书签、稍后读列表、剪贴板记录,完全可以构建一个基于向量检索的个人知识中台。
有一个具体的方向是接入对话式问答。semantic kernel这类编排框架天生适合干这件事:用户先通过自然语言对话明确想看什么,框架调用语义检索插件在历史库里拿候选文档,再交给大模型做摘要或对比。这比单纯给出一堆链接体验又高一个层级。我设想中比较顺滑的交互是用户问“我上次看的那个Docker网络配置的文章帮我总结一下要点”,系统先检索到那篇文章片段,再调用大模型基于片段内容做总结,整个链路围绕用户意图工作,而不是让用户自己再去读一遍。
另一个方向是多模态扩展。浏览器历史里其实不止文字内容,还有图片、视频页面。如果能对图片缩略图做视觉嵌入、对视频的字幕文本做语义提取,检索的覆盖面又能扩大不少。不过这个方向的工程复杂度会明显提升,我暂时还没有完整的落地计划。
关于隐私和本地化,我可以负责任地说这个项目最大的价值之一就是完全离线。嵌入模型跑本地,向量库存本地,整个链路没有任何第三方接口参与。如果你的需求不要求绝对本地化,想用更强大的云端嵌入模型和量化检索方案,替换成本也不高,只需要改嵌入服务的API路径和数据能出网的网络策略即可。
最后再分享一个使用体验上的小技巧:为了让语义检索能找回更早期的页面,我还在每个页面采集时额外存了访问次数和停留时长估算值,检索排序时会对高访问次数、高停留时长的页面赋予一小部分权重加成。这相当于一个隐性的“页面重要性”信号,能让高频使用的页面更容易被找回来。实测下来,这种内容相关性加行为权重的混合排序,比纯向量相似度排序更贴近我真实的找网页需求。这个调参思路对你来说可能也值得一试,量化的部分不用做太复杂,简单加个0.1到0.2的重要性系数,效果差异就很明显了。