1.1Excel 文件比对智能体

下面是一个完整的智能体实现,它能够上传 Excel 文件,自动与上一次上传的同一文件进行比较,并输出字段级别的差异(包括 sheet 名称、列名、行索引、旧值和新值)。使用 Chroma 作为向量数据库存储历史 Excel 数据的文本表示,短期存储 使用内存变量(或可选的 InMemorySaver)保存当前会话状态,以支持多轮交互。

环境准备

bash 复制代码
pip install langchain langchain-community chromadb pandas openpyxl

完整代码

python 复制代码
import os
import json
import hashlib
import tempfile
from datetime import datetime
from typing import List, Dict, Any, Optional
import pandas as pd
from langchain.vectorstores import Chroma
from langchain.embeddings import HuggingFaceEmbeddings
from langchain.schema import Document
from langchain.tools import tool
from langchain.agents import create_react_agent, AgentExecutor
from langchain.prompts import PromptTemplate
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
import asyncio
from dotenv import load_dotenv

load_dotenv()

# ---------- 1. 配置向量数据库 ----------
embedding_model = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
vector_store = Chroma(
    collection_name="excel_history",
    embedding_function=embedding_model,
    persist_directory="./chroma_excel_db"  # 持久化
)

# ---------- 2. 工具函数 ----------
@tool
def upload_excel(file_path: str) -> str:
    """
    上传一个 Excel 文件,解析所有 sheet 内容,生成文本表示,
    并存储到向量数据库,同时返回当前文件的摘要。
    如果之前已上传过该文件(通过文件名哈希或内容哈希),则自动与最新存储的版本比较。
    """
    # 读取 Excel
    xls = pd.ExcelFile(file_path)
    all_sheets = {}
    for sheet_name in xls.sheet_names:
        df = pd.read_excel(file_path, sheet_name=sheet_name, header=0)
        # 将 DataFrame 转为 CSV 字符串(包含表头)
        csv_str = df.to_csv(index=False)
        all_sheets[sheet_name] = csv_str

    # 生成内容哈希(用于识别同一文件的不同版本)
    content_hash = hashlib.md5(json.dumps(all_sheets).encode()).hexdigest()

    # 构建文档内容(包含所有 sheet)
    doc_content = f"File: {os.path.basename(file_path)}\n"
    for sheet, csv in all_sheets.items():
        doc_content += f"Sheet: {sheet}\n{csv}\n---\n"

    # 元数据
    metadata = {
        "filename": os.path.basename(file_path),
        "timestamp": datetime.now().isoformat(),
        "hash": content_hash,
        "sheets": list(all_sheets.keys())
    }

    # 存储到向量数据库
    doc = Document(page_content=doc_content, metadata=metadata)
    vector_store.add_documents([doc])
    vector_store.persist()

    # 存储到短期内存(全局变量,用于比较)
    global _last_upload_data
    _last_upload_data = {
        "content_hash": content_hash,
        "sheets": all_sheets,
        "filename": os.path.basename(file_path),
        "metadata": metadata
    }

    return f"✅ 文件 {os.path.basename(file_path)} 已上传并存储。版本哈希: {content_hash[:8]}"

# 全局变量用于短期存储
_last_upload_data: Optional[Dict] = None

