ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用语义检索和向量数据库实现浏览器历史自然语言搜索

用语义检索和向量数据库实现浏览器历史自然语言搜索 你有没有过这种经历想找回一个上周看过的网页只记得内容大意是“讲解如何用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_embeddingsTrue).tolist() return {vector: vec}注意我加了一个normalize_embeddingsTrue参数这是向量检索里的一个关键细节。归一化之后所有向量的模长都变成1此时余弦相似度和内积计算结果完全一致查询时直接用点积距离即可。这样做既方便Chroma存储也更有利于某些索引类型的性能优化。嵌入服务的性能优化方面我加了请求缓存用一个字典缓存“文本哈希-向量”的映射重复文本直接返回缓存结果省去重复计算。实测下来技术博客页面经常有重复的代码片段和模板文本缓存命中率还不低。3.3 向量写入与查询Chroma的完整使用流程写入和查询是数据流的两端我把核心逻辑抽成一个独立模块history_store.py方便复用。写入时接收嵌入服务返回的向量、页面元数据、片段文本一起写入Chromaimport chromadb client chromadb.PersistentClient(path./history_db) collection client.get_or_create_collection( namebrowsing_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取TopKdef search(query, time_rangeNone, top_k20): query_vec embed_text(query) where {} if time_range: where[visit_time] {$gte: time_range[0]} results collection.query( query_embeddings[query_vec], n_resultstop_k, wherewhere 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的重要性系数效果差异就很明显了。
返回列表