Skip to main content
Glama

问津:内网研发资料 RAG / MCP 检索服务

把一个团队需要的公开技术文档,变成每条都能溯源、可重建、可回退的检索证据,再通过 MCP 交给编程 Agent 使用。

当前是个人实现与学习项目,只在本机完成验证,不代表任何公司、客户或生产环境部署, 也没有通过生产验收。

这个项目在解决什么

RAG 的难点通常不在“能不能检索到”,而在检索到的东西能不能被信任。本项目围绕这一点 做了三件事:

  1. 可溯源——每条证据都带上游 URL、固定版本、原始文件行号(PDF 为物理页码)和原文, 调用方可以自己核对,而不是只能相信一个分数。

  2. 可复现——语料不提交到仓库,只提交 manifest:来源 URL、固定 commit、许可、逐文件 raw_sha256 / canonical_sha256。任何人都能按它把同一份语料取回来并校验,重建出同一套 索引。索引本身是派生产物,不入库。

  3. 可回退——资料变更时新建独立候选索引,通过验证后原子切换 active 注册表,必要时一条 命令回退;旧索引在验收通过后退役,避免“伪回退”。

Related MCP server: docrag

架构

外网公开资料
  → 固定版本 / 许可 / manifest / 逐文件双 Hash
  → 按组织流程摆渡入内网
  → 多格式解析(保持原始行号与 PDF 物理页)
  → 切片(fixed / blocks / blocks-split)
  → Qdrant dense + SQLite FTS5 BM25(中文经 jieba 切分,见"中文检索")
  → RRF 融合
  → 本地 Qwen3-Reranker-0.6B
  → Top-K 原文证据(来源 / 行号或页码 / 版本)
  → 状态化 MCP 工具 search_docs
  → 客户端 Agent 依据证据作答

MCP 只返回证据,不生成最终答案。RRF 和 Reranker 分数只是排序信号,不是答案正确率; 证据充分性由调用方判断。

语料

仓库只提交 corpora/<corpus-id>/source-manifest.json不提交语料原文raw_data/ 被 Git 忽略)。原文按 manifest 从上游获取并逐字节校验。

语料

版本

许可

白名单文件

002 候选 chunk

OpenSceneGraph

3.6.5

OSGPL

29/30

—(active 为 307)

osgEarth

3.8

LGPL-3.0(含例外)

13/15

44

GDAL

3.13.1

MIT(部分组件另有许可)

14/17

344

PROJ

9.8.1

MIT(X/MIT 风格)

9/12

63

OGC 3D Tiles

1.1

OGC Document Notice

2/2

1025

OGC WMS

1.3.0

OGC Document Notice

2/2

215

各语料的来源、固定 commit、许可和逐文件 Hash 见 corpora/THIRD_PARTY_NOTICES.md

已知缺口scripts/fetch_corpus.py 目前只支持带 repository + commit 的 OSG 风格 manifest(已实测自动下载 30/30)。其余 5 个语料需要按 manifest 里的逐文件 source_url 获取,尚无对应脚本。

本机快速开始

要求:Python 3.12、uv、Docker、NVIDIA GPU,以及本地 Qwen3-Embedding-0.6B 与 Qwen3-Reranker-0.6B 模型目录。

依赖解析默认走清华源(pyproject.toml 里的 [[tool.uv.index]]uv.lock 中锁定的 也就是这套源)。这是为此项目在国内网络下的可复现安装设的;在境外网络下若解析缓慢, 把该条删掉或改用官方源重新 uv lock 即可。

这是一个有意识接受的供应链取舍default = true 意味着全部依赖的解析都经过第三方 镜像,而不只是某一个包。当前的缓解手段是安装一律以 uv.lock 为准(CI 与文档都用 uv sync --locked),锁定的是具体版本与该源;若你的环境要求只走官方源,删掉这条并 重新 uv lock 即可,代码本身不依赖这个源。

Copy-Item .env.example .env      # 填 Qdrant 密钥和本地模型路径
uv sync --locked

