python脚本通过docling解析文档qrdant-edge索引使用RAG-MCP填充资料

 

这是一个用来快速提取文档关键值的脚本,可以用来批量提取一批相同类型,但是不同型号的公共参数值,可以避免人工大量重复性的搜索和来回翻阅工作:

例如快速提取esp32_datasheet_cn.pdf的以下字段

| **型号** | **封装类型** | **工作温度(°C)** | **CPU 架构** | **核心数** | **最高频率(MHz)** | **SRAM(KB)** | **PSRAM(支持)** | **内置Flash(MB)** | **Wi-Fi 协议** | **蓝牙规格** | **典型GPIO数** | **USB接口** | **以太网MAC** | **CAN总线** | **工作电压(V)** | **深度睡眠功耗(μA)** | **安全启动/Flash加密** |

可以得到:

| **型号** | **封装类型** | **工作温度(°C)** | **CPU 架构** | **核心数** | **最高频率(MHz)** | **SRAM(KB)** | **PSRAM(支持)** | **内置Flash(MB)** | **Wi-Fi 协议** | **蓝牙规格** | **典型GPIO数** | **USB接口** | **以太网MAC** | **CAN总线** | **工作电压(V)** | **深度睡眠功耗(μA)** | **安全启动/Flash加密** |
|----------|--------------|------------------|--------------|------------|-------------------|--------------|-----------------|-------------------|----------------|--------------|----------------|-------------|---------------|-------------|-----------------|----------------------|--------------------------|
| ESP32 | QFN 5*5 | -40 ~ 125 | Xtensa LX6 32-bit | 双核 | 240 | 520 | 支持(外部最大 8 MB) | | IEEE 802.11b/g/n | 蓝牙 v4.2 BR/EDR 和 Bluetooth LE | 34 | | 支持(IEEE 802.3,需外部 PHY) | 支持(TWAI,兼容 ISO 11898-1) | 2.3 ~ 3.6 | 10 | 支持(安全启动、flash 加密) |

 

流程图:

metool_mermaid

依赖:

[project]
name = "rag-filler"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
    "docling>=2.121.0",
    "langchain>=1.3.16",
    "langchain-core>=1.6.0",
    "langchain-mcp-adapters>=0.3.2",
    "langchain-openai>=1.6.0",
    "langchain-text-splitters>=1.1.2",
    "mcp>=1.29.0",
    "qdrant-client>=1.19.0",
]

 

在命令行中接受三个参数,其他参数通过环境变量来输入:

def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="main.py",
        add_help=False,
        description="Fill a reference Markdown document from a PDF manual via RAG + an LLM agent.",
    )
    parser.add_argument("--in", dest="pdf", required=True, metavar="PDF",
                        help="用来索引的PDF")
    parser.add_argument("--reference", required=True, metavar="MD",
                        help="需要填充的markdown文件")
    parser.add_argument("--out", required=True, metavar="MD",
                        help="输出的markdown文件")
    return parser.parse_args(argv)


def load_config() -> dict:
    required = ("LLM_MODEL", "EMBEDDING_MODEL")
    missing = [name for name in required if not os.environ.get(name)]
    if missing:
        raise ConfigError(
            "缺少必需的环境变量:" + ", ".join(missing)
        )
    return {
        "llm_api_base": os.environ.get("LLM_API_BASE", DEFAULT_LLM_BASE),
        "llm_api_key": os.environ.get("LLM_API_KEY", "not-needed"),
        "llm_model": os.environ["LLM_MODEL"],
        "embedding_api_base": os.environ.get("EMBEDDING_API_BASE", DEFAULT_EMBED_BASE),
        "embedding_api_key": os.environ.get("EMBEDDING_API_KEY", "not-needed"),
        "embedding_model": os.environ["EMBEDDING_MODEL"],
        "embedding_dimension": int(os.environ.get("EMBEDDING_DIMENSION", DEFAULT_EMBED_DIMENSION)),
    }

 

脚本通过mcp来暴露查询方法,而不是将文档塞入上下文,用本地模型也可以跑

这里使用FastMCP来创建mcp服务

