跳到主要内容

记忆、文档与混合检索(RAG)

30 秒导读: 一个 agent 要"记事",靠两样东西:记得说过什么(对话历史)、查得到知道什么(知识库)。Julep 把这两样都塞进 Postgres/TimescaleDB,并把最难的一块——"从一堆文档里找出跟这句话最相关的几段"——写成一组数据库里的 SQL 函数:向量相似度 + 全文检索 + 模糊匹配三路并跑,再用一套统计归一化(DBSF)融合成一个排名。本章讲这两条记忆线的存储结构与检索算法;上层"agent 怎么调用它们"在 04-tools-and-integrations.md 已讲。


1. 这是什么(零基础也能懂)

1.1 agent 为什么需要"记忆"

大模型本身是无状态的:每次调用只看得到你这次塞给它的那段文字(上下文窗口),关掉就忘。可一个有用的 agent 得跨很多轮对话、甚至跨很多天记住东西。记忆就是把"模型记不住的"落到数据库里,用的时候再捞回来拼进上下文。

Julep 把记忆分成两条互不相同的线,因为它们要解决的问题不一样:

记忆线记的是典型问题存哪
对话历史(entries)这个会话里逐条说过的话"我们上一轮聊到哪了"entries / entry_relations
知识库文档(docs)与某个 user/agent 绑定的长期知识"关于报销政策,库里怎么说"docs / docs_embeddings

一句类比:对话历史像聊天记录(按时间排,一条接一条);知识库像一个能语义搜索的笔记本(按相关性排,谁跟问题最贴谁先出)。

1.2 这一章聚焦哪一半

难点不在"存",在"取"。聊天记录顺着时间取就行;但知识库有成千上万段文字,"取出跟这句话最相关的 5 段"是个真问题——这就是 RAG(Retrieval-Augmented Generation,检索增强生成) 的检索环节。

Julep 的一个鲜明选择:把整套检索算法写进 Postgres 的 SQL 函数,而不是放在应用层或外接一个向量数据库。Python 侧只是薄薄一层——拼参数、调一个 SELECT * FROM search_hybrid(...)。所以本章大量篇幅在读 src/memory-store/migrations/ 里的 SQL 演进史。

1.3 用起来什么样

从 Python 查询层看,一次混合检索就是一句函数调用:

# 示意,基于真实签名简化;真源码见 search_docs_hybrid.py:44
results = await search_docs_hybrid(
developer_id=dev_id,
owners=[("agent", agent_id)], # 只在这个 agent 的知识库里找
text_query="报销上限是多少", # 走全文/模糊那一路
embedding=[0.01, -0.02, ...], # 1024 维,走向量那一路
k=10, # 要前 10 段
alpha=0.7, # 向量占 70% 权重,文本占 30%
)
# 返回一批 DocReference,每个带 title/content/distance(越小越相关)

背后真正干活的是数据库里的 search_hybrid 函数。本章就是把这个函数——以及它经历的近 10 版 SQL 迭代——讲透。


2. 顶层全景(它大概怎么转)

2.1 两条记忆线的部件图

