检索引擎与向量存储
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.55 或 MIN_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/16000 | endpoints/llm/chat.py:409、retrieval/rows.py |
| 关系 / 归属 / 影响 | 图谱扩展 | _relation_intent 规则族 + 内容锚点双闸;仅关系意图跑图谱扩展,渲染「图谱推理路径」区块 | endpoints/llm/chat.py:392、endpoints/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.py、app/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.id(app/services/vec_index.py)。 - 本地优先的账:单文件即备份、零额外进程、零跨库一致性维护;桌面端 PyInstaller sidecar 直接打包扩展,用户无感。
- 降级路径明确:扩展加载失败落回暴力余弦,启动日志明示当前口径(
vec_available()/start())。
下一步
- 分块与索引机制:row:: 表格行、锚窗与向量/FTS 索引的上游
- 模型管理与双通道计费:检索改写、扩展所用的本地零成本模型口径
- 知识图谱 GraphRAG:关系意图背后的图谱扩展与传递闭包
- LLM Wiki:百科条目如何进入融合检索基座
- AI 对话与 RAG 问答:检索结果在对话侧的呈现与引用