def make_search_server(qdrant: QdrantClient,
                       embed: OpenAIEmbeddings) -> FastMCP:
    server = FastMCP("manual-search")

    @server.tool()
    def search_manual(query: str, top_k: int = 5) -> list[dict]:
        """在已索引的手册中检索与 query 最相关的段落并返回(含相关度分数)。填写表格单元格之前,请调用此工具从手册中查找事实依据。"""
        k = max(1, min(int(top_k), 20))
        # PyCharm 解析到旧版 qdrant-client 签名会误报 query 参数类型;1.19 运行时接受 list[float]
        vector = embed.embed_query(query)
        hits = qdrant.query_points(collection_name=COLLECTION, query=vector, limit=k).points  # type: ignore[arg-type]
        return [
            {
                "text": (hit.payload or {}).get("text", ""),
                "section": (hit.payload or {}).get("section", ""),
                "score": round(float(hit.score), 4),
            }
            for hit in hits
        ]

    return server

 

然后通过 docling库来解析PDF,注意docling这个库会下载一个小模型来解析PDF,解析到的数据通过langchain提供的方法来分块,分块后通过Qwen3-Embedding-0.6B来embedding,存入qrdant-edge

建议embedding模型在本地跑,chat模型使用api,这样可以节省大量token,性能要求也不高


async def run(args: argparse.Namespace, cfg: dict) -> int:
    embed = OpenAIEmbeddings(
        model=cfg["embedding_model"],
        openai_api_base=cfg["embedding_api_base"],
        openai_api_key=cfg["embedding_api_key"],
        check_embedding_ctx_length=False,
        show_progress_bar=False,
    )

    reference = Path(args.reference)
    ref_text = reference.read_text(encoding="utf-8")

    print(f"正在索引 {args.pdf} ...")
    qdrant = QdrantClient(":memory:")
    count = index_pdf(args.pdf, embed, qdrant, cfg["embedding_dimension"])
    print(f"已将 {count} 个文本块索引到内存向量数据库")

    llm = ChatOpenAI(
        model=cfg["llm_model"],
        base_url=cfg["llm_api_base"],
        api_key=cfg["llm_api_key"],
        temperature=0,
    )

    server = make_search_server(qdrant, embed)
    try:
        async with create_connected_server_and_client_session(server) as session:
            tools = await load_mcp_tools(session)
            if not any(getattr(tool, "name", None) == "search_manual" for tool in tools):
                raise FatalError("向量检索 MCP 工具 'search_manual' 未绑定到 agent")

            agent = create_agent(llm, tools, system_prompt=SYSTEM_PROMPT)
            print("正在填充参考文档 ...")
            response = await agent.ainvoke(
                {"messages": [HumanMessage(content=(
                    "下面是需要填充的参考文档。请依据手册填充其中留空或占位的内容;"
                    "手册内容一律通过 search_manual 检索获取,不要把 PDF 全文当作已知信息。"
                    "输出文档的最终形式由你判断,但必须内容完整、可直接使用。\n\n"
                    "参考文档:\n```markdown\n" + ref_text + "\n```"
                ))]},
                config=RunnableConfig(recursion_limit=120),
            )
            filled = extract_content(response["messages"][-1].content)
    except FatalError:
        raise
    except Exception as exc:
        raise FatalError(f"API 调用失败:{format_api_error(exc)}") from exc

    write_output(Path(args.out), filled)
    print(f"已写入 {args.out}")
    return 0

 

 

实测例子:

这里RX100M3说明书.pdf是一个135页的pdf

 ⚡wo ❯❯ $env:LLM_MODEL="deepseek-v4-flash"; $env:EMBEDDING_MODEL="Qwen3-Embedding-0.6B"; $env:LLM_API_BASE="https://api.deepseek.com/v1"; $env:LLM_API_KEY="sk-44f7ab82118c4767b44bf89f807c418b"; uv run python main.py --in res/RX100M3说明书.pdf --reference res/ref_cam.md --out result_cam.md
