Petrichor 技术可视化 · Vectorize · Index · Recall

向量化 · 入库索引 · 召回

Petrichor 没有独立的向量数据库:切块跟着 Markdown 目录树走,向量只是目录树上多出来的一列。 这一篇把整条链路摊开——怎么切、怎么摘、怎么嵌、存哪张表、建什么索引, 以及八条召回路径分别在什么条件下生效、什么条件下静默降级。代码在 src/server/kb/src/server/assistant/src/server/ai/embedding.ts

← 返回文章列表 ← Agent 架构 GitHub ↗
一句话本质:向量是可选增强项,不是必需品——所以每一处都必须能优雅地不工作。

用户没配嵌入模型、本地跑在 SQLite、嵌入接口报错——这三件事随时可能发生。 于是全链路 best effort:能向量就向量,不能就退回关键词、退回 LLM 目录导航、退回 ilike, 但绝不因为向量不可用而让一次回答失败

split → summarize → embed → hnsw → recall → prompt

01 · 全景数据流

四种源、三块索引、一个出口

点步骤或框图节点逐层聚焦。左边四种数据源各自走不同的归一化路线,中间只有一个共用的嵌入模型,右边是三类索引,最终都汇进同一处:系统提示词。

写入 / 建索引 查询时召回
标题+摘要+正文 ≤500 字摘录 1024 维 excerpt 建 GIN 邻接表 + 关系表 余弦 top-k ts_rank · trgm N 跳链路 知识库文章 Markdown 正文 助手对话消息 assistant_message 文档库分片 doc_chunk 公开文章 已分享 · 未过期 无密码 目录树切块 parseMarkdownTree + 一次 LLM 批量摘要 contentHash 指纹缓存 脱敏摘录 sanitizeRecallExcerpt ≤500 字 · [redacted] 纯文本,不入向量 pg_trgm 相似度 星图节点与关系 concept · entity · tag reference / semantic / derived 嵌入模型 EMBEDDING 配置 · 用户自备 embedTexts / embedQuery 推荐 bge-m3 · 1024 维 pgvector 列 vector(1024) hnsw · vector_cosine_ops tree_node · message_embedding 关键词索引 GIN to_tsvector('simple') pg_trgm similarity 图索引 邻接表 + 关系表 递归 CTE / 内存 BFS 系统提示词 + 工具结果 摘要 · 召回片段(带相关度) 检索工具返回值 · 引用卡
交互提示:悬停节点查看职责与源文件;点击节点跳到对应讲解步骤。
键盘: 切步骤 · P 播放 · Esc 退出聚焦

02 · 切块实验室

不按固定字数切,按目录切

业界常见做法是「每 500 字一刀」,代价是切断语义。这里换了个思路(PageIndex 式): 按 Markdown 标题层级切成一棵目录树,每个节点天然带标题、面包屑路径与行号区间—— 既是嵌入单位,也是「让 LLM 在目录上导航」的单位。下面的编辑器完整复刻 parseMarkdownTree(同一套正则、同一套祖先栈),改一改标题层级就能看出切法。

粘一段 Markdown 试试

切出来的目录树

节点数(= 嵌入条数)
LLM 批量摘要
推理式导航可用
最长节点 token 估算

切块规则 parseMarkdownTree