# 取回语料并校验双 Hash
uv run python scripts/fetch_corpus.py
uv run python scripts/verify_corpus_hashes.py

docker compose up -d qdrant
uv run python scripts/check_environment.py --models
# 建索引资产。两个入口**不读运行时注册表**,产物名取自已提交的目标配置,
# 所以新克隆(还没有注册表)也能直接跑。
uv run python scripts/build_dense_index.py --strategy blocks
uv run python scripts/build_bm25_index.py --strategy blocks

# 完整校验:确认 manifest、BM25、Qdrant 三份资产对得上。
uv run python scripts/manage_indexes.py validate configs/indexes/osg-3.6.5-blocks-dualhash.json

# 初始化运行时注册表(记录"当前 active 用哪个索引")。它不进 Git,属于本机部署状态;
# 用 init 就地生成,已存在时会拒绝覆盖。必须在索引建好并通过校验之后。
uv run python scripts/manage_indexes.py init configs/indexes/osg-3.6.5-blocks-dualhash.json `
    --reason "本机首次初始化"

# 初始化访问策略。它同样不进 Git;少了这一步,检索、列表、下载**全部拒绝**
# (默认拒绝是设计行为,不是故障)。
uv run python scripts/manage_access.py --sync

uv run python scripts/search_docs.py "Viewer 的 run() 做什么?" --top-k 2

顺序不能颠倒:语料 → 建索引 → 完整校验 → 注册表 → 访问策略 → 查询。 建索引需要索引资产、注册表初始化需要索引已通过校验,把注册表放在建索引之前会形成 "建库要先有注册表、初始化注册表要先有索引"的循环。

启动 MCP 并调用:

docker compose up -d mcp
uv run python scripts/test_mcp_client.py "Viewer 的 run() 做什么?"

TLS 与 Bearer 安全覆盖(自签名证书仅用于开发):

uv run python scripts/generate_dev_certificate.py --output-dir artifacts/tls `
  --hostname localhost --ip 127.0.0.1
docker compose -f compose.yaml -f compose.secure.yaml up -d

安全边界见 SECURITY.md

多语料检索

五个新语料各有一套隔离的候选索引,通过显式 library + version 路由查询:

uv run python scripts/search_routed.py --catalog
uv run python scripts/search_routed.py "projsync 支持哪些命令行参数?" --library PROJ
uv run python scripts/search_routed.py "WMS 和 osgEarth 的版本约定" --max-libraries 3
  • 指定 library 时必须唯一匹配,版本不符会列出可用版本;省略 library 才跨库扇出, 并受 max_libraries 限制,被跳过的库会在响应里显式列出。

  • 路由在构造时逐库校验(manifest 指纹、BM25 行数、Qdrant 点数、维度、双 Hash 元数据), 任一候选不合格就整体不建立。

通过 MCP 检索多语料

search_docs 支持指定语料;不带参数时行为不变,仍走 OSG active 单库:

{"query": "gdal_translate 支持哪些重采样方法", "library": "gdal"}   // 或 "target_id"
{"query": "Viewer 的 run() 做什么"}                                  // 仍是 OSG
  • 同一个库有多个版本时只给 library 会报错并列出可选版本,再用 versiontarget_id 指定;

  • 按权限过滤发生在解析目标之前:无权读取的语料不会出现在候选里,也不会被实际查询, 且"无权"与"不存在"对外给出完全相同的报错——否则报错差异本身就说明了语料存在;

  • 证据带自己的 corpus_id / index_target_id / download_path,不会被重标成别的语料;

  • 未配置多语料候选清单时,指定 library 会明确报"未配置",不会悄悄去查 OSG 索引。

先用 list_corpora 看有哪些库、哪些 index_state=usable

评测结果

全部为本机小样本结果,用于开发回归,不构成生产结论或第三方盲测。 题集由本项目自行编写(含 Agent 审查),不是第三方盲测。

两套数字要分开读validation(50 题)用来挑选方案,方案就是按它的表现定的, 因此偏乐观;test(40 题)在此之前封存未用,只在方案定死后跑过一次。 对外的预期性能应引用 test 的数字。