正在索引 res/RX100M3说明书.pdf ...
[INFO] 2026-08-23 15:26:07,927 [RapidOCR] base.py:23: Using engine_name: torch
[INFO] 2026-08-23 15:26:07,946 [RapidOCR] device_config.py:57: Using CPU device
[INFO] 2026-08-23 15:26:07,958 [RapidOCR] download_file.py:60: File exists and is valid: E:\work\working\py\RagFiller\.venv\Lib\site-packages\rapidocr\models\PP-OCRv6_det_small.pth
[INFO] 2026-08-23 15:26:07,958 [RapidOCR] main.py:50: Using E:\work\working\py\RagFiller\.venv\Lib\site-packages\rapidocr\models\PP-OCRv6_det_small.pth
[INFO] 2026-08-23 15:26:08,114 [RapidOCR] base.py:23: Using engine_name: torch
[INFO] 2026-08-23 15:26:08,114 [RapidOCR] device_config.py:57: Using CPU device
[INFO] 2026-08-23 15:26:08,118 [RapidOCR] download_file.py:60: File exists and is valid: E:\work\working\py\RagFiller\.venv\Lib\site-packages\rapidocr\models\ch_ptocr_mobile_v2.0_cls_mobile.pth
[INFO] 2026-08-23 15:26:08,118 [RapidOCR] main.py:50: Using E:\work\working\py\RagFiller\.venv\Lib\site-packages\rapidocr\models\ch_ptocr_mobile_v2.0_cls_mobile.pth
[INFO] 2026-08-23 15:26:08,191 [RapidOCR] base.py:23: Using engine_name: torch
[INFO] 2026-08-23 15:26:08,193 [RapidOCR] device_config.py:57: Using CPU device
[INFO] 2026-08-23 15:26:08,208 [RapidOCR] download_file.py:60: File exists and is valid: E:\work\working\py\RagFiller\.venv\Lib\site-packages\rapidocr\models\PP-OCRv6_rec_small.pth
[INFO] 2026-08-23 15:26:08,208 [RapidOCR] main.py:50: Using E:\work\working\py\RagFiller\.venv\Lib\site-packages\rapidocr\models\PP-OCRv6_rec_small.pth
Loading weights: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 770/770 [00:00<00:00, 6633.26it/s]
E:\work\working\py\RagFiller\.venv\Lib\site-packages\torch\nn\modules\conv.py:560: UserWarning: Using padding='same' with even kernel lengths and odd dilation may require a zero-padded copy of the input be created (Triggered internally at C:\actions-runner\_work\pytorch\pytorch\aten\src\ATen\native\Convolution.cpp:1102.)
  return F.conv2d(
RapidOCR returned empty result!
RapidOCR returned empty result!
RapidOCR returned empty result!
[WARNING] 2026-08-23 15:28:36,526 [RapidOCR] main.py:132: The text detection result is empty
RapidOCR returned empty result!
RapidOCR returned empty result!
RapidOCR returned empty result!
RapidOCR returned empty result!
[WARNING] 2026-08-23 15:29:07,453 [RapidOCR] main.py:132: The text detection result is empty
RapidOCR returned empty result!
已将 375 个文本块索引到内存向量数据库
E:\work\working\py\RagFiller\.venv\Lib\site-packages\pydantic_settings\sources\utils.py:47: IncompleteFieldDefinitionWarning: Field 'lifespan' has an incomplete definition: its annotation contains an unresolved forward reference, so settings sources may fail to correctly resolve its value. Call `model_rebuild()` on the model where the field is defined, once all the referenced types are defined.
  warnings.warn(
[08/23/26 15:31:19] INFO     Processing request of type ListToolsRequest                                                                                              server.py:733
正在填充参考文档 ...
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:21] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
[08/23/26 15:31:22] INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:24] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:26] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:28] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:30] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:32] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
[08/23/26 15:31:33] INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:35] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:38] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:41] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
[08/23/26 15:31:42] INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:44] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:47] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:49] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:51] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
[08/23/26 15:31:52] INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:54] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:56] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:31:59] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:32:05] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:32:07] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
[08/23/26 15:32:08] INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
[08/23/26 15:32:14] INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     Processing request of type CallToolRequest                                                                                               server.py:733
                    INFO     HTTP Request: POST http://127.0.0.1:7838/v1/embeddings "HTTP/1.1 200 OK"                                                               _client.py:1085
                    INFO     HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"                                                      _client.py:1923
已写入 result_cam.md

可以得到:

| **型号** | **发布时间** | **传感器类型** | **有效像素(MP)** | **传感器尺寸** | **镜头焦距(等效)** | **最大光圈** | **光学变焦** | **对焦系统** | **连拍速度(fps)** | **视频规格** | **取景器** | **屏幕** | **机身尺寸(mm)** | **重量(g)** | **电池型号** |
|----------|--------------|----------------|------------------|----------------|---------------------|--------------|--------------|--------------|-------------------|--------------|------------|----------|------------------|--------------|--------------|
| Sony Cyber-shot DSC-RX100M3 |  | Exmor R™ CMOS | 约2010万(约20.1 MP) | 13.2 mm × 8.8 mm(1.0型) | f=8.8 mm 至 25.7 mm(24 mm 至 70 mm,35 mm 胶片等效) | F1.8(W)至 F2.8(T) | 2.9倍 | 对焦模式:单次AF / 连续AF / DMF / 手动对焦;对焦区域:广域 / 中间 / 自由点 / 锁定AF;支持眼控AF、中央锁定AF、AF辅助照明 |  | 文件格式:XAVC S(50p 50M、25p 50M、100p 50M,100p 为 1280×720 高速记录,可记录 100 fps 动态影像)/ AVCHD(50i 24M(FX)、50i 17M(FH)、50p 28M(PS)、25p 24M(FX)、25p 17M(FH))/ MP4(1440×1080 12M、VGA 3M);兼容 1080 50i / 1080 50p | 电子取景器(有机EL),弹出式;总点数 1 440 000 点,取景率 100%,倍率约 0.59 倍,眼点约 20 mm | 7.5 cm(3.0 型)TFT 液晶;总点数 1 228 800 点;可向上旋转约 180 度 | 约 101.6 × 58.1 × 41.0(符合 CIPA 标准) | 约 290 g(包括电池 NP-BX1、Memory Stick PRO Duo) | NP-BX1 |

 

 

完整代码:

查看代码
"""Rag-Filler: fill a reference Markdown document from a PDF manual via RAG + an LLM agent.

Single-file CLI (managed by uv). Usage:

    python main.py --in <manual.pdf> --reference <ref.md> --out <result.md>

Environment:
    LLM_API_BASE          default https://api.deepseek.com/v1
    LLM_API_KEY           optional
    LLM_MODEL             required
    EMBEDDING_API_BASE    default http://127.0.0.1:7838/v1
    EMBEDDING_API_KEY     optional
    EMBEDDING_MODEL       required
    EMBEDDING_DIMENSION   optional, default 1024
"""

from __future__ import annotations

import argparse
import asyncio
import os
import sys
import tempfile
from pathlib import Path

from docling.document_converter import DocumentConverter
from langchain.agents import create_agent
from langchain_core.documents import Document
from langchain_core.messages import HumanMessage
from langchain_core.runnables.config import RunnableConfig
from langchain_mcp_adapters.tools import load_mcp_tools
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_text_splitters import (
    MarkdownHeaderTextSplitter,
    RecursiveCharacterTextSplitter,
)
from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import create_connected_server_and_client_session
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams

COLLECTION = "manual"
CHUNK_SIZE = 800
CHUNK_OVERLAP = 120  # ~15% of CHUNK_SIZE
EMBED_BATCH = 32
DEFAULT_LLM_BASE = "https://api.deepseek.com/v1"
DEFAULT_EMBED_BASE = "http://127.0.0.1:7838/v1"
DEFAULT_EMBED_DIMENSION = 1024
SYSTEM_PROMPT = (
    "你是一个文档填充助手:根据技术手册(PDF)中的事实,填充参考文档中留空或占位的内容。\n"
    "规则:\n"
    "1. 填写任何内容之前,必须先调用 search_manual 工具从手册中检索证据;必要时综合多条检索结果,"
    "不要对同一事实重复检索。\n"
    "2. 严禁编造或猜测。手册中没有证据的内容,保持与参考文档中的原样(留空或占位符不变)。\n"
    "3. 数字和单位必须与手册原文完全一致。\n"
    "4. 输出文档的最终形式由你自己根据参考文档与填充内容判断(表格、段落或其它结构均可),"
    "但输出必须内容完整、可直接使用。\n"
    "5. 只输出最终文档本身:不要将检索过程、解释或对话内容混入输出文档。"
)

class ConfigError(Exception):
    """Raised when required environment configuration is missing."""


class FatalError(Exception):
    """Raised for expected user-facing pipeline failures."""


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="main.py",
        add_help=False,
        description="Fill a reference Markdown document from a PDF manual via RAG + an LLM agent.",
    )
    parser.add_argument("--in", dest="pdf", required=True, metavar="PDF",
                        help="用来索引的PDF")
    parser.add_argument("--reference", required=True, metavar="MD",
                        help="需要填充的markdown文件")
    parser.add_argument("--out", required=True, metavar="MD",
                        help="输出的markdown文件")
    return parser.parse_args(argv)