标题正则/^(#{1,6})\s+(.+?)\s*#*\s*$/,支持收尾的闭合井号
围栏保护遇到 ```~~~ 翻转状态,围栏内的 # 不当标题——否则代码注释会把文档切碎
根节点position 0、depth 0,标题取文章标题,正文是首个标题之前的引言
父子关系维护 (depth → position) 祖先栈,弹掉所有 depth ≥ 当前层级的祖先,栈顶即父节点
正文区间标题行的下一行 → 下一个标题行之前;同时记录 startLine / endLine
业务键a{articleId}-{position},全库唯一;从 nodeKey 能反解出 articleId
token 估算中日韩字符算 1 token,其余按 0.25,向上取整——只用于展示与预算判断

摘要与指纹缓存 buildArticleTree

切完之后再花一次模型调用给所有章节写摘要,而不是每节点一次:

≤40 个节点有正文的节点超过 MAX_NODES_FOR_LLM_SUMMARY 就整篇退回本地摘要,不发请求
每节点 ≤400 字送进摘要提示词的正文上限,超出截断
一句话 ≤60 字要求模型逐节点输出「这一节回答了什么」,JSON 形态 { summaries: { position: 文本 } }
本地兜底失败 / JSON 解析不出来 / 单节点缺失 → localSummary:剥掉代码块、图片、链接与 Markdown 记号后截 120 字
根节点存树指纹sha256(position|depth|title|content 全表),作为整篇结构的缓存判据
跳过重建指纹一致且节点数相同且非强制 → 整篇跳过,不重复消耗模型额度
重建即替换需要重建时在一个事务里先删该文章全部节点再整批插入,不做增量 diff

03 · 向量化与入库

一个模型配置,两条写入路径

全站只有一处嵌入模型入口 ai/embedding.ts:读用户在后台配的那条默认启用的 EMBEDDING 配置,解密 API Key,按 OpenAI 兼容协议建模型。模型是用户自备的, 所以「没配」是常态而不是异常——两条写入路径对此的处理方式不同。

路径 A:知识库章节主动 + 顺带

嵌入文本由三段拼成,中间用换行连接后截断:

[title, summary ?? "", contentMd] .map(trim).filter(Boolean) .join("\n") .slice(0, 4000)

64 条/批KB_EMBED_BATCH_SIZE,一次 embedMany 后逐条 update
2000 条/次请求KB_MAX_EMBED_PER_REQUEST:避免超大知识库把接口拖超时,剩余可再点一次或跑 backfill
4000 字单节点嵌入文本上限 KB_EMBED_MAX_CHARS
只补空缺只查 embedding is null 的节点;已有向量不重算
手动入口后台「生成向量」按钮 → embedKnowledgeBaseTreeNodes,缺配置直接报错并给出建议(推荐 bge-m3)
自动入口编译 Wiki 之后 embedKnowledgeBaseTreeNodesBestEffort:无配置 / SQLite / 出错一律静默跳过,只往 warnings 里加一条,绝不影响编译结果
可观测getKbWikiEmbeddingStatus 返回 total / embedded / pending,后台直接显示「已向量化 N / M」

路径 B:对话消息后台异步

这条路径在回答的热路径上,所以设计目标是「一秒都不能拖」:

先查后补召回时只读已有向量;缺向量的补齐用 void ... .catch() 丢进后台,不 await
扫描上限 200按时间正序取该线程最近 200 条消息作候选
排除窗口内还在原文窗口里的消息不入向量(它们本来就在上下文里)
≥8 字才入库脱敏后不足 8 字的摘录直接丢弃,避免「好的」「收到」污染向量空间
16 条/批CONTEXT_RECALL_EMBED_BATCH,每次最多补 16 条,余量下轮再补
幂等写入on conflict (message_id) do update,摘录与向量一起覆盖

insert into petrichor_assistant_message_embedding (message_id, thread_id, user_id, excerpt_md, embedding, created_at) values ($1, $2, $3, $4, $5::vector, now()) on conflict (message_id) do update set excerpt_md = excluded.excerpt_md, embedding = excluded.embedding

模型配置 ai/embedding.ts · config-logic.ts

configType四类之一:CHAT / VISION / DOC_QA / EMBEDDING,各自独立选默认
解析规则resolveChatConfig(userId, null, "EMBEDDING"):取该用户 isDefault && enabled 的那条
协议统一用 createOpenAI({ apiKey, baseURL, name }) 建 provider,再取 textEmbeddingModel(config.model)
必填校验缺 API Key 或 BaseUrl 直接 400;API Key 加密落库,用时才解密
探测hasEmbeddingConfig(userId) 只查一行 id,用于所有 best-effort 分支的前置判断
维度EMBEDDING_DIMENSIONS = 1024,与两处 vector(1024) 列绑定——换维度要改迁移

索引 DDL full-migration.ts

两个扩展、两个 HNSW、一个 GIN,全部 if not exists

create extension if not exists pg_trgm; create extension if not exists vector; alter table petrichor_kb_wiki_tree_node add column if not exists embedding vector(1024); create index if not exists idx_petrichor_kb_wiki_tree_node_embedding on petrichor_kb_wiki_tree_node using hnsw (embedding vector_cosine_ops); create index if not exists idx_petrichor_assistant_message_embedding on petrichor_assistant_message_embedding using hnsw (embedding vector_cosine_ops); create index if not exists petrichor_assistant_message_embedding_fts_idx on petrichor_assistant_message_embedding using gin (to_tsvector('simple', coalesce(excerpt_md, '')));

注意 kb_wiki_tree_node.embedding 故意不在 Drizzle schema 里声明: 声明了会让 select() 全列查询在没有该列的 SQLite 本地库上直接报错。这一列只走原生 SQL 读写。

04 · 三块索引空间

哪些东西真的被向量化了

一句话答案:只有两处。文档库分片、公开文章、星图节点都不入向量——它们各有更合适的索引方式。

知识库章节 对话消息 文档库分片 公开文章 / 星图
kb_wiki_tree_node assistant_message_embedding doc_chunk kb_article · site_graph_node/edge
切块单位 Markdown 标题章节 一条消息 → 一条摘录 解析产出的文本片(≤4000 字,≤4000 条) 整篇文章 / 概念节点
向量列 ✓ vector(1024) ✓ vector(1024) ✗ 无 ✗ 无
关键词索引 内存打分(scoreSearchFields) GIN to_tsvector('simple') pg_trgm word_similarity pg_trgm similarity 加权
额外索引 目录树本身(LLM 可导航) 邻接表 + 关系表 + 递归 CTE
写入时机 编译 Wiki 后自动补 + 后台按钮手动补 召回时后台异步补 上传解析时一次写入 后台点「生成图谱」时全量重跑
隔离维度 user_id + knowledge_base_id user_id + thread_id user_id + library_id 站点级单例(图谱按 user_id 存但只有站长发布)
没有嵌入模型时 退回目录导航 + 关键词 召回整段跳过 不受影响 不受影响

还有一列向量,但已经没人读了

迁移脚本里还有第三处 vector(1024)petrichor_agent_memory.embedding, 连同它的 HNSW 索引一起保留在 full-migration.ts 里,原本用于旧问答 Agent 长期记忆的语义去重

但当前代码路径已经不再读写它——站内助手的操作员记忆走的是 assistant_operator_profile(两块 Markdown 短文)与 assistant_message_embedding(情景检索),与这张旧表互不相干。 列与索引留着,代码没有引用:这是事实,不是遗漏说明。

为什么文档分片不做向量

不是做不了,是这条线上向量的性价比不高:

  • 文档分片是定位而非理解:用户往往已经知道要查哪份文件,需要的是「第几页、哪一段」,locatorpage 比语义相似度更有用。
  • 分片量级远大于章节(单文档上限 4000 片),全量嵌入的成本与延迟都由用户的 API 额度承担。
  • pg_trgmword_similarity(query, text) 对中文子串友好,且 SQLite 分支也能用 LIKE + 词频兜底,零依赖。
  • 真需要语义时,用户可以把文档导入知识库——那条线是有向量的。

05 · 召回路径

八条召回路径,只有三条真的用向量

每条路径都标了它的触发条件与关键常量。注意第 1、2 条会被 search_knowledge 合并成一次工具调用,第 5、6 条是同一张表的两种读法。

  1. 推理式目录导航wiki-tree.ts · retrieveTreeNodesForAgent

    LLM把整棵目录([nodeKey] 标题 — 摘要)渲染给模型,让它推理该看哪几节,并要求给出 reason。

    目录超过 MAX_OUTLINE_NODES = 200 个节点直接不发请求;返回 JSON 解析失败、nodeKey 不在目录里、或重复的一律丢弃;全空则回退关键词打分。limit 夹在 1–12(默认 6),单节点正文截 1600 字。

  2. 章节向量语义检索wiki-tree.ts · semanticSearchTreeNodes

    向量把查询嵌入后按余弦距离排序取前 N,再回填面包屑路径。

    select node_key from petrichor_kb_wiki_tree_node where user_id = $1 and knowledge_base_id = $2 and embedding is not null order by embedding <=> $3::vector limit $4

    SQLite 环境直接抛错(由调用方吞掉);不设分数阈值,靠 limit 控量。

  3. 跨知识库模糊检索wiki-agent-logic.ts · searchWikiPagesAcrossKbs

    关键词search_knowledge 不传 knowledgeBaseId 且无 focus 默认库时走这条:Wiki 页与源文章各拉最近 500 条,在内存里用 scoreSearchFields 打分合并去重。

    为中文整句查询专门做了 n-gram 近邻(见下一节的打分器)——「全部歌曲下载进度」也能命中「歌曲下载进度」。

  4. 文档分片检索doc-library/library-logic.ts · searchChunks

    关键词查询按空白切最多 5 个词,ILIKE OR 先粗召回,再用 word_similarity(keyword, text) 排序,同分按 chunkIndex 升序。

    返回带 locator(形如 p.12)与 page,片段截 600 字。SQLite 分支退化为 LIKE + JS 词频打分。

  5. 线程内向量召回context-recall.ts · recallRelevantHistory

    向量被折叠出窗口的旧对话,靠这条重新被想起来。

    select message_id, excerpt_md, (1 - (embedding <=> $q::vector))::float8 as score from petrichor_assistant_message_embedding where thread_id = $1 and user_id = $2 and embedding is not null and message_id not in (窗口内消息) order by embedding <=> $q::vector limit 4

    阈值 score ≥ 0.25,查询文本截 4000 字。整条链路 try/catch 包住,任何异常都只打日志返回空数组。

  6. 跨线程情景检索operator-episodic.ts · searchOperatorHistory

    向量关键词操作员专属,跨自己全部线程。三种模式:

    keywordto_tsvector('simple') @@ plainto_tsquery('simple') + ts_ranksemantic 走 pgvector(阈值同样 0.25);both(默认)两路 Promise.all 并行,按 threadId:messageId 去重合并,语义结果排在前面。默认排除当前线程,limit 夹在 1–20,SQLite 退化为 ilike。

  7. 全站星图检索site-graph/qa-retrieval.ts · retrieveFromGraph

    图谱把问句按标点切词,逐词对概念/实体节点打分(名称精确 100 / 别名精确 95 / 互含 60×长度比+20 / 别名互含 55 / 属性 40 / 摘要 30),取每节点最高分排前 5。

    再从入口做限跳 BFS(默认 2 跳,上限 3;节点上限 60、链路上限 12),结构边不参与扩散,最后回溯出「概念 → … → 文章」的可读链路。读的是缓存后的公开载荷,不重查库。

  8. 公开文章全文检索kb/public-qa-logic.ts · searchPublicArticles

    关键词前台公开问答用,四字段加权相似度:

    similarity(title, $q) * 4 + similarity(coalesce(public_excerpt, ''), $q) * 2 + similarity(coalesce(ai_summary, ''), $q) * 2 + similarity(coalesce(content_md, ''), $q)

    先用 ILIKE 四字段 OR 过滤,取 limit × 3 行再逐条过一遍应用层可见性(未列出 / 已过期 / 带密码全部剔除),最后截到 limit(默认 8,上限 20)。

06 · 关键词打分器

中文整句查询为什么还能命中

中文用户习惯把整句话当查询(无空格),精确 includes 一定漏。 search-terms.ts 的做法是:整句命中给高分,不命中就退到二元组覆盖率, 按覆盖比例给部分分。下面完整复刻了 scoreSearchFields,改改候选文本就能看出分数从哪来。

查询与候选

打分结果

0

tokenizeSearchQuery 展开出的词项(命中标题的高亮):

整句命中100 + 查询长度 × 4,命中标题再 +40
整词命中标题 ×6 / 摘要 ×3 / 正文 ×1,按词长加权
中文近邻整词不中且为 ≥3 字中文时展开 bigram:标题覆盖率 ≥0.5 → 词长×5×覆盖率;全字段覆盖率 ≥0.6 → 词长×2×覆盖率
tokenize按空白与标点切词;≥3 字的中文词额外展开 2 / 3 字 n-gram
用在哪目录导航的关键词回退、跨库模糊检索的排序

07 · 合并、去重与注入

两路结果怎么合,合完怎么进提示词

混合检索的难点从来不是「多查一路」,而是「合起来之后还能解释」。这里的选择是:不做重排、不做分数归一,只做顺序拼接 + 去重

search_knowledge 的合并 tools/knowledge.ts

指定了知识库(或有 focus 默认库)时,两路都跑,然后按 nodeKey 去重:

  1. 先跑推理式导航 retrieveTreeNodesForAgent(LLM 选节点)
  2. 再跑向量语义 semanticSearchTreeNodes,用 try/catch 包住并记下 semanticAvailable
  3. [...treeHits, ...semanticHits] 灌进 Map,先到者胜——导航结果天然排在前面
  4. 截到 limit,返回 mode: "tree+semantic" 或降级后的 "tree"

mode 字段是给模型看的:它能据此知道这次到底有没有语义检索兜底。 不传 knowledgeBaseId 时则走完全不同的 mode: "cross_kb" 分支。

注入提示词 buildInstructionsWithContextExtras

召回片段不是伪装成对话,而是明确标注来源与可信度:

{基础系统提示词} 以下是本对话较早内容的摘要(已折叠细节,仅供连贯理解; 最近几轮原文仍在消息中): {contextSummaryMd} 以下是与当前问题相关的较早对话片段 (向量召回,供参考,非完整历史): 1. (相关度 0.62) … 2. (相关度 0.41) …

两段都是可选的:没有摘要就不写摘要段,没有召回就不写召回段。 而工具检索的结果走的是另一条路——它们以工具返回值的形态进入对话,由模型自行决定引用哪些, 最终必须调 show_citations 出引用卡,字段不得改写。

08 · 脱敏与降级

把「不可用」当成常态来设计

向量这条线上有三个随时可能缺席的前提:Postgres、嵌入配置、嵌入接口本身。每一条召回路径都得回答「它们不在时怎么办」。

场景 章节语义检索 线程内召回 跨线程情景检索 关键词 / 图谱
跑在 SQLite 抛 400「需要 PostgreSQL」,被 search_knowledge 吞成 mode: "tree" 直接 return [] keyword 退 ilike,semantic 返回空 图查询有等价内存遍历兜底
没配 EMBEDDING 检索侧 embedQuery 抛错 → 被吞成 mode: "tree";写入侧手动按钮报错并提示去配(推荐 bge-m3),自动补写静默跳过 hasEmbeddingConfig 为假直接返回空 semantic 分支返回空,只剩 FTS 不受影响
嵌入接口报错 异常上抛 → 调用方 catch → 降级为 tree catch 打日志返回空 catch 打日志返回空 不受影响
目录树没建 无节点可查,返回空 无关 无关 跨库检索仍可用(走 Wiki 页与源文章)
星图未发布 无关 无关 无关 载荷为空 → emptyMessage 指引改用全文检索

入库前脱敏 sanitizeRecallExcerpt

对话摘录会长期留在向量表里,所以清洗必须发生在入库前,而不是展示时:

sk-…/sk-[a-zA-Z0-9]{10,}/g[redacted]
键值对api_key / token / secret / password / cookie 后跟 := 的值 → 键=[redacted]
Bearer/bearer\s+[a-zA-Z0-9._\-]+/giBearer [redacted]
确认卡executionOutcome 之后 200 字 → [confirmation omitted],避免删除操作的完整参数被记住
压缩空白连续空白折成单空格,再截 500 字并补省略号
二次防御跨线程检索取出结果后再过一遍同一函数,历史脏数据也不会漏

另一处脱敏:步骤落档 redactAssistantStepInput

工具调用的入参会原样写进 assistant_step.input 用于回放, 所以落库前会递归遍历对象,把敏感键名的值换成 [redacted],数组与嵌套对象一并处理。

两处脱敏的目标不同:这一处防的是运维回放时看到密钥, 上一处防的是密钥被向量化后在别的对话里被召回出来——后者更隐蔽,也更重要。

检索链路的设计边界(known limits)

  • 向量维度硬编码 1024,与两处 vector(1024) 列绑定。换嵌入模型必须选同维度的,否则要出迁移。
  • 章节语义检索不设分数阈值,只靠 limit 控量——查询与库内容完全无关时也会返回「最不差」的几节;判断相关性的责任交给模型。
  • 混合检索不做重排(rerank)也不做分数归一:两路结果按顺序拼接去重,导航结果天然优先。可解释性换掉了一点召回质量。
  • 跨库模糊检索是内存打分,各拉 500 条封顶。知识库规模远超这个量级时,尾部内容可能进不了候选集。
  • 目录树重建是「先删后插」的整篇替换,没有节点级 diff;结构指纹一变就重跑一次摘要。
  • 对话向量的补写每轮最多 16 条,长线程需要多轮对话才能把历史补齐;补齐前召回结果会偏少。
  • 嵌入模型是用户自备的,成本与速率限制都由用户的额度承担——这也是所有批量上限存在的原因。