Skip to content

检索引擎与向量存储

Molore的检索引擎是一套跑在 SQLite 单库内的混合检索管线:向量(sqlite-vec KNN)与全文(FTS5 BM25)两路召回、加权融合、双门槛过滤,再按提问意图分流到若干结构化直查通道。它要解决的问题是个人语料场景的两个极端——语义相关但字面不匹配的内容要召得回,字面零星命中但实际无关的内容要拦得住;拦不住时宁可拒答,也不拿无关内容充当引用源。检索代码位于 backend/app/api/v1/endpoints/llm/retrieval/ 子包(原 retrieval.py 拆分为 9 文件子包 + façade),向量索引层在 backend/app/services/vec_index.py

核心概念

概念说明
向量通道sqlite-vec 的 vec0 虚拟表做 KNN,嵌入统一 bge-m3 1024 维;扩展不可用时降级为暴力余弦
关键词通道FTS5 BM25 全文索引,命中计数与归一分并行记账(kw_hits / kw
融合分combined = 向量余弦 × 0.55 + 关键词归一分 × 0.45(weighted 模式,常量 HYBRID_VECTOR_WEIGHT / HYBRID_KEYWORD_WEIGHT
相对水位门MIN_RELEVANCE_SCORE = 0.3,融合分低于水位即丢弃,与最佳候选差距过大的零星命中不进上下文
绝对信号兜底MIN_VECTOR_SIMILARITY = 0.55MIN_KEYWORD_HITS = 3,两路都只有弱信号的候选直接丢弃
kw_cov 豁免关键词去重覆盖度达标(≥3 个不同关键词覆盖)的候选不因相对水位门被误杀,豁免按覆盖度而非命中计数
候选池多样化向量深池 top_k*12 → 每文档 ≤3 块 → 总量 top_k*4,防单文档垄断候选位
CRAG 纠错初检零命中时放宽阈值重试一轮(CRAG_RETRY_MIN_RELEVANCE_SCORE=0.2 / CRAG_RETRY_MIN_KEYWORD_HITS=2
意图分轨枚举 / 关系 / 时间线 / 数字普查四类意图走各自结构化通道,不依赖相似度

融合架构与门槛

  • 两路融合默认 weighted 固定权重。RRF(Reciprocal Rank Fusion,RRF_K=60)保留为可切换模式(HYBRID_FUSION_MODE 环境变量),但 50 题与 56 题评测集多轮复测 RRF 均不增分,默认维持 weighted(retrieval/fusion.py:38)。
  • 双门槛判定在原通道分数上、两种融合模式同口径:RRF 只改变过阈候选的先后,不改变「过不过门槛」(retrieval/fusion.py:62 _fuse_rank)。
  • MIN_VECTOR_SIMILARITY = 0.55 是按 nomic-embed-text 校准的旧口径(无关文本对余弦通常 <0.5)。换代 bge-m3 后实际下限由 similarity_profile() 按当前嵌入模型解析:bge-m3 取 0.48,nomic 取 0.55(app/services/embedding_service.py:296)。bge-m3 无关对实测余弦 0.31–0.40,0.48 的下限仍拦得住。
  • bm25 归一塌缩修复:长文档经长度稀释、rel/(1+rel) 饱和压缩、相对归一三重压缩后真实命中会塌到水位以下,故「去重覆盖度达标」的关键词候选豁免相对水位门;豁免按 kw_cov 而非命中计数——同一个通用词在标题+正文各中一次(3+1=4)不能算多关键词证据(retrieval/fusion.py:118)。
  • 跨语豁免带:中文问英文文档时关键词通道零贡献,纯向量候选的融合分上限只有 0.55×vec。关键词真零信号且向量过模型下限(bge-m3 0.48)时按向量放行,只救跨语/纯语义形态,有任何关键词命中的候选走原口径(retrieval/fusion.py:125)。
  • 注卡加权 ×1.15、disputed 存疑单元 ×0.7,两种融合模式都乘在排序分上(retrieval/fusion.py:77)。

候选池文档级多样化

  • 向量侧拉深池 top_k*12,再按块 id 前缀归属文档去重:同一文档最多保留 3 块,总量 top_k*4 封顶(retrieval/fusion.py:42 _diversify_chunk_hits,调用点在 retrieval/core.py:426-433)。
  • 背景:库内数百条内容时,块级 top-N 会被少数热门文档的多个块占满,目标长文档整篇进不了候选——块级前 N ≠ 文档级前 N。深池 + 每文档限帽既保留深章节多块竞争锚窗的能力,又防单文档垄断。

查询扩展、CRAG 与 rerank

  • 查询扩展多变体:_expand_query_variants 生成多个查询变体合并检索,变体生成失败静默退化为单查询;CRAG 重试轮不再扩展(retrieval/core.py:754)。改写 _rewrite_query 与扩展常量集中在 retrieval/rewrite.py
  • CRAG 零命中纠错:初检无过阈结果时按序放宽重试一轮,CRAG_RETRY_ENABLED = True,放宽阈值 0.2 / 2(retrieval/core.py:46-48)。只影响零命中路径,正常命中不加重试开销。
  • rerank 永久关闭:第二段 LLM 精排(融合后取 top_k*4 候选重排)代码保留,但 HYBRID_RERANK_ENABLED 默认 false——小模型 rerank 与强模型 rerank 实测复评均不增分,默认关闭不再复评(retrieval/rewrite.py:33-40)。

意图识别分轨

相似度检索结构性解不了某些题型(如「30 个项目里投资额超 2 亿的有哪些」——30 行不可能都高分),故按意图分轨到结构化通道:

意图通道关键机制代码位置
枚举 / 筛选 / 计数聚合SQL 直拉目标文档全部 row:: 行条目线索词含筛选比较与计数形态;题面点名表/工作表整组排前;预算分级 8000/12000/16000endpoints/llm/chat.py:409retrieval/rows.py
关系 / 归属 / 影响图谱扩展_relation_intent 规则族 + 内容锚点双闸;仅关系意图跑图谱扩展,渲染「图谱推理路径」区块endpoints/llm/chat.py:392endpoints/llm/graph_expansion.py
时间线 / 历程日期行通道_TIMELINE_CUE_RE 命中时全文扫年份行,长文档补头/尾样段retrieval/core.py:121-123
同数字多出处数字普查_numeric_census 逐数字全库 ILIKE 普查,每数字独立帽;区块报「N 处命中(M 篇内容)」endpoints/llm/chat.py:442

分轨通道全是 SQL 直取,异常时静默降级为无该通道块(留 warning),不影响主对话链路。

检索健康度

  • 每次对话检索按日打点进 retrieval_metrics_daily 表((day, metric) 联合主键,value 原子累加),记录 chat 调用量与零命中次数(app/models/metrics.pyapp/services/retrieval_metrics.py)。
  • 管理端暴露近 N 天逐日计数与汇总比率(app/api/admin/endpoints/metrics.py);每周回顾复用同一张表报告本周检索健康(app/services/weekly_review_service.py)。

为什么不用独立向量库

  • 向量影子索引用 sqlite-vec 扩展,与业务数据同在一个 SQLite 文件:按维度分表 vec_embeddings_{dim}(vec0 虚拟表,cosine 距离),embedding_id 辅助列映射回 Embedding.idapp/services/vec_index.py)。
  • 本地优先的账:单文件即备份、零额外进程、零跨库一致性维护;桌面端 PyInstaller sidecar 直接打包扩展,用户无感。
  • 降级路径明确:扩展加载失败落回暴力余弦,启动日志明示当前口径(vec_available() / start())。

下一步

基于 AGPL-3.0 协议发布。