def load_config() -> dict:
    required = ("LLM_MODEL", "EMBEDDING_MODEL")
    missing = [name for name in required if not os.environ.get(name)]
    if missing:
        raise ConfigError(
            "缺少必需的环境变量:" + ", ".join(missing)
        )
    return {
        "llm_api_base": os.environ.get("LLM_API_BASE", DEFAULT_LLM_BASE),
        "llm_api_key": os.environ.get("LLM_API_KEY", "not-needed"),
        "llm_model": os.environ["LLM_MODEL"],
        "embedding_api_base": os.environ.get("EMBEDDING_API_BASE", DEFAULT_EMBED_BASE),
        "embedding_api_key": os.environ.get("EMBEDDING_API_KEY", "not-needed"),
        "embedding_model": os.environ["EMBEDDING_MODEL"],
        "embedding_dimension": int(os.environ.get("EMBEDDING_DIMENSION", DEFAULT_EMBED_DIMENSION)),
    }


def chunk_markdown(md: str) -> list[Document]:
    """
    将 Markdown 文本按标题层级(#、##、###)分块,再按长度切分为最终片段。
    返回不包含空内容的 Document 列表。
    """
    # 1. 按标题分割,保留层级元数据
    header_splitter = MarkdownHeaderTextSplitter(
        headers_to_split_on=[
            ("#", "section"),
            ("##", "subsection"),
            ("###", "subsubsection"),
        ]
    )
    header_docs = header_splitter.split_text(md)

    # 2. 对已分割的文档进行长度切分
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP,
        separators=["\n\n", "\n", " ", ""],   # 去掉无关的 "## " 等
    )

    if header_docs:
        chunks = splitter.split_documents(header_docs)
    else:
        # 无标题时直接创建文档(保持与 split_documents 返回类型一致)
        chunks = splitter.create_documents([md])

    # 3. 过滤空内容块
    return [c for c in chunks if c.page_content.strip()]


def embed_chunks(chunks: list[Document], embed: OpenAIEmbeddings) -> list[list[float]]:
    vectors: list[list[float]] = []
    for start in range(0, len(chunks), EMBED_BATCH):
        batch = [c.page_content for c in chunks[start : start + EMBED_BATCH]]
        vectors.extend(embed.embed_documents(batch))
    if len(vectors) != len(chunks):
        raise FatalError("嵌入 API 返回的向量数量与文本块数量不一致")
    return vectors


def index_pdf(pdf_path: str, embed: OpenAIEmbeddings, qdrant: QdrantClient,
              configured_dim: int) -> int:
    try:
        result = DocumentConverter().convert(str(pdf_path))
        md = result.document.export_to_markdown()
    except Exception as exc:
        raise FatalError(f"无法解析 PDF {pdf_path}:{exc}") from exc
    if not md.strip():
        raise FatalError(f"无法解析 PDF {pdf_path}:未提取到任何内容")

    chunks = chunk_markdown(md)
    if not chunks:
        raise FatalError(f"无法解析 PDF {pdf_path}:分块后无内容")

    vectors = embed_chunks(chunks, embed)
    actual_dim = len(vectors[0])
    if actual_dim != configured_dim:
        print(f"注意:嵌入模型输出维度为 {actual_dim},EMBEDDING_DIMENSION={configured_dim} 已忽略",
              file=sys.stderr)

    qdrant.create_collection(
        collection_name=COLLECTION,
        vectors_config=VectorParams(size=actual_dim, distance=Distance.COSINE),
    )
    points = [
        PointStruct(
            id=idx,
            vector=vectors[idx],
            payload={
                "text": chunks[idx].page_content,
                "section": chunks[idx].metadata.get("section", ""),
                "source": str(pdf_path),
            },
        )
        for idx in range(len(chunks))
    ]
    qdrant.upsert(collection_name=COLLECTION, points=points)
    return len(chunks)