题集

用途

重排 @5 全证据

重排 @5 MRR

validation 50 题

挑方案(偏乐观)

0.96

0.917

test 40 题

封存后只跑一次

0.750

0.753

冻结 OSG 8 题(全证据 @5):baseline 5/8,加 0.6B 重排 7/8。

冻结 test 40 题:首次也是唯一一次运行

题集 evaluation/datasets/rag-test-001.jsonl,40 题覆盖 GDAL(19)与 OGC 3D Tiles(21), 51 条证据(41 条行级 + 10 条 PDF 页级)。配置与 validation 相同 (candidate_k=20、RRF k=60、002 候选、Qwen3-Reranker-0.6B)。

检索器

@5 span_recall

@5 全证据

@5 任一命中

@5 MRR

@20 全证据

dense

0.675

0.625

0.725

0.593

0.850

BM25

0.575

0.525

0.625

0.430

0.700

RRF

0.650

0.625

0.675

0.562

0.825

+ 0.6B 重排

0.812

0.750

0.875

0.753

0.875

重排单题延时 0.758 ± 0.099 秒(本机 GPU)。

分库看,两个库的问题不一样(重排 @5):

题数

全证据

MRR

瓶颈

GDAL

19

0.842

0.974

召回:证据进了候选池几乎都能排到最前

OGC 3D Tiles

21

0.667

0.553

召回与排序都弱:该库仅 2 个文件却切出 1025 个 chunk,候选池大

发现

  • 真实水平约为 test 的 0.750,低于 validation 的 0.96。两套题覆盖的库不同 (validation 为 osgEarth/WMS/PROJ,test 为 GDAL/3D Tiles),因此这 0.21 的差 既包含"按 validation 调过参",也包含"这两个库本身更难",现有数据分不开这两者

  • GDAL 的瓶颈是召回而非排序(MRR 0.974 但全证据 0.842);提升应从召回入手,不是重排。

  • 5 道题连 @20 都取不到(013/014/015/037/038),属未召回而非排错。

读数要注意的口径

  • 页级证据(10 条)只判"该页有 chunk",不校验原文是否落在文本里;

  • 指标把登记的每条证据都视为必需,包括 expected_status=insufficientrag-test-040 ——那题"未被覆盖"本身可能就是正确行为;

  • 空行不算证据。切片按空行分块,连续空行里靠后的那些不属于任何 chunk(这是切片 正确行为)。早期实现要求证据区间每一行都被覆盖,把"内容全在、中间隔了个空行" 判成未覆盖——例如证据 199-209、两段 chunk 分别是 194-204 和 206-222,缺的第 205 行 是 </div>(解析后为空)。修正后重排 @5 全证据由 0.675 升到 0.750,检索本身未变 (40/40 题 top-5 chunk ID 与修正前逐字节一致)。判定实现在 src/wenjin/coverage.pyevaluation/run_retrieval_eval.covers_span

RAGAS 复核(语义口径,与上面的词法口径互为对照)

判分模型 DeepSeek deepseek-flashreference 用题集的 answer_rubric

两个口径在问不同的问题,数值不可互替:

  • 上面的 span_recall / 全证据是词法·位置判定:登记的那几段证据取回来了吗?

  • RAGAS 是语义·答案判定:取回来的东西够不够推导出参考答案

指标

均值

最低

可用性

context_recall

0.9517

0.33

可用

context_precision

0.9642

0.75

可用

faithfulness

0.9228

0.40

不可用,不纳入结论

RAGAS 明显高于词法口径,因为题集自己写着 listed spans jointly support the rubric; not exhaustive——登记证据是充分条件而非唯一解,别处有同样内容也能答对。

为什么说判分可用(受控验证,不是只看分数):它给 expected_status=insufficientrag-test-040 打 0.0、给 partially_supported 的 039 打 0.75,与标注一致; 两轮重跑的逐题波动是 3/40 题、题均绝对差 0.0175。

生成答案与 faithfulness:做了,但判分不可信