┌──────────────────────────────────────────┐
│ agents-api │
│ queries/entries/* queries/docs/* │
└───────────┬──────────────────┬───────────┘
│ 对话线 │ 文档线
┌───────────────▼──────┐ ┌────────▼───────────────────┐
│ entries (hypertable)│ │ docs (hypertable) │
│ entry_relations DAG │ │ doc_owners (谁拥有这篇) │
└───────────┬──────────┘ │ docs_embeddings (向量视图) │
│ └────────┬───────────────────┘
顺时间取历史 │ │ 三路检索 + 融合
rec_sum 摘要 │ ┌────────▼───────────────────┐
│ │ SQL 检索函数(库内) │
▼ │ · search_by_vector (pgvector)│
拼进 prompt │ · search_by_text (FTS+trgm) │
│ · search_hybrid (DBSF 融合) │
└─────────────────────────────┘

怎么读这张图: 左边是对话线(时间序),右边是文档线(相关性序)。文档线的核心价值全在最下面那三个库内 SQL 函数里。

2.2 部件一句话职责

部件干什么在哪
entries逐条存对话消息,按天分片的时序表migrations/000015_entries.up.sql:31
entry_relations记消息之间的父子/摘要关系,构成一棵 DAGmigrations/000016_entry_relations.up.sql:7
rec_sum/递归摘要:把长历史压缩成短摘要以省 tokenagents_api/rec_sum/summarize.py:47
docs / doc_owners存文档分块正文,及"这篇归谁"migrations/000006_docs.up.sql:14
docs_embeddingspgai vectorizer 自动生成的向量视图migrations/000007_ann.up.sql:9
search_by_vector纯向量相似度检索migrations/000044_search_optimize.up.sql:385
search_by_text全文(tsvector)+ trigram 模糊检索migrations/000044_search_optimize.up.sql:177
search_hybrid融合前两者、DBSF 归一化排序migrations/000044_search_optimize.up.sql:484

2.3 主线走一遍(不进代码)

对话线: 每轮对话 → create_entries 插入一条 entry(带 role/content/token 数)→ 需要时 add_entry_relations 记下它是谁的后继 → 下一轮用 get_history / list_entries 顺着时间捞回来 → 历史太长时 rec_sum 挑一段旧消息压成摘要。

文档线: create_doc 把文档切块插入 docs → 后台 pgai vectorizer 异步给每块算 1024 维向量存进 docs_embeddings → 查询时把问题同时变成"一段文本 + 一个向量" → search_hybrid 三路并跑再融合 → 返回前 k 段。


3. 对话线:entries 与关系图

这节讲"聊天记录"怎么存、怎么取、怎么在太长时被压缩。

3.1 entries:一张按天分片的时序表

对话消息天然是"越写越多、基本只追加、按时间读"的数据——正是时序数据库的主场。Julep 把 entries 建成 TimescaleDB hypertable:物理上按 created_at 每天切一片,再按 session_id 哈希分 2 份。

-- migrations/000015_entries.up.sql:51 把 entries 变成时序超表
SELECT create_hypertable('entries', by_range('created_at', INTERVAL '1 day'), if_not_exists => TRUE);
SELECT add_dimension('entries', by_hash('session_id', 2), if_not_exists => TRUE);

每条 entry 的关键字段:role(user/assistant/tool/system/developer 五种枚举)、content(JSONB 数组,约束要求每个元素都是 object)、token_counttokenizer。主键是 (session_id, entry_id, created_at)——注意 created_at 进主键,是 hypertable 的硬性要求(分片键必须在主键里)。

一个值得学的坑:token 计数的触发器被刻意关掉了。 原本设计是插入后用触发器调 ai.openai_tokenize 算 token 数,但作者发现它拖慢了写入,于是整段注释掉:

-- migrations/000015_entries.up.sql:108 作者留的告解
-- FIXME: This trigger is causing the slow performance of the create_entries query
-- CREATE TRIGGER trg_optimized_update_token_count_after ...

写入热路径上宁可不算精确 token,也不让每次插消息都同步跑一次分词——这是很典型的"写多读少场景优先保写入"取舍。

老数据还会被自动压缩:超过 7 天的 entries 按 session_id 分组列式压缩,省存储(migrations/000017_compression.up.sql:13)。

3.2 entry_relations:把线性历史变成 DAG

如果对话永远是"一问一答一条线",不需要关系表。但 agent 场景里会出现分叉:同一段历史被摘要成一条新消息、工具调用挂在某条消息下、重跑某轮产生新分支。Julep 用 entry_relations 记录 (head, relation, tail) 三元组,把 entries 织成一张有向图(DAG)。

relationCITEXT(大小写不敏感文本),典型的值如工具调用关系、摘要关系等。表还维护一个 is_leaf 标志表示"这条是不是当前的叶子(最新末端)"。

巧妙处:叶子状态由触发器自动维护,不靠应用层记。 每插一条关系,触发器做两件事——把"现在有了后继的那些旧节点"标成非叶子,再判断新节点自己是不是叶子:

-- migrations/000016_entry_relations.up.sql:32 auto_update_leaf_status
UPDATE entry_relations SET is_leaf = false
WHERE session_id = NEW.session_id AND tail = NEW.head; -- 我指向谁,谁就不再是叶子

NEW.is_leaf := NOT EXISTS ( -- 没人指向我 → 我是叶子
SELECT 1 FROM entry_relations
WHERE session_id = NEW.session_id AND head = NEW.tail);

这样"哪些是当前活跃末端"这个查询,只要 WHERE is_leaf = true,不用每次遍历图。

3.3 两种读法:get_history vs list_entries

取历史有两个入口,针对不同用途:

入口用途关键点
get_history一次性拿整段历史 + 关系图把 entries 和 relations 各聚合成一个 JSON 数组一起返回
list_entries分页、排序、按关系过滤地拉消息支持 exclude_relations 排除某类关系(如已被摘要的)

get_history 的做法很紧凑——两个 CTE 分别收集消息和关系,末尾用 json_agg 打包:

-- queries/entries/get_history.py:42 一次查询返回两个数组
SELECT
(SELECT json_agg(e) FROM collected_entries e) AS entries,
(SELECT json_agg(r) FROM collected_relations r) AS relations,
...

list_entriesLEFT JOIN entry_relations,并用 er.relation != ALL($6) 把调用方指定的关系类型排除掉(queries/entries/list_entries.py:43)——这正是"取历史时跳过那些已被摘要替代的旧消息"的机制。

3.4 rec_sum:递归摘要,给历史"瘦身"

历史越长,拼进 prompt 的 token 越多、越贵。rec_sum/ 模块负责把一段旧对话压缩成更短的摘要消息,腾出上下文预算。

核心是 summarize_messages:给模型一段带编号的对话,让它输出"哪些消息可以合并成什么摘要",且必须标出被摘要消息的下标,否则无法知道该删哪些:

# rec_sum/summarize.py:84 强约束:每条摘要必须带 summarizes 索引
assert all(msg.get("summarizes") is not None for msg in summarized_messages)

prompt 里对模型的指令也点明了这条红线:用户主动分享的内容不许摘要(可能后面还要用),而且"务必标出被摘要消息的索引,否则没法识别要移除哪些"(rec_sum/summarize.py:26summarize_instructions)。摘要产出后,这些"被 summarize 的关系"就落进 entry_relations,后续 list_entriesexclude_relations 把原始消息跳过——摘要线和关系图在这里闭环。


4. 文档线:docs 的存储结构

这节讲知识库文档"存"的一面,是理解后面检索算法的地基。

4.1 docs 与 doc_owners:内容和归属分离

一篇文档在 Julep 里不是一行,而是按块(chunk)存多行:docs 表主键 (developer_id, doc_id, index),index 是块序号。为什么切块?因为 embedding 模型有输入上限,长文必须切开分别向量化。

归属单独放在 doc_owners:(developer_id, doc_id, owner_type, owner_id),owner_type 只能是 'user''agent'内容与归属分离的好处是同一篇文档理论上可挂给多个 owner,而检索时按 owner 过滤即可只搜"这个 agent 的知识"。

-- migrations/000006_docs.up.sql:14 与 :62 内容表与归属表
CREATE TABLE docs (developer_id UUID, doc_id UUID, title TEXT, content TEXT,
index INTEGER, ... metadata JSONB, ...);
CREATE TABLE doc_owners (developer_id UUID, doc_id UUID,
owner_type TEXT, owner_id UUID, ...); -- owner_type ∈ {user, agent}

create_doc 在 Python 侧对"content 是列表"的情况循环插多块,并同时插 doc_owners(queries/docs/create_doc.py:97)。

4.2 search_tsv:写入即建全文索引

docs 有个 tsvectorsearch_tsv,由触发器在插入/更新时自动生成——把 title 和 content 分词、去重音、加权重(标题权重 A 高于正文 B):

-- migrations/000006_docs.up.sql:162 docs_update_search_tsv 触发器
NEW.search_tsv :=
setweight(to_tsvector(NEW.language::regconfig, unaccent(coalesce(NEW.title, ''))), 'A') ||
setweight(to_tsvector(NEW.language::regconfig, unaccent(coalesce(NEW.content, ''))), 'B');

配套建了三种索引:search_tsv 上的 GIN(全文)、title/content 上的 GIN + gin_trgm_ops(trigram 模糊)。这解释了后面 search_by_text 为什么能同时走全文和模糊两路——索引早就备好了。

4.3 docs_embeddings:后台异步向量化

向量不是 create_doc 时同步算的,而是 TimescaleDB pgai vectorizer 后台异步生成的。migrations/000007_ann.up.sql:9ai.create_vectorizer 声明了一条流水线:

  • 载入 content 列;
  • 切块 用递归字符分割器,块大小约 30k 字符、重叠 600 字符,分隔符按优先级排队(先试 markdown 标题 \n#,再 HTML 标签 </p>,再段落 \n\n,最后退到句号、空格),尽量在语义边界切开;
  • 嵌入 用 OpenAI text-embedding-3-large,取 1024 维;
  • 索引 用 DiskANN;
  • 格式化Title: $title\n\n$chunk 再喂给模型。

结果落在 docs_embeddings_store 表,对外暴露成 docs_embeddings 视图。检索函数只跟这个视图/表打交道。

create_doc 插入 docs ──(触发器)──► search_tsv 立即就绪(全文可搜)
└─(后台 vectorizer 异步)──► docs_embeddings 稍后就绪(向量可搜)

要点: 全文索引是同步的(插完就能搜),向量是异步的(有延迟)。这个时间差在设计检索兜底时是要考虑的。

4.4 防重:内容哈希 + 归属级去重

同一段内容被重复灌进同一个 owner 的知识库是浪费。migrations/000029docs 加了 content_hash 列(title+content 的 MD5,由触发器自动算),并在 doc_owners 插入前用触发器检查:这个 owner 是否已有相同 content_hash 的文档,有就直接报错拒绝:

-- migrations/000029_duplicate_doc_prevention.up.sql:70 归属级去重
IF v_doc_exists THEN
RAISE EXCEPTION 'Document with similar content already exists for this owner (type: %, id: %)',
NEW.owner_type, NEW.owner_id;
END IF;

注意去重粒度是**"每个 owner"**,不是全局——不同 agent 各自持有同一份文档是允许的。


5. 检索算法(本章核心)

现在进入最硬的部分:给定一个问题,怎么从知识库里排出最相关的 k 段。Julep 用三种互补的匹配 + 一套融合。先讲每一路的直觉,再讲融合,最后追 SQL 的演进史。

5.1 为什么要三路而不是一路

匹配方式擅长抓不住底层
向量相似度语义/近义("薪水"匹配"工资")精确的罕见词、拼写、专有名词pgvector 余弦距离
全文检索(FTS)关键词精确命中、BM25 式打分换了说法、错别字tsvector / tsquery
trigram 模糊拼写错误、词形变化语义pg_trgm 三字母重叠

单靠向量会漏掉"精确关键词";单靠全文会漏掉"换个说法";单靠 trigram 只会字面像。三路并跑再融合,才能既懂语义又抓关键词还容错拼写。

5.2 第一路:向量相似度(pgvector)

把问题变成 1024 维向量,在 docs_embeddings 里找余弦距离最近的块。核心就是 pgvector 的 <=> 余弦距离算子:

-- 概念示意,基于 migrations/000022_vector_search.up.sql:56
(d.embedding <=> $1) AS distance -- 距离越小越近
... WHERE (d.embedding <=> $1) <= $2 -- 只要够近的
ORDER BY (d.embedding <=> $1) ASC
LIMIT ($3 * 4) -- 先多捞 4 倍候选,后面去重再截断

这里有个语义演进值得注意:最早的 000018 版本用的是 1 - (embedding <=> ...) 当"相似度"、按 DESC 排,confidence 限定在 0..1;到 000022 改成直接用原始距离 embedding <=> ...、按 ASC 排,confidence 放宽到 -1..1。也就是从"相似度语义"切到"距离语义",阈值 search_threshold := 1.0 - confidence 的含义随之改变。读这套函数时一定要认准是哪一版,否则会把方向搞反。

"多捞 N 倍候选再去重截断"是贯穿始终的套路:LIMIT (k*4) 先拿一批,再 DISTINCT ON (doc_id) 每篇只留最佳块,最后 LIMIT k

5.3 第二路 & 第三路:全文 + trigram(合在 search_by_text 里)

search_by_text 一个函数里同时干了全文和模糊两件事。演进也最曲折,分几步长出来:

① 起点(000018): 纯全文。websearch_to_tsquery 把用户输入解析成 tsquery,ts_rank_cd 打分:

-- migrations/000018_doc_search.up.sql:275
ts_rank_cd(d.search_tsv, $1, 32)::double precision as distance
... WHERE d.search_tsv @@ $1 -- @@ 是全文匹配

② 加 unaccent(000030): 建了个 english_unaccent 文本搜索配置——先去重音再做英语词干化,让 "café" 能匹配 "cafe"。搜索默认语言从此变成 english_unaccent(migrations/000030_add_unaccent_search_config.up.sql:5)。

③ 加 trigram(000031): 引入第二个 CTE trigram_results,对全文没命中的文档补一路模糊匹配,用 similarity() 取 title/content 的较大值,超过阈值就算候选,再和全文结果 UNION:

-- migrations/000031_add_trigram_search.up.sql:66
GREATEST(similarity(d.title, $6), similarity(d.content, $6))::double precision as trigram_score
... AND NOT EXISTS (SELECT 1 FROM tsv_results tr WHERE tr.doc_id = d.doc_id ...) -- 全文已命中的不重复算

④ 增强模糊(000032): 把简单的 similarity() 升级成 comprehensive_similarity——它内部再融合了三种信号:trigram 相似度、Levenshtein 编辑距离(短串才算,超阈值回退)、以及逐词的 fuzzy_word_similarity,并按查询长度动态调权重(短查询更依赖逐词匹配):

-- migrations/000032_enhance_trigram_search.up.sql:21 编辑距离与 trigram 混合
RETURN 0.7 * trgm_sim + 0.3 * norm_lev; -- enhanced_similarity 内部
-- :101 comprehensive_similarity:按查询长度加权 trigram 与逐词分
RETURN trgm_weight * trgm_sim + word_weight * word_sim;

⑤ 提速(000037 / 000044): comprehensive_similarity 很贵,不能对全表每行算。000037 的关键优化——先用可走索引的 %> 操作符粗筛出 trigram 候选,只对这一小批才跑昂贵的 comprehensive_similarity:

-- migrations/000037_speed_up_search.up.sql:119 索引友好的粗筛,再精算
AND (d.title %> $6 OR d.content %> $6) -- %> 能用 GIN trigram 索引
-- 之后 trgm_scored 这个 CTE 才对候选算 comprehensive_similarity

000044 进一步把 owner 过滤从"事后 join"改成"用 unnest 展开成 owner_filter 表再 join",并区分"要不要 trigram 分支"两条路径,避免不用模糊时白跑一遍重活。

一路 fallback 的流向:

问题文本
│ websearch_to_tsquery

[全文命中?] ──是──► ts_rank_cd 打分 ┐
│否 ├─ UNION ─► 按 score 排 ─► LIMIT k
▼ │
[%> 粗筛出候选] ─► comprehensive_similarity 精算 ┘
(只对没被全文命中的、且 trigram 粗筛过的)

5.4 融合:DBSF 分布式分数融合

现在手上有两组结果:文本一组(分数是 ts_rank/相似度)、向量一组(分数是余弦距离)。两者量纲完全不同,不能直接相加。 search_hybridDBSF(Distribution-Based Score Fusion,分布式分数融合) 先把每一组分数各自归一化到可比区间,再加权合并。

DBSF 的做法:对一组分数,用均值 ± 3 倍标准差当上下界做 min-max 归一化(3σ 覆盖绝大多数正常值,天然抗离群点):

-- migrations/000018_doc_search.up.sql:324 dbsf_normalize 核心
m := array_mean(scores); sd := array_stddev(scores);
m3d := 3 * sd + m; m_3d := m - 3 * sd; -- 均值 ± 3σ 当边界
RETURN array(SELECT (s - m_3d) / (m3d - m_3d) FROM unnest(scores) s);

归一化后按 alpha 加权,alpha 是向量权重(默认 0.7):最终 distance = 1 - (text_weight * norm_text + embedding_weight * norm_embedding),越小越靠前(migrations/000044_search_optimize.up.sql:601)。

融合的整体流水:

search_by_text ─► text_results ┐
├─ UNION ALL ─► DISTINCT ON(每篇留最佳) ─► all_results
search_by_vector ► embedding_results ┘ │

把每篇的 text_score / embedding_score 收齐 ─► 各自 dbsf_normalize ─► 加权 ─► 排序 LIMIT k

5.5 融合实现的三次重写

search_hybrid 的融合逻辑本身也 debug 了好几版,值得一看它错在哪:

版本归一化怎么做问题
000018unnest(dbsf_normalize(array_agg(...) OVER ())) 直接内联窗口窗口里 unnest 数组、行序不保证对齐,分数可能张冠李戴
000024/000028ROW_NUMBER() 给稳定行号,聚合归一化后按行号映射回去000028 引入 DISTINCT ON ... ORDER BY distance DESC 明确去重规则
000044去重键升级为 (doc_id, owner_type, owner_id, index),并给归一化数组下标做 COALESCE 兜底修正"同文档不同 owner/块"被错误合并的问题

核心教训:"把归一化后的数组按位置映射回每一行"这件事,必须有一个稳定、确定的行序(ROW_NUMBER() OVER (ORDER BY ...)),否则 norm_scores[rn] 会取错。000018 的内联窗口写法就栽在这上面,后续版本全靠显式行号修好了。

5.6 去重(000029 与查询链的呼应)

去重在两个层面发生,别混淆:

  • 写入去重(000029): 同一 owner 不许存内容哈希相同的文档(§4.4),这是入库前的防重。
  • 检索去重(DISTINCT ON): 同一次搜索里,一篇文档的多个块可能都命中,融合时 DISTINCT ON 每篇只保留最相关的一块,避免结果被同一文档刷屏。

5.7 DiskANN 标签预过滤(000044 的向量提速)

向量检索在多租户下有个隐患:DiskANN 近似最近邻索引是全局的,但每个 developer 只该搜自己的向量。若先 ANN 再过滤 developer,可能候选全被过滤光。

000044 的解法:把 developer_id 编码成一组 smallint 标签存成生成列,建带标签的 DiskANN 索引,让 ANN 在索引层就只在本 developer 的向量里找:

-- migrations/000044_search_optimize.up.sql:161 developer 标签生成列
ADD COLUMN developer_labels smallint[] GENERATED ALWAYS AS (uuid_to_smallint_labels(developer_id)) STORED;
-- :166 标签感知的 DiskANN 索引
CREATE INDEX ... USING diskann (embedding vector_cosine_ops, developer_labels) WITH (num_neighbors = 50);
-- :434 查询时用 && 数组重叠先框住本 developer
WHERE emb.developer_labels && label_filter ORDER BY distance ASC LIMIT candidate_limit;

candidate_limit := GREATEST(p_k * 8, 40) 先多捞候选,再 join 回 docs/doc_owners 精确过滤 owner。这需要 vectorscale ≥ 0.8.0(迁移开头有版本校验,migrations/000044_search_optimize.up.sql:9)。


6. Python 查询层:薄薄一层胶水

SQL 承担了全部算法,Python 侧只做三件事:拼参数、调 SQL 函数、把结果转成 Pydantic 模型。三个搜索入口一一对应三个 SQL 函数:

Python 函数调的 SQL关键默认值
search_docs_by_embeddingsearch_by_vectork=10, confidence=0.5
search_docs_by_textsearch_by_textk=3, search_language="english_unaccent"
search_docs_hybridsearch_hybridk=10, alpha=0.7, k_multiplier=7

胶水层也做了些防御和开关:

  • embedding 转字符串: pgvector 参数得是 [0.1, 0.2, ...] 字面量,Python 手动拼(queries/docs/search_docs_hybrid.py:92)。
  • trigram 默认关闭: 混合搜索里 trigram 分支被一个环境开关 gate 住,默认不开——因为它贵。只有 ENABLE_HYBRID_TRIGRAM_SEARCH=true 时才把阈值传下去:
# queries/docs/search_docs_hybrid.py:104 默认不走 trigram 分支
effective_trigram_threshold = (
trigram_similarity_threshold if enable_hybrid_trigram_search else None
)

对应 env.py:191enable_hybrid_trigram_search(默认 False)。这与 §5.3 里 search_by_text 的两条路径(similarity_threshold IS NULL 时走纯全文快路)正好咬合:默认线上跑的是"向量 + 全文",trigram 是可选增强。

  • 关键词抽取(可选): extract_keywords=True 时先用 text_to_keywords 把长句拆成关键词再用 OR 连接,提高全文召回(queries/docs/search_docs_hybrid.py:99)。

7. 巧妙之处(可借鉴的技术)

  • 算法下沉进数据库。 整套 RAG 检索是一组 SQL 函数,应用层只发一句 SELECT * FROM search_hybrid(...)。省了一次"取全量数据回应用层再算"的网络与内存开销,也让向量/全文/模糊三种索引都在数据库里被优化器统一调度。

  • embedding 缓存表。 相同 (provider, model, text, type) 的 embedding 请求先查一张 unlogged 缓存表 embeddings_cache,命中就不再调 OpenAI(migrations/000018_doc_search.up.sql:13embed_with_cache)。unlogged = 不写 WAL、更快,反正丢了能重算。

  • 先索引粗筛、再精算。 昂贵的 comprehensive_similarity 只对 %> 操作符(走 GIN trigram 索引)粗筛出的候选运行,而不是全表(migrations/000037_speed_up_search.up.sql:119)。"用便宜的可索引谓词把候选集缩小,再对小集合跑贵计算"是通用提速范式。

  • 写入即建全文、后台补向量。 全文索引随插入同步就绪,向量异步生成——把慢的(调 embedding API)挪出写入热路径。同理 token 计数触发器被关掉也是这个思路。

  • DBSF 用 3σ 抗离群。 融合异构分数时不用裸 min-max(易被一个离群高分压扁其它),而是用均值 ±3σ 当边界,天然稳健(migrations/000018_doc_search.up.sql:324)。

  • 多租户的标签感知 ANN。 把 developer 身份编码进 DiskANN 索引标签,让"只搜我的数据"在索引层完成,而非事后过滤——解决了近似检索 + 多租户过滤的经典冲突(migrations/000044_search_optimize.up.sql:166)。


8. 边界与局限(诚实)

  • 向量维度写死 1024、provider 只支持 openai。 embed_with_cache 里非 openai 直接 RAISE EXCEPTION(migrations/000018_doc_search.up.sql:28);类型 doc_search_result 与各函数签名都锁死 vector(1024)。换模型/维度要改一串迁移。

  • 向量有可见性延迟。 向量由后台 vectorizer 异步生成,新建文档在 embedding 就绪前只能被全文/trigram 搜到,向量那一路暂时是空的(§4.3)。

  • trigram 增强默认关闭。 因为贵,线上默认不开(§6),意味着默认拼写容错主要靠 unaccent + 全文,而非完整的 comprehensive_similarity

  • search_hybrid 迭代了近 10 版才稳。 分数对齐(行序映射)、去重键粒度都踩过坑(§5.5);读历史迁移时容易被旧版本误导,务必认准最终版 000044

  • 去重是 owner 级、基于 MD5。 内容哈希用 MD5(title+content),理论上有碰撞可能,且只在同一 owner 内去重,不做跨 owner 或近似去重(§4.4)。

  • token 计数不再由数据库精算。 关掉触发器后 token_count 依赖写入方传入,库本身不保证其精确(§3.1)。


9. 横向对比(同 shelf 兄弟项目)

RAG/记忆是 agent 运行时的通用关切,不同项目取舍不同:

  • 存储载体: Julep 选择"一切进 Postgres/TimescaleDB",向量、全文、时序、关系图同库;不少同类项目把向量外接到专用向量库(Pinecone/Qdrant)、把对话历史放另一套存储。同库的代价是耦合 Timescale/pgvector/vectorscale 扩展,收益是事务一致、一次查询搞定融合。
  • 融合策略: Julep 用 DBSF(3σ 归一化);另一常见做法是 RRF(Reciprocal Rank Fusion,只看排名不看分数),对量纲更不敏感但丢失分数细节。
  • 记忆分层: Julep 明确分"对话历史 DAG + 递归摘要"与"文档 RAG"两条线;部分 agent 框架把两者揉进一个统一的"memory"检索接口。

具体到本 shelf 其它子库如何实现记忆/检索,见各自子库 doc 与总库对应原理章节的对比表。


10. 代码地图(导航索引)

对话线

主题文件路径符号名
entries 时序表src/memory-store/migrations/000015_entries.up.sqlentries, create_hypertable
token 触发器(已禁用)src/memory-store/migrations/000015_entries.up.sqloptimized_update_token_count_after
关系 DAGsrc/memory-store/migrations/000016_entry_relations.up.sqlentry_relations, auto_update_leaf_status
老数据压缩src/memory-store/migrations/000017_compression.up.sqladd_compression_policy
建 entrysrc/agents-api/agents_api/queries/entries/create_entries.pycreate_entries, add_entry_relations
取整段历史src/agents-api/agents_api/queries/entries/get_history.pyget_history, history_query
分页/过滤取历史src/agents-api/agents_api/queries/entries/list_entries.pylist_entries, list_entries_query
递归摘要src/agents-api/agents_api/rec_sum/summarize.pysummarize_messages

文档线:存储

主题文件路径符号名
docs / doc_owners 表src/memory-store/migrations/000006_docs.up.sqldocs, doc_owners
全文列触发器src/memory-store/migrations/000006_docs.up.sqldocs_update_search_tsv
向量化流水线src/memory-store/migrations/000007_ann.up.sqlai.create_vectorizer, docs_embeddings
写入去重src/memory-store/migrations/000029_duplicate_doc_prevention.up.sqlgenerate_content_hash, check_duplicate_doc_owner
建文档src/agents-api/agents_api/queries/docs/create_doc.pycreate_doc, doc_query

文档线:检索

主题文件路径符号名
向量检索(最终版)src/memory-store/migrations/000044_search_optimize.up.sqlsearch_by_vector
全文+trigram(最终版)src/memory-store/migrations/000044_search_optimize.up.sqlsearch_by_text
混合融合(最终版)src/memory-store/migrations/000044_search_optimize.up.sqlsearch_hybrid
DiskANN 标签预过滤src/memory-store/migrations/000044_search_optimize.up.sqluuid_to_smallint_labels, developer_labels
DBSF 归一化src/memory-store/migrations/000018_doc_search.up.sqldbsf_normalize, array_mean, array_stddev
embedding 缓存src/memory-store/migrations/000018_doc_search.up.sqlembed_with_cache, embeddings_cache
unaccent 配置src/memory-store/migrations/000030_add_unaccent_search_config.up.sqlenglish_unaccent
trigram 引入src/memory-store/migrations/000031_add_trigram_search.up.sqltrigram_results
增强模糊相似度src/memory-store/migrations/000032_enhance_trigram_search.up.sqlcomprehensive_similarity, enhanced_similarity, fuzzy_word_similarity
索引粗筛提速src/memory-store/migrations/000037_speed_up_search.up.sqlsearch_by_text(%> 粗筛)
向量检索入口src/agents-api/agents_api/queries/docs/search_docs_by_embedding.pysearch_docs_by_embedding
文本检索入口src/agents-api/agents_api/queries/docs/search_docs_by_text.pysearch_docs_by_text
混合检索入口src/agents-api/agents_api/queries/docs/search_docs_hybrid.pysearch_docs_hybrid
trigram 开关src/agents-api/agents_api/env.pyenable_hybrid_trigram_search

相关章节:文档/检索被 agent 工具调用的入口见 04-tools-and-integrations.md;数据模型总览见 01-concepts-data-model.md;这些查询在 API/Worker 中如何被编排见 06-platform-architecture.md