@tool
def compare_with_previous() -> str:
    """
    比较当前上传的 Excel 与上一次存储的版本之间的字段差异。
    如果没有上一次版本,则返回提示。
    返回差异报告,包含 sheet、列、行、旧值、新值。
    """
    global _last_upload_data
    if _last_upload_data is None:
        return "⚠️ 没有可比较的上一次版本。请先上传至少两个不同版本的 Excel 文件。"

    # 获取上一次上传的版本(最新存储的,排除当前版本)
    # 这里简化:从向量库中检索所有相同文件名的文档,按时间戳排序,取倒数第二个
    docs = vector_store.similarity_search(
        f"filename:{_last_upload_data['filename']}",
        k=10
    )
    # 按时间戳排序
    docs_sorted = sorted(docs, key=lambda d: d.metadata.get("timestamp", ""))
    if len(docs_sorted) < 2:
        return "⚠️ 只有当前一个版本,需要至少两个不同版本来比较。"

    # 最新的版本应该是当前上传的(但我们在_metadata中存储了当前版本,需排除)
    # 为了准确,我们取倒数第二个作为之前的版本
    prev_doc = docs_sorted[-2]
    prev_content = prev_doc.page_content
    # 解析之前的 sheet 数据
    prev_sheets = {}
    # 简单解析:按 "Sheet: " 分割
    parts = prev_content.split("---\n")
    for part in parts:
        lines = part.strip().splitlines()
        if not lines:
            continue
        if lines[0].startswith("Sheet: "):
            sheet_name = lines[0].replace("Sheet: ", "")
            # 剩余行是 CSV 数据
            csv_lines = lines[1:]
            csv_str = "\n".join(csv_lines)
            prev_sheets[sheet_name] = csv_str

    # 当前 sheet 数据(从 _last_upload_data 获取)
    curr_sheets = _last_upload_data["sheets"]

    # 比较差异
    diff_report = []
    all_sheets = set(prev_sheets.keys()) | set(curr_sheets.keys())
    for sheet in sorted(all_sheets):
        if sheet not in prev_sheets:
            diff_report.append(f"📄 Sheet '{sheet}' 为新增,无旧数据。")
            continue
        if sheet not in curr_sheets:
            diff_report.append(f"📄 Sheet '{sheet}' 已被删除。")
            continue

        # 比较两个 DataFrame
        prev_df = pd.read_csv(pd.compat.StringIO(prev_sheets[sheet]))
        curr_df = pd.read_csv(pd.compat.StringIO(curr_sheets[sheet]))

        # 对齐行和列(以当前版本为基准,也检查新增/删除行)
        # 获取所有行索引(假设有唯一标识,这里简单使用行号)
        max_rows = max(len(prev_df), len(curr_df))
        for i in range(max_rows):
            prev_row = prev_df.iloc[i] if i < len(prev_df) else None
            curr_row = curr_df.iloc[i] if i < len(curr_df) else None
            if prev_row is None and curr_row is not None:
                diff_report.append(f"📌 Sheet '{sheet}' 新增了第 {i+1} 行")
                continue
            if prev_row is not None and curr_row is None:
                diff_report.append(f"📌 Sheet '{sheet}' 删除了第 {i+1} 行")
                continue

            # 比较列值
            all_cols = set(prev_row.index) | set(curr_row.index)
            for col in sorted(all_cols):
                prev_val = prev_row.get(col) if prev_row is not None else None
                curr_val = curr_row.get(col) if curr_row is not None else None
                # 处理 NaN
                if pd.isna(prev_val) and pd.isna(curr_val):
                    continue
                if prev_val != curr_val:
                    diff_report.append(
                        f"🔹 Sheet '{sheet}' 第 {i+1} 行 列 '{col}' : "
                        f"{repr(prev_val)} → {repr(curr_val)}"
                    )

    if not diff_report:
        return "✅ 两个版本完全一致,没有发现差异。"
    else:
        return "📊 差异报告(按 sheet 和行组织):\n" + "\n".join(diff_report)

# ---------- 3. 构建 LangGraph 智能体 ----------
llm = ChatOpenAI(model="qwen-plus", temperature=0)

tools = [upload_excel, compare_with_previous]
llm_with_tools = llm.bind_tools(tools)

async def call_model(state: MessagesState):
    response = await llm_with_tools.ainvoke(state["messages"])
    return {"messages": [response]}

# 构建图
builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_conditional_edges("agent", tools_condition, {"tools": "tools", "__end__": END})
builder.add_edge("tools", "agent")
builder.add_edge(START, "agent")

# 短期存储(Checkpointer)
memory = MemorySaver()
graph = builder.compile(checkpointer=memory)

# ---------- 4. 运行交互 ----------
async def main():
    config = {"configurable": {"thread_id": "excel_comparison"}}
    print("🤖 Excel 比较智能体已启动")
    print("可用的工具: upload_excel, compare_with_previous")
    print("输入命令,例如: upload_excel('data.xlsx') 或 compare_with_previous()")
    print("输入 'quit' 退出\n")

    while True:
        user_input = input("你: ")
        if user_input.lower() in ("quit", "exit"):
            break

        # 让用户输入命令,我们将其转化为消息
        # 简单处理:如果输入以 'upload' 或 'compare' 开头,直接调用对应工具(或者使用 LLM 解析)
        # 这里我们直接将输入作为自然语言交给智能体
        inputs = {"messages": [HumanMessage(content=user_input)]}
        async for event in graph.astream(inputs, config=config, stream_mode="values"):
            # 打印中间结果
            if "messages" in event:
                last = event["messages"][-1]
                if isinstance(last, ToolMessage):
                    print(f"工具结果: {last.content}")
                elif isinstance(last, AIMessage) and last.content:
                    print(f"助手: {last.content}")