为跑 Faithfulness 加了一层生成:讯飞 xopqwen35397b 依据检索到的 top-5 作答, reference 对照 answer_rubric这层只存在于评测里——服务端仍然不生成答案 (MCP 返回 answer_status: not_generated),契约没变。40 题全部生成成功,平均 486 字。

判分结果不可信,因此不作为结论。三条受控证据:

测试

期望

实测

检索到的上下文原文本身当答案

1.0

0.857

同一份输入连续判两次

一致

0.4 / 0.667

判分链路是否通则(context 两项)

正常

正常

同一批低分题的答案人读起来是对的(如 016 答出了 GDAL 3.11、CPL_DEBUG、默认不告警, 与参考一致),却只得 0.4。所以这是指标配这个判分模型时的噪声,不是生成质量差。

同理 FactualCorrectness 也试过并弃用:答案=参考得 1.0、答案正确但更长得 0.0、 把两者对调又变回 1.0——结果严重依赖方向。AnswerRelevancy / AnswerCorrectness 需要 embedding 模型,evaluation/ragas/ 环境里未安装。三个都不可信或不可用, 就不报数。

逐题结果与判分可靠性记录见 artifacts/frozen-test-001/ragas-retrieval-eval.json

运行记录与已知修订:跑之前发现 21 道 3D Tiles 题的 library 写成显示名 "OGC 3D Tiles",经归一化得到 ogc-3d-tiles,而语料 id 是 ogc-3dtiles无法路由 (validation 里没有 3D Tiles 题,这条路径从未被走过)。只改了 library 一列并更新 dataset_revision,题目/查询/证据/标签未动,改在见到任何结果之前。该题集现已 evaluated=true,不再作为未用过的考卷。运行记录见 artifacts/frozen-test-001/run-record.json, 逐题结果见 artifacts/frozen-test-001/test-retrieval-eval.json

validation 50 题(用于挑选方案)

candidate_k=20、RRF k=60

检索器

Recall@5

全证据@5

MRR@5

dense

0.850

0.82

0.687

BM25

0.813

0.78

0.657

RRF

0.870

0.84

0.737

+ 0.6B 重排

0.970

0.96

0.917

"全证据@5" 是混合口径,两列不可互换读:文本类证据要求命中的 chunk 覆盖到证据 行区间;PDF 证据只登记物理页、没有行号或锚点,只能判"该页有 chunk"。 一张表里同时含这两种证据,"全证据"不等于每段证据的原文都被完整覆盖。

重排单题延时 0.574 ± 0.125 秒(本机 GPU)。复现:

uv run python evaluation/run_multicorpus_eval.py

已知失败:rag-validation-050(PROJ,单条证据跨 3 个 chunk)重排后在 @5 掉出, @20 才覆盖;rag-validation-021 的证据分布在两个语料库,单库路由结构性无法覆盖。 逐题结果见 artifacts/corpus-acceptance-002/validation-retrieval-eval.json

切片

三种策略,max_chars=1600

策略

说明

五库 chunk 数

超长 chunk

blocks

按空行成块后合并;超长块整块成片

1496

162(最长 8563)

blocks-split

同上,但超长块按行、必要时按字符切到硬上限

1691

0(最长 1600)

blocks-split 的片段不重叠且连续,保留原始行号/物理页码,单行被硬切时多个片段共享同一 行号但 chunk ID 唯一。

validation 共 64 个证据区间,按判定依据拆开看(两种策略都是同样结果):

依据

区间数

结果

证据文本(按行位置合并,校验文本与范围一致)

51

0 失败

PDF 物理页(只能判"该页有 chunk")

13

不是证据原文覆盖

所以能说的是:51 个文本证据区间完整进入 chunk;13 个 PDF 证据只做到"该页有 chunk", 原文是否落在切出来的文本里没有验证,不能并进同一个数字里说"64 个全部覆盖"。 两种依据的定义见 src/wenjin/coverage.py

中文检索

