@weifuwujs/rag
v1.3.0
Published
weifuwu 向量数据面:Postgres/pgvector-first 的知识灌入与相似检索(headless,增量嵌入,元数据过滤)
Readme
向量数据面(@weifuwujs/rag)
状态:已发布(
@weifuwujs/[email protected],tagrag-v1.2.0)。路线与边界见plan/rag.md。
声明一次知识库,在**既有 Postgres(pgvector)**上得到增量灌入 + 相似检索;零外部向量库、零硬依赖。
import { defineRag } from '@weifuwujs/rag'
import postgres from 'postgres'
const rag = defineRag({
sql: postgres(process.env.DATABASE_URL!), // 或 @weifuwujs/db 的 postgresExecutor(...)
dim: 1536,
embed: async (texts) => yourEmbeddingApi(texts), // 可替换;测试可直传向量
})
await rag.migrate() // create extension vector + _rag_* + HNSW 索引(幂等)
await rag.ingest([{ id: 'policy-1', text: '……', metadata: { tenantId: 'acme' } }])
const hits = await rag.search('退款政策是什么', {
k: 5,
filter: { tenantId: { eq: 'acme' } }, // 键值全部参数化,租户隔离
minScore: 0.3,
})
// hits: [{ score, documentId, chunkIndex, text, metadata }]最小可跑示例(零依赖,node 直接运行;test/batteries/readme-examples.test.ts 会真跑它):
import assert from 'node:assert/strict'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { loadFiles } from '@weifuwujs/rag'
const dir = await mkdtemp(join(tmpdir(), 'rag-readme-'))
try {
const path = join(dir, 'policy.md')
await writeFile(path, '---\ntenant: acme\n---\n# 退款政策\n\n7 天内可退。\n')
const [document] = await loadFiles([path], { root: dir })
assert.ok(document, 'loadFiles 必须返回文档')
assert.equal(document.id, 'policy.md') // id = 相对 root 的 POSIX 路径
assert.deepEqual(document.metadata?.['headings'], ['退款政策'])
assert.deepEqual(document.metadata?.['frontMatter'], { tenant: 'acme' })
assert.match(document.text, /7 天内可退/)
} finally {
await rm(dir, { recursive: true, force: true })
}示例标注:
ts exec= 由测试真跑;ts fragment= 片段(依赖前后文,不独立运行);自足示例带包标签(如ts tasks),由测试编译。
运行期 API
| 方法 | 语义 |
| --- | --- |
| defineRag({ sql, dim, embed?, chunk?, prefix? }) | 构造期校验维度(1..2000,HNSW 上限)与前缀白名单 |
| plan() | 返回待执行 DDL(零副作用) |
| migrate({ actor?, dryRun? }) | 事务 + 独立 advisory lock → 建扩展/表/HNSW → 审计 _rag_migrations;二次为空 |
| ingest(docs, { embed?, force? }) | 文档级内容 hash 增量:未变 0 次嵌入、变更仅重嵌该篇;事务内删旧插新 |
| search(query, { k?, mode?, filter?, minScore?, withDocument?, rerank?, embed? }) | 字符串或向量;k 默认 5 / 上限 50;score = 1 - 余弦距离;mode:'hybrid' 见下节;withDocument:true 时命中额外带 documentText(父文档当前全文,逐字不截断;整页只增 1 条 id = any($1::text[]) 批量查询,零 N+1,且不改排序/分数/条数) |
| delete(id) | 级联清块;返回是否删除 |
| stats() | { documents, chunks } |
混合检索(L1.5)
const hits = await rag.search('退款政策', { mode: 'hybrid', k: 5 })
// hits[i]: { score, vectorScore?, lexicalScore?, documentId, chunkIndex, text, metadata }mode: 'vector'(默认,与 v1 行为一致);mode: 'hybrid'= 向量 +pg_trgmlexical 双路各取4k候选, JS 侧 RRF(RRF_K = 60)融合:score = Σ 1/(60+rank),并暴露vectorScore / lexicalScore便于解释与调参。- 解决「精确词/编号/专名/中文短查询」被语义近邻挤掉的问题:lexical 通道保证关键词命中进入融合。
- 迁移自动创建
pg_trgm扩展与gin ("text" gin_trgm_ops)索引(幂等,无需手工 DDL)。 - 约束:hybrid 只接受字符串查询;暂不支持
minScore(RRF 与相似度不同量纲,明确拒绝而非静默忽略)。
自定义重排(切片 C)
const hits = await rag.search('退款政策', {
k: 5,
rerank: async (query, candidates) => rerankModel(query, candidates), // 收到原始 query 与 ≤4k 候选
})- 只能返回输入候选的子集(可重排、可过滤),最终条数仍 ≤ k;无 rerank 时行为不变。
- 伪造命中 / 重复元素 / 非数组 / 抛异常 → 一律
RERANK_FAILED(不泄漏底层异常)。 withDocument在重排与截断之后挂载,只对最终 k 条取父文档全文。
离线评测(切片 D)
const report = await rag.evaluate({
k: 5,
queries: [
{ query: '退款政策', expected: ['policy-1'], mode: 'hybrid' },
{ query: queryVector, expected: ['doc-a'] }, // 向量查询同样离线可用
],
})
// report: { k, queries, recallAtK, mrr, misses }- 口径:chunk 级 top-k 检索 → 按
documentId去重;recall@k = 每条查询「期望命中数 / 期望总数」均值; MRR = 首个期望命中排名倒数均值(未命中记 0)。 misses只含未完全召回的查询(期望与实收 id),用来区分「没召回到」与「排名太靠后」。- 纯离线:只调用现有
search,零新依赖、无额外网络/事务;可直接作为 CI 回归门(如断言recallAtK >= 0.9)。
HTTP 投射(L2)
import { createApp } from '@weifuwujs/weifuwu'
const app = createApp().mount(
rag.api({
basePath: '/rag',
authorize: (ctx, { action }) =>
action === 'search'
? { where: { tenantId: ctx.user.tenantId } } // 行级策略:只能召回自己租户
: ctx.user.role === 'admin', // 写默认拒绝,除非显式放行
}),
{ openapi: '/openapi.json' },
)| 端点 | 形态 |
| --- | --- |
| POST /rag/ingest | { documents, force? } → 201 灌入报告 |
| POST /rag/search | { query, k?, mode?, filter?, minScore?, withDocument? } → { hits } |
| GET /rag/stats | { documents, chunks } |
| DELETE /rag/documents/:id | 删除(未命中 404)|
- 无
authorize→ 全端点 403(不允许裸奔检索);被拒请求零嵌入、零 SQL(授权先于一切成本)。 search的{ where }与客户端filter同通道 AND 合并(全参数化),客户端or注入无法绕过。{ where }对写操作非法(403)——不猜行级写入语义。- 错误码(兼容契约):
INVALID_*/DIMENSION_MISMATCH→ 400;未授权 → 403;EMBEDDING_FAILED/RERANK_FAILED→ 502。 - OpenAPI 3.1 文档可直接喂
openapi-typescript(门控 e2e:OPENAPI_CODEGEN_E2E=1)。
CLI(L2)
weifuwu-rag migrate --dry-run # 预览(零执行)
weifuwu-rag migrate --actor deploy # 建扩展/表/索引 + 审计
weifuwu-rag ingest --file docs.json # { documents: [...] } 或文档数组
weifuwu-rag search --vector '[1,0,0]' --k 5 --filter '{"tenantId":{"eq":"acme"}}'
weifuwu-rag search '退款政策' --mode hybrid --with-document
weifuwu-rag stats--config <file>默认./weifuwu-rag.config.ts,默认导出defineRag(...)的产物(也支持导出工厂函数)。- 退出码
0成功 /1错误;结果 JSON 只写 stdout(可管道给 jq),错误只写 stderr 且带稳定CODE:前缀。 - 只编排库的公开方法(零新 SQL 通道、零新依赖);
migrate --dry-run零副作用;输出后显式退出,不依赖 config 关闭连接。
Loader:文件 → 文档(1.3)
零依赖的 loader 协议:Loader(kind / extensions / load({ buffer, path })),
产物 LoadedFile 就是 RagDocument({ id, text, metadata? }),一行进 ingest:
import { loadFiles } from '@weifuwujs/rag'
const docs = await loadFiles(['docs/a.md', 'docs/b.html'], { root: process.cwd() })
await rag.ingest(docs) // id = 相对路径(POSIX)→ 同文件重复 ingest 幂等更新内置三个零依赖实现(保守清洗,不是 HTML/Markdown 解析器):
| loader | 扩展名 | 做什么(规则可测) |
| --- | --- | --- |
| textLoader | .txt .log | UTF-8(fatal,非法编码 → LOADER_INVALID)+ 去 BOM + 换行归一 |
| markdownLoader | .md .markdown | front matter(--- 块内 key: value,值一律字符串不猜类型)→ metadata.frontMatter;# 标题 → metadata.headings;正文保留 Markdown 语法 |
| htmlLoader | .html .htm | 去 <script>/<style>/注释、抽 <title>、去标签、解码 & < > " '、压空行 |
PDF 走插槽(重依赖不进包):pdfLoader(parse) 传入你的解析器;不传 → LOADER_UNAVAILABLE(不是空文本),
解析器抛错 → LOADER_INVALID。未知扩展名 → LOADER_UNSUPPORTED 并列出可用扩展名(不静默回退 text)。
语义与安全契约
- 增量嵌入:文档 hash = 文本 + 分块参数;仅内容变化触发重嵌。元数据更新不触发嵌入。
- 分块:段落优先 → 长段落按句 → 超长句硬切;相邻块携带
overlap尾部(默认 800/100)。 - 过滤:针对文档与块 metadata 的合并视图(文档值优先),支持
eq/neq/in/isNull/and/or; 字符串/数值/布尔按 JS 类型选择text/numeric/boolean转换;键与值全部$n参数化。 - 维度:配置维度非法 →
INVALID_DIMENSION;嵌入返回维度不符 →DIMENSION_MISMATCH且零写入。 - 错误码:
INVALID_ARGUMENT / INVALID_DIMENSION / INVALID_PREFIX / INVALID_FILTER / DIMENSION_MISMATCH / EMBEDDING_FAILED / LOADER_UNSUPPORTED / LOADER_UNAVAILABLE / LOADER_INVALID, 以CODE: detail前缀稳定暴露。 - 不猜:不存在「向量维度自动截断/补齐」;
k越界直接拒绝。
与 @weifuwujs/db 的关系
sql接受 db 的postgresExecutor(...)(执行器结构兼容);迁移锁独立(weifuwujs/rag),互不阻塞。_rag_*表带 vector 列,db 的adopt会整表跳过并给原因——两边互不干扰。- 行级权限(
{ where })在 HTTP 投影层:rag.api({ authorize }),与客户端filterAND 合并;库内直用时用filter做隔离。 - 四包对照:
docs/authorization.md——四包默认拒绝矩阵(rag 无公开检索开关)与组合边界(可执行契约)。
真库测试
docker compose up -d # 仓库根目录(pgvector/pgvector:pg18)
npm test -w @weifuwujs/rag # 服务不可达时显式 skip,绝不假绿