if __name__ == "__main__":
    asyncio.run(main())

📊 代码流程梳理

一、整体架构

该智能体是一个基于 LangGraph 构建的、支持多轮对话的 Excel 版本比较工具。其核心功能是:上传 Excel 文件 → 自动存储到向量数据库(Chroma)→ 与历史版本比对 → 输出字段级差异报告。

二、核心组件

组件 作用
Chroma(向量数据库) 存储每个 Excel 文件的文本表示(所有 sheet 的 CSV 数据)及元数据(文件名、时间戳、哈希),支持版本检索。
HuggingFace Embeddings 将文本内容转换为向量,用于语义检索(本例中主要用于按文件名检索历史版本)。
LangGraph StateGraph 定义工作流:agent(调用 LLM)→ tools(执行工具)→ 回到 agent,支持循环与条件路由。
MemorySaver(Checkpointer) 短期记忆,保存对话历史(messages 状态),实现多轮对话记忆。
全局变量 _last_upload_data 临时缓存当前上传的最新数据,加速比较操作,减少向量数据库查询次数。
工具函数 upload_excel(解析、存储、记录当前版本),compare_with_previous(检索上一版本、逐行逐列比较)。

三、执行流程(用户交互视角)

  1. 用户输入 → 输入自然语言指令(例如"上传 data.xlsx"或"比较差异")。
  2. Agent 节点(call_model
    • 接收当前 messages(包括用户输入和历史消息)。
    • 调用 LLM(千问)将用户意图转换为工具调用(tool_calls)。
    • 如果有 tool_calls,则进入工具节点;否则直接生成最终回答。
  3. 工具节点(ToolNode
    • 根据 LLM 选择的工具,执行 upload_excelcompare_with_previous
    • 工具执行结果(ToolMessage)追加到 messages
  4. 返回 Agent 节点 → LLM 根据工具结果生成最终自然语言回复。
  5. 循环 → 用户可继续提问,Checkpointer 保留整个对话历史。

四、详细步骤(以两次上传并比较为例)

  1. 第一次上传
    • 用户输入"上传 data.xlsx"。
    • Agent 调用 upload_excel
    • 工具解析 Excel(所有 sheet 转 CSV 字符串),生成哈希,构建 Document,存入 Chroma,并记录到全局变量 _last_upload_data
    • 返回"✅ 文件已上传..."。
  2. 第二次上传 (不同版本):
    • 用户再次上传相同文件名或不同文件。
    • 同样解析、存储,并更新 _last_upload_data(覆盖)。
  3. 用户请求比较
    • 输入"比较差异"。
    • Agent 调用 compare_with_previous
    • 工具从 Chroma 中检索该文件名的所有历史版本,按时间戳排序,取倒数第二个作为上一版本。
    • 解析上一版本的文本内容,恢复为 sheet 字典。
    • 对比当前 _last_upload_data 与上一版本,逐行逐列比较,生成差异报告。
    • 返回差异报告。

📈 流程图(Mermaid)

#mermaid-svg-3jvqt3RLeIc2stUE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3jvqt3RLeIc2stUE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3jvqt3RLeIc2stUE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3jvqt3RLeIc2stUE .error-icon{fill:#552222;}#mermaid-svg-3jvqt3RLeIc2stUE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3jvqt3RLeIc2stUE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3jvqt3RLeIc2stUE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3jvqt3RLeIc2stUE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3jvqt3RLeIc2stUE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3jvqt3RLeIc2stUE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3jvqt3RLeIc2stUE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3jvqt3RLeIc2stUE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3jvqt3RLeIc2stUE .marker.cross{stroke:#333333;}#mermaid-svg-3jvqt3RLeIc2stUE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3jvqt3RLeIc2stUE p{margin:0;}#mermaid-svg-3jvqt3RLeIc2stUE .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3jvqt3RLeIc2stUE .cluster-label text{fill:#333;}#mermaid-svg-3jvqt3RLeIc2stUE .cluster-label span{color:#333;}#mermaid-svg-3jvqt3RLeIc2stUE .cluster-label span p{background-color:transparent;}#mermaid-svg-3jvqt3RLeIc2stUE .label text,#mermaid-svg-3jvqt3RLeIc2stUE span{fill:#333;color:#333;}#mermaid-svg-3jvqt3RLeIc2stUE .node rect,#mermaid-svg-3jvqt3RLeIc2stUE .node circle,#mermaid-svg-3jvqt3RLeIc2stUE .node ellipse,#mermaid-svg-3jvqt3RLeIc2stUE .node polygon,#mermaid-svg-3jvqt3RLeIc2stUE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3jvqt3RLeIc2stUE .rough-node .label text,#mermaid-svg-3jvqt3RLeIc2stUE .node .label text,#mermaid-svg-3jvqt3RLeIc2stUE .image-shape .label,#mermaid-svg-3jvqt3RLeIc2stUE .icon-shape .label{text-anchor:middle;}#mermaid-svg-3jvqt3RLeIc2stUE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3jvqt3RLeIc2stUE .rough-node .label,#mermaid-svg-3jvqt3RLeIc2stUE .node .label,#mermaid-svg-3jvqt3RLeIc2stUE .image-shape .label,#mermaid-svg-3jvqt3RLeIc2stUE .icon-shape .label{text-align:center;}#mermaid-svg-3jvqt3RLeIc2stUE .node.clickable{cursor:pointer;}#mermaid-svg-3jvqt3RLeIc2stUE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3jvqt3RLeIc2stUE .arrowheadPath{fill:#333333;}#mermaid-svg-3jvqt3RLeIc2stUE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3jvqt3RLeIc2stUE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3jvqt3RLeIc2stUE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3jvqt3RLeIc2stUE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3jvqt3RLeIc2stUE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3jvqt3RLeIc2stUE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3jvqt3RLeIc2stUE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3jvqt3RLeIc2stUE .cluster text{fill:#333;}#mermaid-svg-3jvqt3RLeIc2stUE .cluster span{color:#333;}#mermaid-svg-3jvqt3RLeIc2stUE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3jvqt3RLeIc2stUE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3jvqt3RLeIc2stUE rect.text{fill:none;stroke-width:0;}#mermaid-svg-3jvqt3RLeIc2stUE .icon-shape,#mermaid-svg-3jvqt3RLeIc2stUE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3jvqt3RLeIc2stUE .icon-shape p,#mermaid-svg-3jvqt3RLeIc2stUE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3jvqt3RLeIc2stUE .icon-shape .label rect,#mermaid-svg-3jvqt3RLeIc2stUE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3jvqt3RLeIc2stUE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3jvqt3RLeIc2stUE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3jvqt3RLeIc2stUE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 需要工具
无需工具
upload_excel
compare_with_previous
用户输入
Agent 节点
LLM 判断
工具节点
直接回复
工具类型
解析 Excel
生成文本表示
存入 Chroma
更新全局缓存 _last_upload_data
返回成功消息
查询 Chroma 历史版本
按时间排序取上一版本
逐行逐列比对
生成差异报告
返回报告
输出


🎯 高频面试题及解答

1. 为什么使用向量数据库而不是传统 SQL 存储 Excel 数据?

面试回答

向量数据库(如 Chroma)适合存储非结构化或半结构化数据的向量表示,支持语义检索。本例中虽然主要按文件名检索,但使用向量数据库便于未来扩展:例如用户提问"找出去年相同季度的报表差异",可通过语义匹配找到相关文件。此外,向量数据库天然支持嵌入持久化,方便版本管理。传统 SQL 存储需要预定义 schema,不适合动态的 sheet 结构和字段。

2. 短期记忆(MemorySaver)与长期记忆(向量库)在架构中如何分工?

面试回答

  • 短期记忆MemorySaver):保存当前会话的 messages 列表,使 Agent 能理解上下文(如"再比较一次")。它是 LangGraph 的 Checkpointer 机制,基于 thread_id 隔离。
  • 长期记忆 (Chroma):存储历史 Excel 文件内容,跨会话、跨进程持久化,用于版本比较和时间回溯。
    两者互补:短期管理状态流,长期提供知识库。

3. 如何保证比较逻辑的准确性(如处理 NaN、新增/删除行列)?

面试回答

  • 使用 pandas 读取 CSV 字符串,利用 pd.isna() 处理缺失值。
  • 对齐行索引:以较长 DataFrame 为基准,缺失行视为新增或删除。
  • 对齐列:合并两个 DataFrame 的所有列,缺失值同样处理。
  • 输出格式清晰:明确标注 sheet、行号、列名、旧值和新值。

4. 如果 Excel 非常大(如十万行),如何优化性能?

面试回答

  • 分块读取 :使用 pd.read_excel(..., chunksize=10000) 逐块处理。
  • 增量哈希:只计算文件内容哈希,避免重复存储相同版本。
  • 索引优化:在 Chroma 中增加文件名和版本号作为过滤条件,减少检索范围。
  • 异步处理 :使用 asyncio 处理 I/O 密集操作(如读取文件、查询数据库)。
  • 缓存:将比较结果缓存到 Redis,相同文件版本直接返回。

5. 如何扩展支持更多文件类型(如 CSV、JSON)?

面试回答

设计统一的 Loader 接口,每种文件类型实现 load() 方法返回标准化 DataFrame 字典。在 upload_excel 工具中根据文件扩展名动态选择 Loader,其余逻辑(存储、比较)保持不变。这符合开闭原则。

6. 如何确保数据隐私和安全?(生产环境)

面试回答

  • 文件加密:存储前对内容进行 AES 加密。
  • 访问控制 :基于用户身份(如 user_id)隔离命名空间(Chroma 的 collection_name 可包含用户 ID)。
  • 临时文件清理:上传后删除本地临时文件。
  • 审计日志:记录每次上传和比较操作。

7. LangGraph 中的 interrupt_beforeinterrupt() 的区别?

面试回答

  • interrupt_before 在图编译时声明,在指定节点前自动暂停,适合"工具执行前确认"等固定场景。
  • interrupt() 在节点内部手动调用,可传递自定义提示并接收用户输入,适合需要动态交互(如询问缺失参数)的复杂流程。
    两者都依赖 Checkpointer 保存状态,通过 Command(resume=...) 恢复执行。

8. 如何实现"与任意历史版本比较"而非仅上一次?

面试回答

修改 compare_with_previous 工具,增加可选参数 version_hashtimestamp。用户指定版本后,从 Chroma 中检索对应文档,再与当前版本比对。如果没有指定,默认取最新两个版本。


该智能体代码已涵盖以上设计思想,可作为面试中的项目实例,展现你对 LangGraph、向量数据库、数据比较和系统设计的综合理解。

使用说明

  1. 准备环境变量 :在 .env 文件中设置 DASHSCOPE_API_KEY(若使用千问),或替换为其他 LLM。
  2. 运行代码:执行脚本,在交互界面输入指令。
  3. 上传 Excel:输入 `上传 Excel 文
  4. 件 data.xlsx(或直接调用 upload_excel('data.xlsx')`)。程序会自动解析、存储并返回哈希。
  5. 比较差异 :输入 比较差异compare_with_previous()。程序会从向量库中检索上一次版本,比对字段变化,输出详细差异报告。
  6. 多次上传:可以上传不同版本(文件名相同或不同),每次上传都会存储到向量库,比较时取最新两个版本。