FTS5 默认的 unicode61 分词器把一整段连续中文当成一个词元本接口用于查询用户信息 只索引出这一个词元,于是查 接口 命中 0 条。更糟的是一个英文标识符都不带的纯中文 query 提取不到任何词,bm25_query/fts_query 直接抛错,service.py/router.py 里的 if terms 开关把 BM25 分路整条跳过。

适用范围要说清:带英文标识符的中文查询("GeoTransform 怎么用"、"addChild 添加子节点") 旧行为下本来就能提取到英文词,BM25 一直在出力,不受本次改动影响。修复解决的是纯中文 查询与纯中文文档——国内内网资料最常见的形态。

现在索引与查询两侧都用 src/wenjin/indexing/zh.py 做同一次 jieba 切分:

  • 写入:retrieval_textsegment_cjk(路径 + 正文),中文段变成空格分隔的词。

  • 查询:query_terms() 在 ASCII 标识符之后追加中文词。ASCII 仍在前。

  • 表结构不变(仍是 tokenize='unicode61',仍只有 retrieval_text 被索引), raw_text 存的仍是未切分原文,bm25_query 返回的 text 取自 raw_text—— 引用给用户的证据原文一个字节都没变。

  • 无中文的文本 segment_cjk 逐字节原样返回,且不加载 jieba,纯英文语料不受影响。

索引与查询必须用同一版 jieba

换 jieba 版本必须重建 BM25 索引。 两边切分一旦不一致,索引里的词元和查询词元就 对不上,中文检索会静默返回空结果(不报错、不告警),只有向量一路在干活——和没做 这次改动一样难发现。uv.lock 锁死了版本,升级时请连同重建索引一起做。

jieba 是重依赖(import + 建前缀词典约 0.5 s),因此是惰性加载的:任何模块的顶层 import 都不会把它带进来(import wenjin.mcp_server 实测不加载 jieba)。服务在 create_runtime() 里调一次 prewarm(),避免首个检索请求吃掉这半秒,也避开线程池里 两个请求同时首次初始化。

已知运维风险,不粉饰:jieba 0.42.1 自 2020-01-20 起未再发版;import 时会经 pkg_resources 打一条弃用告警(setuptools 已计划移除该 API);初始化日志走 stderr (已用测试锁住,MCP 的 stdio stdout 不被污染);临时目录不可写时词典每次启动重建 (不崩,只记一条 Dump cache file failed.)。

添加自己的文档