def make_search_server(qdrant: QdrantClient,
                       embed: OpenAIEmbeddings) -> FastMCP:
    server = FastMCP("manual-search")

    @server.tool()
    def search_manual(query: str, top_k: int = 5) -> list[dict]:
        """在已索引的手册中检索与 query 最相关的段落并返回(含相关度分数)。填写表格单元格之前,请调用此工具从手册中查找事实依据。"""
        k = max(1, min(int(top_k), 20))
        # PyCharm 解析到旧版 qdrant-client 签名会误报 query 参数类型;1.19 运行时接受 list[float]
        vector = embed.embed_query(query)
        hits = qdrant.query_points(collection_name=COLLECTION, query=vector, limit=k).points  # type: ignore[arg-type]
        return [
            {
                "text": (hit.payload or {}).get("text", ""),
                "section": (hit.payload or {}).get("section", ""),
                "score": round(float(hit.score), 4),
            }
            for hit in hits
        ]

    return server


def unwrap_fence(text: str) -> str:
    stripped = text.strip()
    if stripped.startswith("```"):
        body = stripped.split("\n", 1)[1] if "\n" in stripped else ""
        if body.endswith("```"):
            body = body[:-3]
        return body.strip() + "\n"
    return text


def extract_content(message_content: str | list | dict) -> str:
    if isinstance(message_content, list):
        message_content = "".join(
            part.get("text", "") for part in message_content if isinstance(part, dict)
        )
    elif isinstance(message_content, dict):
        message_content = message_content.get("text", "")
    return unwrap_fence(message_content)


def write_output(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(prefix=".rag-filler-", suffix=".tmp", dir=str(path.parent))
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
            fh.write(content)
        os.replace(tmp, path)
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise


async def run(args: argparse.Namespace, cfg: dict) -> int:
    embed = OpenAIEmbeddings(
        model=cfg["embedding_model"],
        openai_api_base=cfg["embedding_api_base"],
        openai_api_key=cfg["embedding_api_key"],
        check_embedding_ctx_length=False,
        show_progress_bar=False,
    )

    reference = Path(args.reference)
    ref_text = reference.read_text(encoding="utf-8")

    print(f"正在索引 {args.pdf} ...")
    qdrant = QdrantClient(":memory:")
    count = index_pdf(args.pdf, embed, qdrant, cfg["embedding_dimension"])
    print(f"已将 {count} 个文本块索引到内存向量数据库")

    llm = ChatOpenAI(
        model=cfg["llm_model"],
        base_url=cfg["llm_api_base"],
        api_key=cfg["llm_api_key"],
        temperature=0,
    )

    server = make_search_server(qdrant, embed)
    try:
        async with create_connected_server_and_client_session(server) as session:
            tools = await load_mcp_tools(session)
            if not any(getattr(tool, "name", None) == "search_manual" for tool in tools):
                raise FatalError("向量检索 MCP 工具 'search_manual' 未绑定到 agent")

            agent = create_agent(llm, tools, system_prompt=SYSTEM_PROMPT)
            print("正在填充参考文档 ...")
            response = await agent.ainvoke(
                {"messages": [HumanMessage(content=(
                    "下面是需要填充的参考文档。请依据手册填充其中留空或占位的内容;"
                    "手册内容一律通过 search_manual 检索获取,不要把 PDF 全文当作已知信息。"
                    "输出文档的最终形式由你判断,但必须内容完整、可直接使用。\n\n"
                    "参考文档:\n```markdown\n" + ref_text + "\n```"
                ))]},
                config=RunnableConfig(recursion_limit=120),
            )
            filled = extract_content(response["messages"][-1].content)
    except FatalError:
        raise
    except Exception as exc:
        raise FatalError(f"API 调用失败:{format_api_error(exc)}") from exc

    write_output(Path(args.out), filled)
    print(f"已写入 {args.out}")
    return 0


def format_api_error(exc: BaseException) -> str:
    """Flatten asyncio exception groups so the real cause (e.g. 401) surfaces."""
    if isinstance(exc, BaseExceptionGroup):
        parts = [format_api_error(e) for e in exc.exceptions]
        return "; ".join(dict.fromkeys(parts)) or str(exc)
    return str(exc) or type(exc).__name__


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    try:
        cfg = load_config()
    except ConfigError as exc:
        print(f"错误:{exc}", file=sys.stderr)
        return 2
    try:
        return asyncio.run(run(args, cfg))
    except FatalError as exc:
        print(f"错误:{exc}", file=sys.stderr)
        return 1
    except Exception as exc:  # unexpected failure: keep output file untouched
        print(f"错误:{exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())

 

posted @ 2026-08-23 15:36  Jackie_JK  阅读(4)  评论(0)    收藏  举报