核心设计说明

  • 向量数据库用途:存储每个 Excel 文件的完整文本表示(包含所有 sheet 数据),通过元数据(文件名、时间戳)实现版本检索。
  • 短期存储 :通过 MemorySaver 保留会话历史,同时使用全局变量 _last_upload_data 暂存当前上传的数据,便于快速比较。
  • 比较逻辑:逐行逐列比对,支持新增/删除行和列,输出精确的字段差异。
  • 工具封装upload_excelcompare_with_previous 作为 LangChain 工具,供 Agent 调用。

示例输出

复制代码
上传 data.xlsx
工具结果: ✅ 文件 data.xlsx 已上传并存储。版本哈希: a1b2c3d4
比较差异
📊 差异报告(按 sheet 和行组织):
🔹 Sheet 'Sheet1' 第 3 行 列 '价格' : 100 → 120
🔹 Sheet 'Sheet2' 第 1 行 列 '数量' : 5 → 8

此智能体可轻松扩展,支持更多分析功能(如统计差异数量、生成图表等)。

本代码实现中,采用了三种存储策略,分别服务于不同层次的需求:

存储类型 技术实现 存储内容 生命周期 用途
向量数据库(长期存储) Chroma + HuggingFace Embeddings Excel 文件的完整文本表示(所有 sheet 的 CSV 数据)及元数据(文件名、时间戳、哈希、sheet 列表) 持久化到磁盘(./chroma_excel_db 用于跨会话、跨版本检索历史 Excel 数据,支持"与上一次版本比较"的功能。
短期会话存储(Checkpointer) LangGraph 的 MemorySaver 对话历史(messages 状态) 内存中,随进程结束消失 记录当前会话的交互上下文,使 Agent 能够记住多轮对话内容,实现连续对话。
临时变量存储 Python 全局变量 _last_upload_data 当前上传的最新 Excel 数据(解析后的 sheets 字典、哈希、文件名) 内存中,进程结束消失 作为快速缓存,避免在比较时反复查询向量数据库,提升响应速度。

常见存储策略扩展(面试/工程参考)

在实际开发中,存储策略通常根据数据特性和访问模式选择:

  • 关系型数据库(SQLite/PostgreSQL):适合存储结构化元数据(如文件清单、比较记录),便于复杂查询和事务管理。
  • 文档数据库(MongoDB):适合存储半结构化的 Excel 解析结果(JSON 格式),扩展性强。
  • 对象存储(MinIO/S3):用于保存原始 Excel 文件本身,便于版本回溯和审计。
  • 缓存(Redis):用于高频访问的中间结果(如最近一次比较结果),降低计算延迟。
  • 本地文件系统:适合小规模原型开发,直接读写 CSV/Excel 文件作为"数据库"。

本代码已覆盖常用的"长期向量检索 + 短期会话 + 临时缓存"组合,适用于需要智能比较和检索的场景。

向量数据库(如 Chroma、Pinecone、Weaviate)存储的是文本的向量表示,因此任何能够被转换为文本的数据源都可以作为输入。除了 Excel(通过 pandas 读取后转为 CSV 文本),常见的向量数据源包括:

数据类型 典型格式 LangChain 加载器示例
文档文件 PDF、Word (.docx)、PPT、TXT、Markdown PyPDFLoader, Docx2txtLoader, TextLoader, UnstructuredMarkdownLoader
表格数据 CSV、JSON、SQL 数据库表、Parquet CSVLoader, JSONLoader, SQLDatabase (通过 SQL 查询生成文本)
网页内容 HTML、URL 链接、RSS 订阅 WebBaseLoader, SeleniumURLLoader, BeautifulSoup 解析
代码仓库 Python/Java/JS 源码文件、Git 提交记录 TextLoader (直接读取源码), 或自定义解析 AST 提取注释和函数签名
音视频转录文本 音频/视频文件(通过语音识别得到文字) OpenAIWhisperParser, AssemblyAIAudioTranscriptLoader
社交媒体/消息 微信聊天记录、邮件、Slack 消息、推文 MailDirLoader, SlackLoader, TwitterTweetLoader
知识图谱/关系数据 RDF 三元组、Neo4j 查询结果 通过 Cypher 查询返回文本描述
半结构化数据 JSON/XML 配置文件、API 响应 JSONLoader, XMLLoader
图像描述 图片文件(通过多模态模型生成文本描述) ImageCaptionLoader (如 BLIP)
数据库记录 关系数据库中的行(序列化为文本) SQLDatabaserun() 方法

为什么向量存储能容纳如此多源?

因为向量数据库本质上是一个 嵌入索引 + 相似性搜索 引擎,它不关心原始数据的格式,只关心输入的 向量 。而 LangChain 提供了丰富的 文档加载器(Document Loaders) ,可以将各种数据源统一转换为 Document 对象(包含 page_content 文本和 metadata),然后通过 Embeddings 模型将文本转化为向量。因此,上述所有数据源均可无缝接入同一个向量数据库。

在你的 Excel 比较项目中,只需替换加载器即可支持其他格式。例如,若需要支持 PDF 比较,可将 upload_excel 工具扩展为 upload_file,根据文件扩展名选择对应的加载器,其余存储、比较逻辑完全复用。

向量数据库会存满。它的容量并非无限,主要受限于存储它的硬件(特别是内存)和软件自身的配置。

简单来说,向量数据库就像一个大仓库,虽然能存放很多"货物"(向量数据),但仓库的总面积(硬件资源)和货架的摆放方式(索引算法)决定了它最终能存多少。

🧮 容量是如何计算的?

向量数据库的容量不是一个固定数字,而是由几个关键因素共同决定:

  • 硬件资源 (Hardware) :这是最根本的限制。向量数据库为了追求高速检索,通常会将索引数据加载到内存中。因此,服务器的内存(RAM)大小是决定容量的首要因素。磁盘空间则是另一道防线,当内存不足时,数据可以存在磁盘上,但检索速度会大幅下降。
  • 向量数据本身 (Vector Data)
    • 向量数量 (Number of Vectors):需要存储的向量总数。
    • 向量维度 (Dimensionality):每个向量的长度。维度越高,单个向量占用的空间越大。例如,768维的向量就比384维的向量需要多一倍的空间。
  • 索引算法 (Index Algorithm) :为了加速搜索,向量数据库会构建索引(如HNSW)。这些索引会带来额外的存储开销,通常是原始数据的3到5倍
  • 元数据 (Metadata):除了向量本身,每个向量还可以附带一些描述信息(如文件名、时间戳等),这些也会占用存储空间。

📊 主流向量数据库容量对比

不同的向量数据库,其容量和适用场景也不同。

数据库 定位 典型容量 说明
Chroma 轻量级、嵌入式 百万级以下 适合开发测试、POC验证。当文档量超过5万份时,性能瓶颈会开始显现。
Qdrant 高性能、云原生 千万级 适合中等规模的生产环境。
Milvus 分布式、企业级 亿级以上 专为大规模、高可用的生产环境设计。例如,8GB内存 可容纳约150万个768维向量。
Pinecone 云原生、托管式 取决于Pod类型 一个 p1 Pod 约可存100万 个向量,一个 s1 Pod 约可存500万个。
Weaviate 开源、模块化 取决于资源配置 DigitalOcean的托管服务中,Small 配置(2GB内存)适合10万以内向量。
PGVector PostgreSQL插件 百万级以下 适合已有Postgres基础设施,向量规模不大的场景。

注意:以上容量均为估算值,实际容量会受到向量维度、元数据大小、索引类型等多种因素影响。

📈 数据量增长会带来什么影响?

当向量数据量增长接近硬件极限时,会出现明显的性能衰减

  • 查询延迟飙升 :当数据从1000万增至5000万时,查询的P99延迟可能从8ms飙升至220ms
  • 吞吐量下降 :数据从1000万增至1亿时,QPS(每秒查询数)可能下降60%
  • 系统报错 :当内存或磁盘配额耗尽时,系统会拒绝写入,并抛出类似 memory quota exhausted 的错误。

💡 如何应对容量限制?

  • 合理预估与规划:在项目初期,根据数据量和向量维度,估算所需资源。可以参考"8GB内存 ≈ 150万个768维向量"这类经验公式进行初步规划。
  • 选择合适的数据库:根据数据规模选择适合的数据库类型。如果数据量在百万级以下,Chroma 足够;如果预计会增长到千万级甚至亿级,应从一开始就考虑 Qdrant 或 Milvus。
  • 采用分布式架构:对于海量数据,分布式是必经之路。Milvus 等数据库支持水平扩展,通过增加节点来线性提升容量和吞吐量。
  • 优化索引策略 :选择合适的索引类型,在检索精度、速度和内存占用之间取得平衡。例如,FLAT 索引最精准但最慢最占内存,而 IVF_PQ 等索引则能有效压缩数据。
  • 数据生命周期管理:定期清理过期或无用的向量数据,释放存储空间。

💎 面试考点速查

  • 向量数据库的容量主要受什么限制?
    • 核心 :硬件资源,特别是内存(RAM),因为为了保证低延迟检索,索引数据通常需要常驻内存。
  • Chroma、Milvus 等数据库各自的容量级别和适用场景是什么?
    • Chroma百万级 以下,适合开发测试
    • Milvus亿级 以上,适合大规模生产环境
    • Qdrant千万级 ,适合中等规模生产
  • 除了向量本身,还有哪些因素会消耗存储空间?
    • 索引结构 (通常是数据的3-5倍)、元数据(每个向量可附带的自定义信息)。
  • 当向量数据库容量达到上限时,会出现什么现象?
    • 写入失败(如 memory quota exhausted 错误)、查询延迟显著增加系统吞吐量(QPS)下降
  • 如果数据量超过了单机容量,有什么解决方案?
    • 水平扩展 :采用分布式架构,使用 Milvus 等支持集群的数据库。
    • 垂直扩展:升级单台服务器的硬件配置(如增加内存)。
    • 优化策略:使用更高效的索引算法来压缩数据。