uv run python scripts/add_corpus.py --library mylib --version 1.0 --source ./我的文档 `
    --license MIT --source-url https://example.org/docs --visibility internal

一条命令完成:扫目录 → 认格式 → 算双 Hash → 生成 manifest → 归档原文 → 检查切片 → 建候选索引 → 打印切换命令(不自动切换)。

  • --visibility internal 写入 corpora-private/(Git 忽略),public 写入 corpora/

  • 压缩包等容器格式只归档、不参与检索,原因如实写进 manifest。

  • 标为可检索却切不出任何内容的文件会让整次入库失败并指出文件名,不会静默丢弃。

扫描件:逐页判定三态——有文字层 / 没有文字层但有整页图像(送 OCR)/ 两者都没有 (空白页或纯图形页,送 OCR 也没有内容可取)。只有第二类才触发确认,提示语只陈述 检测到的事实(页数、依据),不替用户下"这是扫描件"的结论。

确认前先探测本机 MinerU:服务不在则明确告知,并把含扫描页的文件转为只归档。 非交互环境用 --ocr always / --ocr never / --yes

当前行为(与设计意图不同,按实际行为写明):

  • 同意 OCR 时,同类文件里同时有文字层页和扫描页的,文字层页保留文本层、只有扫描页 走 OCR,不会整份重跑;文件级解析器记为 mixed-text-ocr-page-v1

  • 拒绝 OCR 时整份文件只归档、不参与检索,包括其中本来完好的文字层页。 decide_ocr() 会把这个文件整个排除,所以"保留文字层页继续索引"目前没有实现PdfPlan.text_layer_onlyskipped_scan_pages 那套分支在拒绝路径上不可达。

切出来的 chunk 带 parser / ocr_derived,证据会说明正文是不是识别来的。

# 只想先看看会怎么处理,不写盘
uv run python scripts/add_corpus.py --library mylib --version 1.0 --source ./docs --dry-run

原文取件与访问控制

检索返回的每条证据都带 download_path;MCP 同端口提供原文件下载,内网用户能直接查看 原文档本体(PDF/HTML 浏览器内联打开),而不是只看抽出来的文本:

GET /files/{corpus_id}/{语料内登记路径}    # 复用同一个 Bearer 鉴权

路径是 manifest 里登记的文件路径(也就是 chunk 的 source_path 去掉 raw_data/<subdir>/ 前缀之后的部分),不是磁盘上的 raw_data/... 相对路径。 两者容易混:OSG 的登记路径形如 include/osg/Node,PROJ 的形如 man/man1/projsync.1。 证据里直接给了拼好的 download_path,照着用即可。

访问策略默认拒绝:没有策略文件的语料,检索、列表、下载全部不放行。

uv run python scripts/manage_access.py --sync              # 为已有语料生成初始策略
uv run python scripts/manage_access.py --list
uv run python scripts/manage_access.py --grant mylib-1.0 --client agent-a
  • 公开语料策略为 ["*"](显式声明公开,未配置 Bearer 的部署也可读);

  • 内部语料初始拒绝所有人,必须显式 --grant 才能被读取;

  • 被拒绝时返回 403,不伪装成 404。

MCP 另有 list_corpora 工具返回知识结构(库、版本、许可、文件数、索引状态), 按调用方权限过滤——没有权限的语料不会出现在列表里。

索引状态分三档,has_index_config 只表示"有配置提到它":

index_state

含义

no_index_config

还没有索引目标引用这份 manifest

usable

预检通过:manifest 字节未变、BM25 可读且行数相符、collection 在且点数相符

unusable

配置在但索引不可用(原因码见 index_failures

usable 是预检结论,不是验收结论:它只查"能不能服务查询"这四件事, 不查 chunk 内容与位置。完整准入以 scripts/verify_candidate_indexes.py(内部走 admit_index)为准。

边界:本项目只执行访问策略,不产生策略。系统能识别的是"哪个客户端" (Bearer 密钥映射的 client_id),不是"哪个人"。内网部署应把 configs/access/ 换成 从内网 IAM 读取的实现,代码接口(AccessPolicy.allows)不变。

索引维护

不在 active 索引上原地修改:

新 manifest → 独立 Qdrant collection 与 BM25 → 逐库/三资产验证
→ 原子切换 active 注册表 → 重启 MCP → 冒烟 → 必要时回退
uv run python -m scripts.build_multicorpus_candidates --generation 002 --strategy blocks-split
# 冻结前的只读复验:一次审计 configs/indexes 下的全部目标(含 active)。
# Qdrant 不可用时会明确报"没连上"并退出码 1,不会伪装成索引不合格或通过。
uv run python scripts/verify_candidate_indexes.py --all-targets
uv run python scripts/manage_indexes.py status
uv run python scripts/manage_indexes.py rollback --reason "..."
uv run python scripts/demo_content_update.py
uv run python scripts/demo_modify_delete.py

许可证

项目自身代码以 MIT 许可(LICENSE)。

第三方语料独立授权,不因进入本仓库而改变:OpenSceneGraph(OSGPL)、osgEarth (LGPL-3.0 含例外)、GDAL 与 PROJ(MIT 系)、OGC 规范(OGC Document Notice)。 本仓库不重新分发这些原始文件,只提交记录来源与哈希的 manifest;细则见 THIRD_PARTY_NOTICES.md

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Provides RAG (Retrieval Augmented Generation) access to technical documentation through MCP, enabling LLMs to search and retrieve relevant documentation on-demand.
    4
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables AI assistants to intelligently search and reference documentation using hybrid semantic + keyword search via MCP protocol.
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI agents to query and manage a document knowledge base via MCP, with RAG-powered search and grounded answers with citations.
    MIT