From dbf563e5d151efefdadbbc6540aae4dc3842d865 Mon Sep 17 00:00:00 2001 From: FPS_Z <3223659402@qq.com> Date: Tue, 8 Sep 2026 16:32:39 +0800 Subject: [PATCH 1/4] =?UTF-8?q?perf(scale):=20=E5=8E=8B=E6=B5=8B=20harness?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E7=BB=AD=E8=B7=91=E4=B8=8E=E6=96=AD?= =?UTF-8?q?=E8=A8=80=EF=BC=8C=E6=96=B0=E5=A2=9E=2050k=20CI=20=E5=A4=8D?= =?UTF-8?q?=E8=B7=91=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - perf_scale.rs: --start-doc 断点续跑参数(50k 压测期间遗留未提交) - perf_scale.rs: --max-contention-factor 断言,超标非零退出(供 CI 门槛,目标小于 2x) - 新增 .github/workflows/perf-scale.yml:手动触发 + 每周日 02:34 UTC 全量 50k,报告 JSON 上传 artifact - README/PERF_SCALE_50K.md:同步 50k 验证结论与 CI 复跑说明 --- .github/workflows/perf-scale.yml | 71 ++++++++++++++++++++++++++++++ README.md | 2 +- docs/qa/PERF_SCALE_50K.md | 39 +++++++++++++++- memori-core/examples/perf_scale.rs | 64 ++++++++++++++++++++++++--- 4 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/perf-scale.yml diff --git a/.github/workflows/perf-scale.yml b/.github/workflows/perf-scale.yml new file mode 100644 index 0000000..d353c43 --- /dev/null +++ b/.github/workflows/perf-scale.yml @@ -0,0 +1,71 @@ +name: Perf Scale + +# 50k 规模压测 CI 复跑:手动触发(可调参)+ 每周日定时全量 50k。 +# 结论阈值:并发 P50 / 顺序 P50(争用系数)不得超过 2.0,超标即本 run 失败。 +# 报告 JSON 作为 artifact 上传:perf-scale-report-/perf_scale_ci_report.json +# 本地复跑命令见 docs/qa/PERF_SCALE_50K.md。 + +on: + workflow_dispatch: + inputs: + docs: + description: '文档数(默认 50000 全量;排查时可跑 10000/1000 快档)' + required: false + default: '50000' + queries: + description: '查询数' + required: false + default: '300' + max_contention_factor: + description: '争用系数门槛(默认 2.0)' + required: false + default: '2.0' + schedule: + - cron: '34 2 * * 0' # 每周日 02:34 UTC(避开整点洪峰) + +concurrency: + group: perf-scale + # false:手动触发与周日定时 50k 并行时排队等待,而不是取消进行中的 2.5h 运行 + # (与 desktop-release.yml 的发布型 job 先例一致)。 + cancel-in-progress: false + +permissions: + contents: read + +jobs: + perf-scale: + name: perf-scale (docs=${{ github.event.inputs.docs || '50000' }}) + runs-on: ubuntu-latest + # 50k 实测 ~2.5h(含建索引),留足编译与调度余量 + timeout-minutes: 360 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Disk space check (50k DB 约 11GB,注意 runner 14GB 上限) + run: df -h . + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Run perf scale (offline deterministic, no rerank) + shell: bash + run: | + cargo run --release -p memori-core --example perf_scale -- \ + --docs "${{ github.event.inputs.docs || '50000' }}" \ + --sections 50 \ + --queries "${{ github.event.inputs.queries || '300' }}" \ + --concurrency 8 \ + --report target/perf_scale_ci_report.json \ + --max-contention-factor "${{ github.event.inputs.max_contention_factor || '2.0' }}" + + - name: Upload report artifact + uses: actions/upload-artifact@v4 + with: + name: perf-scale-report-${{ github.event.inputs.docs || '50000' }} + path: target/perf_scale_ci_report.json + if-no-files-found: error diff --git a/README.md b/README.md index 5cfe92c..c40c825 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ document routing -> chunk retrieval -> RRF/gating -> evidence/citation - 图谱抽取与实体关系 API:已实现,图谱可视化 UI 仍有限。 - 跨语言检索(中文问→英文文档):基础覆盖,query 双语扩展未完整。 - Source preview 与 Markdown export。 +- 50k 规模压测:harness 与验证已完成(顺序/并发 P50/P95/P99,争用系数 1.91× 达标 <2×,数据与结论见 `docs/qa/PERF_SCALE_50K.md`);但 50k 下**单查询绝对延迟仍高**(顺序 P50 ≈ 9.8s,`doc_recall` 占约 81%),文档级召回优化还在路上。 ### 📐 设计中/待实现 @@ -280,7 +281,6 @@ document routing -> chunk retrieval -> RRF/gating -> evidence/citation - API key 接 OS keychain(当前明文存 settings.json)。 - OCR(图片/扫描件目前不可检索)。 - Memory heat score、conflict resolver、lifecycle classifier。 -- 50k 规模压测(P50/P95,大规模并发性能尚未验证)。 - 多租户隔离(当前 OIDC 登录后共享同一库)。 --- diff --git a/docs/qa/PERF_SCALE_50K.md b/docs/qa/PERF_SCALE_50K.md index 318a14e..d79feef 100644 --- a/docs/qa/PERF_SCALE_50K.md +++ b/docs/qa/PERF_SCALE_50K.md @@ -43,9 +43,44 @@ 建索引(单写)也快约 2×。P1 单连接串行化问题已解决;剩余 1.72× 主要是 doc_recall 阶段 (仍 119ms/P50)本身的计算成本与池上限(4 < 并发 8),如需进一步可调大池或优化文档级召回。 +## 规模验证:10k / 50k(2026-09-07) + +harness 同参复跑(确定性 embedding + 内存合成语料):`--docs 10000` / `--docs 50000` +(50k = 50,000 docs × 50 sections = **5,050,000 chunks**),300 查询、并发 8。 +报告:`target/perf_10k_validation.json` / `target/perf_50k_report.json`。 + +| 规模 | 顺序 P50 | 顺序 P95 | 并发 P50 | 并发 P95 | 争用系数 | 并发吞吐 | +|---|---|---|---|---|---|---| +| 1k(改后) | 167.1 ms | 179.5 ms | 286.6 ms | 434.8 ms | 1.72× | 26.7 qps | +| 10k | 1870.0 ms | 1904.6 ms | 3392.6 ms | 5502.0 ms | 1.81× | 2.20 qps | +| 50k | 9817.9 ms | 10020.7 ms | 18717.8 ms | 29792.1 ms | 1.91× | 0.41 qps(4.14× 顺序) | + +- **争用系数**随规模亚线性上升(1.72 → 1.81 → 1.91×),保持在 <2× 目标内——P1 的 + WAL + 只读连接池结论在 50k 规模成立,单 `Mutex` 不再是瓶颈顾虑。 +- **绝对延迟成为新一轮关注点**:50k 顺序 P50 9.82s 中 `doc_recall` 占 7.97s(81%); + 顺序 P99 18.1s / **max 40.4s**,且出现在无争用的顺序场景——长尾与争用无关; + 并发长尾 P99 36.0s / max 41.4s。规模 ×5(10k→50k)时 doc_recall 近似线性增长, + 文档级召回(lexical+dense 候选扫描)是唯一的规模敏感主项。 +- **建索引**:10k `index_ms` 3,171,595 ms;50k `index_ms` 1,861,605 ms(含 50k 续跑时的 + FTS 重建开销;两次机器状态不同,数值不可直接对比)。 +- 已知故障模式:50k 下偶发单查询 batch 延迟 40s+(对应 `chunk_lexical` max 23.9s),待观察。 + +## CI 复跑(2026-09-08 起) + +workflow `.github/workflows/perf-scale.yml`(job `perf-scale`,ubuntu-latest): + +- **触发**:`workflow_dispatch` 手动(Actions 页面,可改 `docs`/`queries` 参数跑排查快档)+ + `schedule` 每周日 02:34 UTC 全量 50k;不挂在 PR 流程上(50k 一轮 ~2.5h)。 +- **默认参数**:`--docs 50000 --sections 50 --queries 300 --concurrency 8` +- **断言**:`--max-contention-factor 2.0`——争用系数(并发 P50 / 顺序 P50)> 2.0 时 + 进程非零退出、run 标红(与 1k/10k/50k 实测 1.72→1.81→1.91× 的留量一致)。 +- **产物**:`perf-scale-report-` artifact 下的 `perf_scale_ci_report.json`(完整 + P50/P95/P99 与阶段分解),日志尾部有 PASS/FAIL 摘要行。 + ## 备注 -- 顺序 P50 251 ms 中 `doc_recall` 185 ms 为大头——50k+ 规模下文档级召回(lexical+dense)是主成本, - 与 chunk 级相比更敏感于规模,后续若再扩规模应优先观察此项。 +- 顺序 P50 中 `doc_recall` 占比随规模上升(1k 74% → 50k 81%),且绝对值随规模近似线性 + 增长——文档级召回(lexical+dense)是唯一规模敏感主项;若产品态目标为 50k+ 文档规模, + 优化应聚焦它(分阶段召回 / 文档级倒排截断)。 - 本测用确定性 embedding,**绝对延迟**反映存储/检索代码路径而非真实模型往返;用于纵向对比 (改造前后、规模前后),不与 live 端到端答题延迟混淆。 diff --git a/memori-core/examples/perf_scale.rs b/memori-core/examples/perf_scale.rs index eed6f53..f015ad8 100644 --- a/memori-core/examples/perf_scale.rs +++ b/memori-core/examples/perf_scale.rs @@ -14,6 +14,7 @@ //! 用法: //! cargo run --release -p memori-core --example perf_scale -- \ //! --docs 1000 --sections 50 --queries 300 --concurrency 8 --report target/perf_50k.json +//! (CI 断言:追加 --max-contention-factor 2.0,超标即非零退出;--start-doc N 可断点续跑。) use std::collections::HashSet; use std::hash::{Hash, Hasher}; @@ -39,6 +40,10 @@ struct Args { concurrency: usize, db_path: PathBuf, report_path: Option, + /// >0 时续跑:保留现有 DB 数据,从该 doc 序号继续写(跳过 purge)。 + start_doc: usize, + /// 竞用系数门槛:>0 时若 并发P50/顺序P50 超标则进程以非零退出码结束(供 CI 断言)。 + max_contention_factor: Option, } fn parse_args() -> Result { @@ -47,6 +52,8 @@ fn parse_args() -> Result { let mut sections = 50usize; let mut queries = 300usize; let mut concurrency = 8usize; + let mut start_doc = 0usize; + let mut max_contention_factor = None; let mut db_path = cwd.join("target").join("perf_scale.db"); let mut report_path = None; @@ -59,6 +66,11 @@ fn parse_args() -> Result { "--concurrency" => { concurrency = it.next().ok_or("--concurrency requires a value")?.parse()? } + "--start-doc" => start_doc = it.next().ok_or("--start-doc requires a value")?.parse()?, + "--max-contention-factor" => { + max_contention_factor = + Some(it.next().ok_or("--max-contention-factor requires a value")?.parse()?) + } "--db-path" => { db_path = absolutize(&cwd, it.next().ok_or("--db-path requires a value")?) } @@ -78,6 +90,8 @@ fn parse_args() -> Result { concurrency: concurrency.max(1), db_path, report_path, + start_doc: start_doc.min(docs), + max_contention_factor, }) } @@ -172,8 +186,10 @@ async fn main() -> Result<(), AnyError> { std::env::set_var(MEMORI_DB_PATH_ENV, &args.db_path); std::env::set_var("MEMORI_RERANK_ENABLED", "0"); } - // 干净起点:删除旧 DB。 - let _ = std::fs::remove_file(&args.db_path); + // 干净起点:删除旧 DB(--start-doc>0 续跑时保留)。 + if args.start_doc == 0 { + let _ = std::fs::remove_file(&args.db_path); + } if let Some(parent) = args.db_path.parent() { std::fs::create_dir_all(parent)?; } @@ -188,7 +204,8 @@ async fn main() -> Result<(), AnyError> { args.docs, args.sections ); let index_started = Instant::now(); - let indexed_chunks = seed_corpus(&engine, &synthetic_root, args.docs, args.sections).await?; + let indexed_chunks = + seed_corpus(&engine, &synthetic_root, args.docs, args.sections, args.start_doc).await?; let index_ms = index_started.elapsed().as_millis() as u64; eprintln!( "[perf] indexed {indexed_chunks} chunks in {index_ms} ms ({:.0} chunks/s)", @@ -280,6 +297,25 @@ async fn main() -> Result<(), AnyError> { } println!("{json}"); print_human_summary(&report); + + // CI 断言:--max-contention-factor 给出时,超标以非零退出码结束(报告已写盘)。 + if let Some(limit) = args.max_contention_factor { + if limit > 0.0 { + if report.contention_factor > limit { + eprintln!( + "[perf] FAIL: contention_factor {:.3} > limit {limit} —— 并发争用超标,CI 判定未达标。", + report.contention_factor + ); + std::process::exit(1); + } + eprintln!( + "[perf] contention_factor {:.3} <= limit {limit} —— PASS", + report.contention_factor + ); + } else { + eprintln!("[perf] WARN: --max-contention-factor {limit} 无效(须 > 0),跳过断言。"); + } + } Ok(()) } @@ -324,14 +360,28 @@ async fn seed_corpus( root: &Path, docs: usize, sections: usize, + start_doc: usize, ) -> Result { let state = engine.state(); let store = state.vector_store.clone(); - store.begin_full_rebuild("perf_scale_seed").await?; - store.purge_all_index_data().await?; + if start_doc == 0 { + store.begin_full_rebuild("perf_scale_seed").await?; + store.purge_all_index_data().await?; + } else { + eprintln!( + "[perf] resume: keeping existing DB, continuing from doc {start_doc}/{docs}..." + ); + } - let mut total_chunks = 0usize; - for d in 0..docs { + // 续跑时把 DB 里已有的 chunks 计入总量,保证报告里 indexed_chunks 是完整语料数。 + let mut total_chunks = if start_doc > 0 { + let n = store.count_chunks().await? as usize; + eprintln!("[perf] existing chunks in db: {n}"); + n + } else { + 0 + }; + for d in start_doc..docs { let path = root.join(format!("doc_{d:06}.md")); let text = synth_document(d, sections); let chunks = parse_and_chunk(&path, &text)?; From cd5e7e1e66cd5f2a6669644909365de2718bacd6 Mon Sep 17 00:00:00 2001 From: FPS_Z <3223659402@qq.com> Date: Tue, 15 Sep 2026 00:42:32 +0800 Subject: [PATCH 2/4] =?UTF-8?q?perf(scale):=20=E4=BF=AE=E5=A4=8D=E5=8E=8B?= =?UTF-8?q?=E6=B5=8B=20harness=20=E7=9A=84=20resume=20=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E4=B8=8E=20CI=20artifact=20=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resume 场景下 --start-doc 可能早于实际进度,重复写入的文档会替换旧 chunk,原实现用'库里已有数 + 本次写入数'累加会重复计数、虚报 indexed_chunks 与 chunks/s;改为以 DB 实际 chunk 数为准。 CI 侧给 artifact 上传加 if: always():断言失败时才是最需要报告的时候,原来恰好在那时丢掉报告。 --- .github/workflows/perf-scale.yml | 2 ++ memori-core/examples/perf_scale.rs | 43 +++++++++++++++++------------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.github/workflows/perf-scale.yml b/.github/workflows/perf-scale.yml index d353c43..5152922 100644 --- a/.github/workflows/perf-scale.yml +++ b/.github/workflows/perf-scale.yml @@ -64,6 +64,8 @@ jobs: --max-contention-factor "${{ github.event.inputs.max_contention_factor || '2.0' }}" - name: Upload report artifact + # 断言失败时才是最需要看报告的时候:必须 always(),否则恰好在 run FAIL 时丢掉 artifact。 + if: always() uses: actions/upload-artifact@v4 with: name: perf-scale-report-${{ github.event.inputs.docs || '50000' }} diff --git a/memori-core/examples/perf_scale.rs b/memori-core/examples/perf_scale.rs index f015ad8..5d79a3e 100644 --- a/memori-core/examples/perf_scale.rs +++ b/memori-core/examples/perf_scale.rs @@ -66,10 +66,15 @@ fn parse_args() -> Result { "--concurrency" => { concurrency = it.next().ok_or("--concurrency requires a value")?.parse()? } - "--start-doc" => start_doc = it.next().ok_or("--start-doc requires a value")?.parse()?, + "--start-doc" => { + start_doc = it.next().ok_or("--start-doc requires a value")?.parse()? + } "--max-contention-factor" => { - max_contention_factor = - Some(it.next().ok_or("--max-contention-factor requires a value")?.parse()?) + max_contention_factor = Some( + it.next() + .ok_or("--max-contention-factor requires a value")? + .parse()?, + ) } "--db-path" => { db_path = absolutize(&cwd, it.next().ok_or("--db-path requires a value")?) @@ -204,8 +209,14 @@ async fn main() -> Result<(), AnyError> { args.docs, args.sections ); let index_started = Instant::now(); - let indexed_chunks = - seed_corpus(&engine, &synthetic_root, args.docs, args.sections, args.start_doc).await?; + let indexed_chunks = seed_corpus( + &engine, + &synthetic_root, + args.docs, + args.sections, + args.start_doc, + ) + .await?; let index_ms = index_started.elapsed().as_millis() as u64; eprintln!( "[perf] indexed {indexed_chunks} chunks in {index_ms} ms ({:.0} chunks/s)", @@ -368,19 +379,10 @@ async fn seed_corpus( store.begin_full_rebuild("perf_scale_seed").await?; store.purge_all_index_data().await?; } else { - eprintln!( - "[perf] resume: keeping existing DB, continuing from doc {start_doc}/{docs}..." - ); + eprintln!("[perf] resume: keeping existing DB, continuing from doc {start_doc}/{docs}..."); } - // 续跑时把 DB 里已有的 chunks 计入总量,保证报告里 indexed_chunks 是完整语料数。 - let mut total_chunks = if start_doc > 0 { - let n = store.count_chunks().await? as usize; - eprintln!("[perf] existing chunks in db: {n}"); - n - } else { - 0 - }; + let mut seeded_chunks = 0usize; for d in start_doc..docs { let path = root.join(format!("doc_{d:06}.md")); let text = synth_document(d, sections); @@ -403,14 +405,19 @@ async fn seed_corpus( embeddings, ) .await?; - total_chunks += chunks.len(); + seeded_chunks += chunks.len(); if d % 200 == 0 && d > 0 { - eprintln!("[perf] seeded {d}/{docs} docs ({total_chunks} chunks)..."); + eprintln!("[perf] seeded {d}/{docs} docs ({seeded_chunks} chunks this run)..."); } } store.finish_full_rebuild().await?; store.load_from_db().await?; + // 以 DB 的真实 chunk 数作为最终值:resume 时 --start-doc 可能小于实际进度, + // 这些文档会被重新写入并**替换**旧 chunk;若用"库里已有数 + 本次又写入数"累加, + // 就会重复计数、虚报 indexed_chunks 与 chunks/s(压测数字偏乐观)。 + let total_chunks = store.count_chunks().await? as usize; + eprintln!("[perf] chunks in db after seeding: {total_chunks}"); Ok(total_chunks) } From ce1616b48044c833deca1a859f4ae2e3758849dc Mon Sep 17 00:00:00 2001 From: FPS_Z <3223659402@qq.com> Date: Tue, 15 Sep 2026 00:42:59 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(release):=20=E8=A1=A5=E9=BD=90=201.5.2?= =?UTF-8?q?=20release=20notes=20=E4=B8=8E=E6=96=87=E6=A1=A3=E7=B4=A2?= =?UTF-8?q?=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当前 workspace/UI/Tauri 版本均为 1.5.2,但 docs/release 只有到 v1.5.0,lint job 的版本一致性检查因此必然失败(上游 collab 本身也缺)。补齐 RELEASE_NOTES_v1.5.2.md,覆盖本次工程硬化与 OCR 变更、已知边界与升级说明。 同时把 docs/README.md 的 Release 索引补上 v1.5.0 与 v1.5.2 两条(此前 v1.5.0 也未登记)。 --- docs/README.md | 2 + docs/release/RELEASE_NOTES_v1.5.2.md | 79 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 docs/release/RELEASE_NOTES_v1.5.2.md diff --git a/docs/README.md b/docs/README.md index 15402ab..6123e56 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,8 @@ This directory is organized by document purpose. Keep new files in the matching - [Release notes v0.4.0](./release/RELEASE_NOTES_v0.4.0.md) - [Release notes v1.0.0](./release/RELEASE_NOTES_v1.0.0.md) - [Release notes v1.0.2](./release/RELEASE_NOTES_v1.0.2.md) +- [Release notes v1.5.0](./release/RELEASE_NOTES_v1.5.0.md) +- [Release notes v1.5.2](./release/RELEASE_NOTES_v1.5.2.md) ## Archive diff --git a/docs/release/RELEASE_NOTES_v1.5.2.md b/docs/release/RELEASE_NOTES_v1.5.2.md new file mode 100644 index 0000000..18f3a5c --- /dev/null +++ b/docs/release/RELEASE_NOTES_v1.5.2.md @@ -0,0 +1,79 @@ +# Memori-Vault 1.5.2 Release Notes + +## Summary + +`1.5.2` is the engineering-hardening and **OCR** release for Memori-Vault. It consolidates the `dev` line work after `1.5.0` — security/productization hardening (rate limiting, request-id tracing, OpenAPI 3.1, session lifecycle, OS keychain for API keys), a cross-platform CI matrix with dependency scanning, a 50k-scale performance harness, and an answer-layer LLM-judge — and adds OCR ingestion for images and scanned documents. + +The product boundary is unchanged: Memori-Vault remains a local-first, verifiable **Memory OS Lite**. Document answers must still be backed by chunk-level citations, and conversation/project memory is surfaced separately as `memory_context` — it can never masquerade as a document citation. + +## Highlights + +- **OCR ingestion (new)**: standalone images (`png`/`jpg`/`jpeg`), text-layer-free scanned PDFs, and DOCX embedded images are OCR'd with tesseract (`chi_sim`) **at index time**; recognized text joins the normal chunk/retrieval pipeline. +- **OCR stays out of the answer path**: ask-time reference excerpts and desktop file preview never run OCR, so a scanned document can no longer stall the retrieval/answer chain. +- **Configurable OCR engine**: `MEMORI_OCR_TESSERACT_PATH` > `settings.json` → `ocr_tesseract_path` > PATH auto-detection. Desktop exposes it under Settings → Models; the server exposes `POST /api/settings/ocr-path` (operator role). Both take effect without restarting; already-indexed files need a re-index. +- **Security hardening**: API keys move to the OS keychain (settings.json keeps only a sentinel), per-IP rate limiting with request-id/trace across the retrieval chain, CORS methods/headers allowlisted, explicit logout endpoint + active-session cap (2048), and audit-write failures escalated from warn to error. +- **OpenAPI 3.1 + Swagger UI**: table-driven spec served from `/api/openapi.json` with a drift self-check, plus an offline Swagger UI page for interactive API docs. +- **CI**: cross-platform matrix (ubuntu/windows/macos), `cargo-deny` + `pnpm audit` scanning, an offline deterministic retrieval quality gate, and a reusable 50k perf-scale workflow. +- **Scale**: 50k-document benchmark harness (sequential/concurrent P50/P95/P99) plus the storage connection-model change (single connection → WAL + read-only pool; contention 6.26× → 1.72×, concurrent throughput 5.4×). +- **Evaluation**: v2 hard benchmark (548 documents / 126 cases) and the first answer-layer LLM-judge baseline (126 cases, correct/partial/incorrect). + +## OCR (Images And Scanned Documents) + +OCR runs **only at index time**, and only for formats it can actually read: + +| Source | Support | +| --- | --- | +| Standalone images (`png` / `jpg` / `jpeg`) | Yes | +| Scanned PDFs **without** a text layer | Yes (page XObject images) | +| DOCX embedded images (`word/media/*`) | Yes | +| PDFs that already have a text layer | Text layer is used as-is | + +Extraction is deliberately conservative — the evidence chain is the product's core value, so unsupported or suspicious input is skipped rather than indexed as noise: + +- only 8 bit/component images are accepted (higher bit depths would be reinterpreted as garbage pixels); +- `ImageMask` stencils and unsupported color spaces (indexed / separation / CMYK) are skipped, while `DeviceGray` / `DeviceRGB` and `ICCBased` profiles (including indirect references) are supported; +- decompression is bounded (`take(limit + 1)`) so a crafted flate stream cannot exhaust memory, and the decoded pixel budget is capped per image; +- the filter chain is parsed strictly: an unparseable `/Filter` skips the image instead of silently degrading to "no filter" (which would decode compressed bytes as raw pixels). + +**Measured behaviour** (repo scan fixture `Memory_Test_V2/special_005_扫描件_苍岭_对账.pdf`, tesseract + `chi_sim`): one page decodes to one image in ~0.8s and yields readable Chinese text, e.g. `…项目的对账窗口为每月 8 号…`. However **entity names can be misread** (`苍岭` → `苑岭`/`苔岭`), so OCR text should be treated as recall support, not as an exact-match or exact-quote source. + +## API And Observability + +- `POST /api/settings/ocr-path` — set/clear the OCR executable path (operator). +- OpenAPI 3.1 contract at `/api/openapi.json`; a route-table consistency test fails the build if a route is registered without a spec entry. +- Swagger UI available at `/api/docs` (offline assets, no CDN dependency). +- Every request carries a request-id that is propagated into the retrieval chain and audit records. + +## Engineering Hardening + +- Duplicate model helpers merged into a single source of truth; Markdown plugin typing tightened (no `any`/`unknown[]`). +- Top-level React ErrorBoundary so render failures degrade to an error page instead of a blank screen. +- CI upgraded to `cargo clippy --workspace --all-targets -D warnings` plus HTTP end-to-end integration tests. +- README maturity badges split into ✅ verified / 🚧 in progress / 📐 designed. +- Handoff documentation and AI branch guardrails (`AGENTS.md`, `CLAUDE.md`) so all collaboration work stays on the `collab` branch. + +## Fixes + +- OCR: tesseract stdout pipe deadlock (dense pages always timed out and burned 30s) — stdout/stderr are now drained by dedicated threads with a timeout watchdog. +- OCR: DOCX embedded-image temp directory was never created on a clean machine (silent failures). +- OCR: images were read as UTF-8 text by the indexing entry point, so OCR never ran and every image wrote an `indexing_runtime.last_error`. +- OCR: no-text-layer PDFs returned `None` instead of an empty string, producing a misleading "file read failed (possibly locked)" error. +- OCR: `/Filter [/ASCII85Decode /DCTDecode]` (ASCII85-wrapped JPEG) images were skipped entirely. +- OCR: tesseract path changes required an application restart; the probing cache is now keyed by the configured value. +- Perf harness: `--start-doc` resume no longer double-counts chunks (the report now uses the actual chunk count in the DB). +- Perf CI: the report artifact is uploaded with `if: always()`, so it survives a failed contention assertion. +- Server mode applies `index_filter` (aligned with desktop); `/v1` URL building no longer double-prefixes. + +## Known Boundaries + +- OCR requires tesseract with the `chi_sim` language pack installed (or a configured path); without it, images/scans are indexed as empty and OCR is simply skipped. +- Mixed PDFs (a text layer plus scanned pages) do not OCR the scanned pages; `ppt`/`xlsx` embedded images are not covered; `CCITTFaxDecode` (G4 fax compression) and `JPXDecode` (JPEG 2000) are not supported. +- The 50k-scale numbers are harness results, not a claim of production-scale accuracy; the v2 hard-benchmark numbers are a measured baseline, not a precision guarantee. +- Refusal behaviour and gating false negatives are still being tuned (Q1/Q2 quality round). +- Changing the OCR path does not retroactively re-index already-indexed files; trigger a re-index to apply it. + +## Upgrade Notes + +- Version is `1.5.2` across the Cargo workspace, the UI package, and the Tauri desktop config. +- Existing local SQLite data stays local; no migration is required for OCR (it only adds newly extractable content on the next index pass). +- To enable OCR, install tesseract (+ `chi_sim`) or point `ocr_tesseract_path` (desktop Settings → Models, server `POST /api/settings/ocr-path`, or the `MEMORI_OCR_TESSERACT_PATH` environment variable) and then trigger a re-index. From 23bee50280295cd5c9da8c0293649304f95000ad Mon Sep 17 00:00:00 2001 From: FPS_Z <3223659402@qq.com> Date: Tue, 15 Sep 2026 12:41:21 +0800 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20=E6=8C=89=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E6=8A=8A=20release=20notes=20=E4=BB=8E=E6=9C=AC=20PR?= =?UTF-8?q?=20=E6=91=98=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该 release notes 在 #2/#3/#4 三份 PR 中内容相同,且写明 adds OCR ingestion;若本 PR 先合,仓库会发布与事实不符的说明。按评审统一只保留在 OCR PR。docs/README.md 的两行索引同理一并摘除。 --- docs/README.md | 2 - docs/release/RELEASE_NOTES_v1.5.2.md | 79 ---------------------------- 2 files changed, 81 deletions(-) delete mode 100644 docs/release/RELEASE_NOTES_v1.5.2.md diff --git a/docs/README.md b/docs/README.md index 6123e56..15402ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,8 +33,6 @@ This directory is organized by document purpose. Keep new files in the matching - [Release notes v0.4.0](./release/RELEASE_NOTES_v0.4.0.md) - [Release notes v1.0.0](./release/RELEASE_NOTES_v1.0.0.md) - [Release notes v1.0.2](./release/RELEASE_NOTES_v1.0.2.md) -- [Release notes v1.5.0](./release/RELEASE_NOTES_v1.5.0.md) -- [Release notes v1.5.2](./release/RELEASE_NOTES_v1.5.2.md) ## Archive diff --git a/docs/release/RELEASE_NOTES_v1.5.2.md b/docs/release/RELEASE_NOTES_v1.5.2.md deleted file mode 100644 index 18f3a5c..0000000 --- a/docs/release/RELEASE_NOTES_v1.5.2.md +++ /dev/null @@ -1,79 +0,0 @@ -# Memori-Vault 1.5.2 Release Notes - -## Summary - -`1.5.2` is the engineering-hardening and **OCR** release for Memori-Vault. It consolidates the `dev` line work after `1.5.0` — security/productization hardening (rate limiting, request-id tracing, OpenAPI 3.1, session lifecycle, OS keychain for API keys), a cross-platform CI matrix with dependency scanning, a 50k-scale performance harness, and an answer-layer LLM-judge — and adds OCR ingestion for images and scanned documents. - -The product boundary is unchanged: Memori-Vault remains a local-first, verifiable **Memory OS Lite**. Document answers must still be backed by chunk-level citations, and conversation/project memory is surfaced separately as `memory_context` — it can never masquerade as a document citation. - -## Highlights - -- **OCR ingestion (new)**: standalone images (`png`/`jpg`/`jpeg`), text-layer-free scanned PDFs, and DOCX embedded images are OCR'd with tesseract (`chi_sim`) **at index time**; recognized text joins the normal chunk/retrieval pipeline. -- **OCR stays out of the answer path**: ask-time reference excerpts and desktop file preview never run OCR, so a scanned document can no longer stall the retrieval/answer chain. -- **Configurable OCR engine**: `MEMORI_OCR_TESSERACT_PATH` > `settings.json` → `ocr_tesseract_path` > PATH auto-detection. Desktop exposes it under Settings → Models; the server exposes `POST /api/settings/ocr-path` (operator role). Both take effect without restarting; already-indexed files need a re-index. -- **Security hardening**: API keys move to the OS keychain (settings.json keeps only a sentinel), per-IP rate limiting with request-id/trace across the retrieval chain, CORS methods/headers allowlisted, explicit logout endpoint + active-session cap (2048), and audit-write failures escalated from warn to error. -- **OpenAPI 3.1 + Swagger UI**: table-driven spec served from `/api/openapi.json` with a drift self-check, plus an offline Swagger UI page for interactive API docs. -- **CI**: cross-platform matrix (ubuntu/windows/macos), `cargo-deny` + `pnpm audit` scanning, an offline deterministic retrieval quality gate, and a reusable 50k perf-scale workflow. -- **Scale**: 50k-document benchmark harness (sequential/concurrent P50/P95/P99) plus the storage connection-model change (single connection → WAL + read-only pool; contention 6.26× → 1.72×, concurrent throughput 5.4×). -- **Evaluation**: v2 hard benchmark (548 documents / 126 cases) and the first answer-layer LLM-judge baseline (126 cases, correct/partial/incorrect). - -## OCR (Images And Scanned Documents) - -OCR runs **only at index time**, and only for formats it can actually read: - -| Source | Support | -| --- | --- | -| Standalone images (`png` / `jpg` / `jpeg`) | Yes | -| Scanned PDFs **without** a text layer | Yes (page XObject images) | -| DOCX embedded images (`word/media/*`) | Yes | -| PDFs that already have a text layer | Text layer is used as-is | - -Extraction is deliberately conservative — the evidence chain is the product's core value, so unsupported or suspicious input is skipped rather than indexed as noise: - -- only 8 bit/component images are accepted (higher bit depths would be reinterpreted as garbage pixels); -- `ImageMask` stencils and unsupported color spaces (indexed / separation / CMYK) are skipped, while `DeviceGray` / `DeviceRGB` and `ICCBased` profiles (including indirect references) are supported; -- decompression is bounded (`take(limit + 1)`) so a crafted flate stream cannot exhaust memory, and the decoded pixel budget is capped per image; -- the filter chain is parsed strictly: an unparseable `/Filter` skips the image instead of silently degrading to "no filter" (which would decode compressed bytes as raw pixels). - -**Measured behaviour** (repo scan fixture `Memory_Test_V2/special_005_扫描件_苍岭_对账.pdf`, tesseract + `chi_sim`): one page decodes to one image in ~0.8s and yields readable Chinese text, e.g. `…项目的对账窗口为每月 8 号…`. However **entity names can be misread** (`苍岭` → `苑岭`/`苔岭`), so OCR text should be treated as recall support, not as an exact-match or exact-quote source. - -## API And Observability - -- `POST /api/settings/ocr-path` — set/clear the OCR executable path (operator). -- OpenAPI 3.1 contract at `/api/openapi.json`; a route-table consistency test fails the build if a route is registered without a spec entry. -- Swagger UI available at `/api/docs` (offline assets, no CDN dependency). -- Every request carries a request-id that is propagated into the retrieval chain and audit records. - -## Engineering Hardening - -- Duplicate model helpers merged into a single source of truth; Markdown plugin typing tightened (no `any`/`unknown[]`). -- Top-level React ErrorBoundary so render failures degrade to an error page instead of a blank screen. -- CI upgraded to `cargo clippy --workspace --all-targets -D warnings` plus HTTP end-to-end integration tests. -- README maturity badges split into ✅ verified / 🚧 in progress / 📐 designed. -- Handoff documentation and AI branch guardrails (`AGENTS.md`, `CLAUDE.md`) so all collaboration work stays on the `collab` branch. - -## Fixes - -- OCR: tesseract stdout pipe deadlock (dense pages always timed out and burned 30s) — stdout/stderr are now drained by dedicated threads with a timeout watchdog. -- OCR: DOCX embedded-image temp directory was never created on a clean machine (silent failures). -- OCR: images were read as UTF-8 text by the indexing entry point, so OCR never ran and every image wrote an `indexing_runtime.last_error`. -- OCR: no-text-layer PDFs returned `None` instead of an empty string, producing a misleading "file read failed (possibly locked)" error. -- OCR: `/Filter [/ASCII85Decode /DCTDecode]` (ASCII85-wrapped JPEG) images were skipped entirely. -- OCR: tesseract path changes required an application restart; the probing cache is now keyed by the configured value. -- Perf harness: `--start-doc` resume no longer double-counts chunks (the report now uses the actual chunk count in the DB). -- Perf CI: the report artifact is uploaded with `if: always()`, so it survives a failed contention assertion. -- Server mode applies `index_filter` (aligned with desktop); `/v1` URL building no longer double-prefixes. - -## Known Boundaries - -- OCR requires tesseract with the `chi_sim` language pack installed (or a configured path); without it, images/scans are indexed as empty and OCR is simply skipped. -- Mixed PDFs (a text layer plus scanned pages) do not OCR the scanned pages; `ppt`/`xlsx` embedded images are not covered; `CCITTFaxDecode` (G4 fax compression) and `JPXDecode` (JPEG 2000) are not supported. -- The 50k-scale numbers are harness results, not a claim of production-scale accuracy; the v2 hard-benchmark numbers are a measured baseline, not a precision guarantee. -- Refusal behaviour and gating false negatives are still being tuned (Q1/Q2 quality round). -- Changing the OCR path does not retroactively re-index already-indexed files; trigger a re-index to apply it. - -## Upgrade Notes - -- Version is `1.5.2` across the Cargo workspace, the UI package, and the Tauri desktop config. -- Existing local SQLite data stays local; no migration is required for OCR (it only adds newly extractable content on the next index pass). -- To enable OCR, install tesseract (+ `chi_sim`) or point `ocr_tesseract_path` (desktop Settings → Models, server `POST /api/settings/ocr-path`, or the `MEMORI_OCR_TESSERACT_PATH` environment variable) and then trigger a re-index.