Cortrix 一手解说
Cortrix 是我最近翻到的一个例外。它刚放出 v1.0.0-rc.1,给自己的定位是「为 Agent 而生的语义存储」。这句话本身也挺虚的,所以我把它 src/ 目录下六万四千行 C++ 代码翻了一遍。
结论:它不是套壳。它在 C++ 里自研了一整套 RAG 基础设施,而且有几个设计取向相当鲜明。更少见的是,它在自己的 README 里明明白白列出了哪些能力现在是坏的。
这篇文章讲三件事:它的技术底座是怎么搭的、它真正的差异化在哪、以及你该不该现在就用。
一、先把定位说清楚
Cortrix 是一个本地优先(local-first)的语义存储服务,C++17 写的后端,AGPL-3.0 协议。
它对外开了四条并列的接入路径,不是主次关系:
概念模型很干净,只有六个东西:Namespace 是隔离边界,Document 是喂进去的原料,Block 是真正被检索的最小单元,Query 是检索请求,Memory 是长期记忆,最后一个叫 Agent surface,指的就是上面那四条程序化入口。
值得注意的是「Block」这个抽象——文档切出来的块和记忆抽出来的事实,落的是同一张表、同一种类型体系。这个决定后面会反复起作用。
二、把 SQLite 用到极致
这是我觉得最有意思的部分。
Cortrix 的底层没有用 RocksDB,也没有挂外部搜索引擎。文档、块、元数据全部压在 SQLite 上,而且是多库架构:
每个 namespace / unit 独立一份 cortrix.db全局一份 catalog.db管元数据稀疏向量的倒排索引再单独一份库
PRAGMA 参数都是调过的:WAL 预写日志模式、synchronous=NORMAL、256MB 的 mmap、auto_vacuum=INCREMENTAL,还开了 FULLMUTEX 防并发打开的竞态。
自研 PHnsw:把内存图索引改成崩溃可恢复
向量索引这块,它封了一个叫 PHnsw(Persistent HNSW)的东西。内核直接把 hnswlib 的源码 vendor 进来了,但在外面套了一整套持久化机制:
AddPoint / MarkDelete
↓ 先写 hnsw.wal
↓ fdatasync 强制刷盘
↓ 再 apply 到内存图
↓ 后台线程按 entries/bytes 阈值触发 snapshot
Open() 时:最新 snapshot + replay WAL → 完成恢复
写入侧还有 group-commit 协调器(group_commit_writer.cpp)和独立的 pending log writer。
说白了,hnswlib 本身是个纯内存的图索引,它硬给改成了崩溃可恢复的。这个活儿不难想到,但做扎实很费功夫。
catalog 层藏着的心思
catalog.db 里有六张核心表,几个细节能看出设计意图:
布隆过滤器预筛:FNV-1a 双哈希,目标假阳性率默认 1%,用来对 file_hash 做去重预判,避免每次都查库 NS → Unit 路由器:现在是 1 对 1,但接口留了 1 对 N。这是明摆着给多租户和数据分片铺路 units表预留了 9 个字段:sealed_at、archived_at、vector_count、seal_idle_days……冷热分层和归档的坑位已经挖好了独立的目录级 GC 子模块
稀疏向量这块反而没用 HNSW 或 IVF,走的是传统倒排(term_id → child_id + weight),单独一个 SQLite 库,按 namespace 缓存检索器实例并配了 LRU。
三、检索:RRF,以及一个很务实的改动
融合策略用的是 RRF(Reciprocal Rank Fusion,倒数排名融合),公式 1/(k+rank),默认 k=60。
这里要强调一下:不是加权分数融合。区别很重要——RRF 只看排名不看分数,所以各路召回的分数量纲不需要可比。向量的余弦相似度和 BM25 的分数根本不在一个尺度上,用加权和就得先做归一化,而归一化本身又引入调参负担。RRF 绕过了这个问题。
主路径是两路:
向量检索走 PHnsw,距离用 1/(1+d)转成相似度关键词检索走 SQLite 自带的 FTS5 做 BM25
SQL 路由是第三路,但只在意图分类器判定为 kSql / kHybrid 时才触发。此外还有一套独立的五路融合(dense / contextualized / sparse / fts5 / hype_question)用于 SPLADE 稀疏分支。
那个务实的改动
FTS5 默认对多个查询词是隐式 AND 的。但真实用户的自然语言提问往往很长——「这个项目的向量索引是怎么做持久化的」切完词有七八个 token,要求全部命中,召回率会低到没法用。
所以 Cortrix 强行把它改成了 OR 连接的词袋 BM25,每个 token 用双引号包裹后 OR 连接。同时对 FTS5 的操作符做了注入清洗(* ( ) ^ : AND OR NOT NEAR 这些都得转义),防止用户输入把查询语法搞乱。
这是个不起眼但很实际的工程判断:教科书式的实现在真实查询分布下是不work的。
过采样与重排
检索完先取 3 倍候选(oversample),再做元数据后置过滤,最后截断到 top_k。这个顺序很关键——如果先过滤再检索,索引就用不上了。
重排用 bge-reranker-v2-m3 交叉编码器,跑 ONNX,输出 logits 过 sigmoid 归一到 0~1。外面包了一个 circuit breaker,防止模型抖动或超时引发雪崩。
值得一提的是它的输入编码是手工拼的:<s> query </s> </s> passage </s>,直接复用 vocab 里的 </s> token id。tokenizer 也是自己实现的(hf_tokenizer.cpp),同时支持 BPE 和 Unigram/SentencePiece 两套算法,全局注册表按 key("bge-m3")复用,embedder 和 reranker 共享同一份 token 表。
推理后端
embedding 用 BGE-M3(1024 维),全部本地 ONNX 推理。执行后端支持 CPU / CoreML(macOS)/ CUDA(Linux) 三选一,也可以设 auto 自动协商,优先级 CUDA → CoreML → CPU。初始化失败会自动回落到 CPU 并打一个 provider_fallback 指标。
推理层做了单次重试 + 200ms 退避,失败抛的错误带 retry_after_ms=200——这个细节后面会呼应。
四、记忆会自我修正
这块的设计比很多同类项目想得深。
数据模型是三层:
memory_sessions 会话元信息
└── interaction_log 每一轮 user/assistant 对话
└── blocks 抽取出的事实(block_type = memory)
注意第三层——记忆事实落的就是普通的 blocks 表,只是类型标记不同。为了满足 blocks→documents 的外键,它造了一个合成的「记忆文档」__cortrix_memory__。这个设计的好处是:memory 检索直接复用 QueryPipeline,天然继承了向量 + BM25 的混合检索和 RRF 排序,不用另写一套召回。
每条记忆带完整溯源字段:user_id / session_id / status / type / source_interaction_id / source_session_id。
抽取是 LLM 驱动的 few-shot JSON 抽取:拼最近 N 轮对话窗口,调 LLM 要求 response_format=json_object,解析成 {type, content, confidence} 数组。中英文两套模板按 CJK 字符启发式自动选。解析有 fallback——如果 LLM 输出不是纯 JSON,会从里面找 [...] 子串再 parse 一次。
类型决定衰减
fact(事实)和preference(偏好)永久免疫,不衰减event(事件)按exp(-λ·age_days)指数衰减,并且设了下限,不会衰减到零
这个区分很合理:「用户是左手写字的」不该随时间变淡,「用户上周三开了个会」应该。
最妙的是矛盾判定
新事实写入前,会再调一次 LLM 判断它和已有事实有没有冲突。命中冲突就把旧事实标记成 invalidated,并记录 invalidated_by_block_id——是哪条新事实把它作废的,有据可查。
同时还留了手动作废接口和撤销作废(RevokeInvalidation)。
这意味着记忆不是只写不改的日志,而是一个会自我修正的状态机。再加上 opt_out_manager(用户可以要求不被记录)和 memory_transparency(记忆可见性视图),合规相关的钩子也都在。
五、护城河不在算法
前面讲的这些,说实话都是「做得扎实」的范畴,算法上没有惊喜。
但这个项目有两个模块,我认为是它真正的差异化所在。而且它们都不是数据库层的优化。
agent_friendly:给 LLM 看的错误
src/agent_friendly/ 目录下只有一个 error.cpp,但它的头文件被几乎所有模块引用——reranker、parser、tenant、enricher、cleaning,全都 include 它。
它定义了一个跨模块统一的错误结构,每个错误必须带上:
code | CX_ERR_* |
message | |
retryable | |
category | authquota / transient / permanent / timeout |
retry_after_ms | |
structured_data |
所有子模块的 MakeXxxError(...) 都得把内部状态塞进这个 struct,再由 API / SDK / MCP 边界统一 ToJson() 序列化出去。
意图非常明确:让 LLM 拿到错误时,能自己判断该重试、该换参数、还是该认输。
想想现在的实际情况——Agent 调一个 RAG 服务,拿回来一个 HTTP 500 和一坨堆栈信息。它能干什么?要么盲目重试,要么直接放弃,要么把堆栈原文塞进上下文污染后续推理。而如果错误里明确写着 retryable: true, category: transient, retry_after_ms: 200,Agent 的行为就是可预期的。
这不是数据库优化,是为 LLM 消费而设计的 API ergonomics。我觉得这是个被严重低估的方向。
agent_trace:给 Agent 调试用的可观测性
src/agent_trace/ 是一整套 tracing 体系,但它不是给运维看的常规 APM。
核心表 agent_trace 记的是:session_id / trace_id / agent_id / method / params / result_summary / duration_ms / source / status / error_code / namespace_id。
几个设计细节很能说明取向:
1)能反查证据链。interactions_handler 支持按 interaction_id 查「这条回复到底引用了哪些源 block」。返回的 snippet 做了头 400 字节 + 尾 100 字节的截断——既能看懂上下文,又不至于把全文吐出来。这是 Agent 调试时最刚需的能力:不是「哪里慢了」,而是「它为什么这么答」。
2)权限做得很克制。 跨用户读别人的 trace 直接返回 kUnauthorized,而且不泄露那个 session 是否存在——避免被枚举探测。管理员的越权访问会写进一条独立的审计通道(ObservabilityAuditLog),不去污染常规的 operation_log,而且是结构化日志而非 metric。
3)HTTP 和 MCP 一视同仁。source 字段取值 "http" 或 "mcp",同一个查询路径无论从 SDK 还是从 MCP client 进来,都落到同一张表,可以按维度聚合。同时对接了 W3C Trace Context 标准(traceparent / tracestate / x-request-id)。
4)内建 retention。 单独的 cleanup registrar + sweeper,还对 trace 行做大小估算(160B 骨架 + params + result),超过 1MB 软上限会提示分页。这是给 SaaS 化做成本和合规控制的准备。
六、最少见的是这份诚实
讲到这里,如果你觉得这项目挺不错想上生产,请先看这一节。
Cortrix 在文档里定义了四级状态标签,然后一条一条把自己的能力标出来:
Verified— 有当前代码/规范证据支撑,且在最新验证范围内被实际跑过Verification required— 代码、规范或文档里有,但还没验到能对外声明的程度Blocked— 已知坏掉、被阻塞,或在当前运行时无法证明Roadmap— 计划中,还没做
被标成 Blocked 的有这些:
而唯一被标成 Verified 的硬指标,是 BEIR 三个数据集(SciFact、FiQA、NFCorpus)的全量语料检索质量,有固定 commit 的基准包可查。
但它紧接着自己补了一句:这不代表回答质量,也不代表生产环境的性能。
README 里还有一整段:「不要假设鉴权、租户隔离、RBAC、配额执行或记忆抽取已经生产就绪。不要把已发布的检索质量测量泛化到回答质量、延迟、成本、安全或生产性能上。」
这种主动划边界、主动自曝的写法,在开源项目里真的不多见。大多数项目的 README 是营销材料,这个更像技术尽调报告。单就这一点,我对它的工程判断力就多了几分信任。
七、要不要用,怎么上手
值得学的
把 SQLite 能力边界推到极致的思路。 多库拆分 + FTS5 做 BM25 + PRAGMA 调优,在中小规模场景下省掉了一整套外部依赖。这个取舍思路值得借鉴 HNSW + WAL 的持久化设计。 如果你也在纠结内存索引怎么落盘, src/store/phnsw/是份可读的参考实现为 Agent 消费而设计的错误与追踪契约。 这是我认为最有启发的部分,而且跟你用什么技术栈无关,明天就能抄进自己的项目
该谨慎的
鉴权、多租户隔离、RBAC、配额、记忆抽取——这五块现在都不能当生产能力用。本地开发、技术预研、读源码学设计,都没问题;真要上线,这几块得自己补,而且补的工作量不小。
上手成本
不高。Docker Compose 一条命令:
git clone https://github.com/cortrix/cortrix.git
cd cortrix
CORTRIX_SOURCE_REVISION="$(git rev-parse HEAD)" \
docker compose -f deploy/docker-compose.yml up --build --wait
curl -fsS http://127.0.0.1:8420/api/v1/system/health/ready
只需要 Git、Docker、Docker Compose。不需要 .env 文件、不需要 LLM 供应商密钥、不需要宿主机的模型工具链、不需要手动下载或转换模型。
首次启动会拉大约 1.17 GB 的固定版本模型资产,可能要几分钟;之后复用缓存卷。默认只暴露 loopback 上的 127.0.0.1:8420,在 CPU 上跑 BGE-M3 嵌入和 bge-reranker-v2-m3 重排,外部 LLM 角色和内置 Agent 默认关闭。
最后
Cortrix 是个工程完成度相当高的预发布项目。它的技术底座没有炫技,但每一处取舍都有清楚的理由;它的差异化不在算法,而在「怎么让 LLM Agent 用得稳、查得回、被管得住」这件事上。
我觉得这个方向是对的。现在大家做 RAG,注意力都在召回率和重排模型上,但真正让 Agent 应用在生产里跑不稳的,往往是错误语义含糊、证据链查不回、以及权限边界模糊这些「工程性」问题。Cortrix 至少把这三件事当成一等公民来设计了。
如果你正在自己搭 RAG 基础设施,这个项目的源码值得翻一翻。
项目地址:https://github.com/cortrix/cortrix
协议:AGPL-3.0-only(第三方材料保留各自协议)
版本:v1.0.0-rc.1
本文基于对
src/目录约 6.4 万行 C++ 源码的实际探查,以及项目 README、compatibility.md 的公开状态声明写成。文中标注为Blocked的能力状态引自项目自述,未做独立复现验证