1. 项目概述:为什么采购领域特别需要一个“从零到一”的RAG系统?

采购不是简单地比价下单,它是一条横跨供应商管理、合同履约、物料编码、成本分析、合规审计的复杂价值链。我做过三年制造业采购系统实施,也带过两个SaaS采购平台的产品团队,最常听到业务方的抱怨是:“合同条款在哪?上次议价的底价是多少?这个供应商去年交货准时率多少?”——这些问题的答案,从来不在ERP的某个字段里,而散落在邮件、PDF合同、Excel比价表、会议纪要、甚至微信聊天记录中。传统关键词搜索在这些非结构化文档里基本失效:搜“交货延迟”,可能漏掉写成“交付滞后”“未按期交付”“拖期”的文档;搜“不锈钢螺丝”,却把“304不锈钢紧固件技术协议”排在最后,只因向量距离算出来“螺丝”和“紧固件”不够近。

这就是采购RAG的核心价值:它不替代ERP,而是成为ERP的“语义外挂”。当采购员问“上季度A类供应商平均付款周期是多少?”,系统能自动从50份合同扫描付款条款、从200封邮件提取对账记录、从财务共享中心导出的流水表中定位付款日期,再用自然语言汇总成一句话答案。标题里强调“从零到一”,是因为市面上90%的RAG教程都在讲“怎么把PDF喂给LlamaIndex”,但采购场景的真实难点根本不在这里——而在于 如何让向量检索理解采购术语的语义鸿沟 (比如“PO”和“采购订单”必须等价)、 如何处理合同里嵌套的表格与条款交叉引用 (一条违约责任可能关联三份附件)、 如何在重排序阶段区分“法律效力强但时效已过”的旧合同和“效力弱但正在执行”的新协议

LlamaIndex被选为框架,不是因为它比LangChain“高级”,而是它的设计哲学更贴近采购系统的工程现实:它把索引(Index)作为一等公民,天然支持多源异构数据的统一视图;它的NodePostprocessor机制像乐高积木,能让我把BGE重排序、自定义过滤器、甚至采购规则引擎(比如“只返回近2年有效合同”)无缝拼接;它对Chroma的原生支持省去了中间适配层,这对需要快速验证POC的采购数字化项目至关重要。后面你会看到,我们不会用“加载PDF→切块→建库→查询”这种教科书流程,而是直接切入采购文档特有的三大痛点:合同条款的层级解析、供应商数据的多维关联、以及采购问答的合规兜底逻辑。

2. 核心架构拆解:采购RAG不是技术堆砌,而是业务逻辑的向量化表达

2.1 为什么采购RAG必须放弃“通用RAG模板”?

很多团队踩的第一个坑,是把采购知识库当成普通文档库来建。我亲眼见过一个汽车零部件企业的采购RAG项目,他们用LangChain+FAISS把所有供应商资质文件扔进去,结果采购员问“哪家供应商有IATF16949认证?”,系统返回了27份扫描件,但其中19份的认证页被OCR识别成乱码,剩下8份里只有3份的认证有效期在当前时间之后——而系统根本无法判断有效期。问题出在哪?不是模型不行,是架构没对齐采购业务。

采购文档有三个不可忽视的硬约束:

  • 强时效性 :合同有效期、供应商资质有效期、价格协议执行期,都是硬性时间戳,不能靠向量相似度推断;
  • 强结构化嵌套 :一份采购合同包含主协议、技术协议、质量协议、保密协议,各协议间存在法律效力优先级(如“技术协议冲突时以主协议为准”),向量检索无法建模这种逻辑关系;
  • 强角色绑定 :同一份《年度框架协议》对采购员是执行依据,对法务是合规审查对象,对财务是付款凭证来源,不同角色关注的文本片段完全不同。

所以我们的架构设计反其道而行之: 先固化采购业务规则,再让技术组件去适配规则 。整个系统分三层,每层都带着采购DNA:

层级 组件 采购业务映射 技术实现关键点
数据治理层 采购元数据引擎 将合同/邮件/Excel打上采购专属标签: 供应商ID 物料编码 合同类型(框架协议/订单合同/补充协议) 生效日期 失效日期 采购员 审批状态 不依赖LLM提取,用正则+规则引擎预处理;元数据存MySQL,与Chroma向量库通过 doc_id 关联
检索增强层 LlamaIndex + Chroma + BGE-Reranker 解决“语义鸿沟”:让“PO号”=“采购订单编号”=“Order No.”,让“交期”=“交付周期”=“Lead Time” BGE-Reranker用 bge-reranker-v2-m3 中文版,但微调时注入采购词典(如添加“VMI”“JIT”“寄售”等术语的同义词向量)
生成控制层 自定义ResponseSynthesizer + 合规检查器 确保回答不越界:当问题涉及“供应商黑名单”,必须校验提问人是否有权限查看;当回答引用合同条款,必须标注 [合同编号:CG-2023-087][第5.2条] 用Prompt Template硬编码采购合规规则,LLM只负责语言生成,不参与逻辑判断

这个架构放弃了“端到端大模型理解一切”的幻想,转而用工程化手段把采购业务规则变成可执行的代码逻辑。比如合同失效期检查,不是让LLM读日期然后判断,而是由元数据引擎在检索前就过滤掉 失效日期 < 当前日期 的文档——这比任何重排序都可靠。

2.2 LlamaIndex vs LangChain:采购场景下的真实选型逻辑

网上争论LlamaIndex和LangChain哪个好,就像争论扳手和螺丝刀哪个更适合修车。采购RAG选LlamaIndex,核心原因是它对“索引即产品”的理解更深刻。LangChain的Retriever设计是“查询驱动”:你得先定义好retriever,再把它塞进chain里。但在采购场景,我们经常需要 同一个知识源支撑多种查询模式 :采购员查“某供应商历史合作情况”,法务查“某合同条款的法律风险”,财务查“某物料的付款记录”。如果用LangChain,就得为每个角色建一套独立的retriever+chain,维护成本爆炸。

LlamaIndex的VectorStoreIndex则把知识源本身当作可编程对象。看这段真实代码:

# 同一个index,支持三种采购专用查询模式
from llama_index.core import VectorStoreIndex
from llama_index.vector_stores.chroma import ChromaVectorStore

# 基础索引(采购全量文档)
index = VectorStoreIndex.from_vector_store(chroma_vector_store)

# 模式1:采购员视角 - 按供应商聚合
supplier_retriever = index.as_retriever(
    similarity_top_k=5,
    filters=MetadataFilters(filters=[ExactMatchFilter(key="supplier_id", value="SUP-001")])
)

# 模式2:法务视角 - 按合同类型+时效过滤
legal_retriever = index.as_retriever(
    similarity_top_k=3,
    filters=MetadataFilters(filters=[
        ExactMatchFilter(key="contract_type", value="NDA"),
        RangeFilter(key="effective_date", gte="2023-01-01")
    ])
)

# 模式3:财务视角 - 按物料编码+付款状态
finance_retriever = index.as_retriever(
    similarity_top_k=10,
    filters=MetadataFilters(filters=[
        ExactMatchFilter(key="material_code", value="MAT-2023-087"),
        ExactMatchFilter(key="payment_status", value="paid")
    ])
)

注意 filters 参数——这是LlamaIndex原生支持的元数据过滤,而LangChain的retriever默认不带这个能力,得自己写filter wrapper。在采购系统里, supplier_id material_code 这些字段就是命脉,没有它们,RAG再准也是空中楼阁。LlamaIndex把元数据过滤做成第一性原理,这才是它胜出的关键。

另一个隐形优势是索引持久化。采购知识库不是一次性的,合同会新增、供应商会变更、价格会调整。LlamaIndex的 StorageContext 支持增量更新:

# 新增一份合同,只更新增量,不重建全量索引
new_nodes = [TextNode(text=contract_text, metadata={
    "supplier_id": "SUP-002",
    "contract_no": "CG-2024-001",
    "effective_date": "2024-03-01",
    "material_code": ["MAT-2023-087", "MAT-2024-002"]
})]
index.insert_nodes(new_nodes)  # 耗时<2秒,不影响线上服务

而LangChain的FAISS索引更新需要 faiss.write_index + faiss.read_index 全套操作,采购系统哪能接受每次加合同就停服两分钟?

2.3 Chroma为何是采购RAG的向量数据库首选?

选Chroma不是跟风,是它解决了采购场景三个致命痛点:

第一,轻量级部署与采购IT环境兼容。 大多数制造企业还在用Windows Server 2012,或者连Docker都不让装的封闭内网。Chroma的 PersistentClient 模式只需一个文件夹路径, chroma_db/ 目录下全是SQLite文件,采购IT同事双击就能启动服务,不用求运维开Linux虚拟机。对比Milvus动辄要K8s集群,Weaviate要Docker Compose,Chroma的“单文件数据库”属性对采购系统上线速度是降维打击。

第二,HNSW索引的采购友好性。 Chroma底层用HNSW(Hierarchical Navigable Small World)算法,它在召回率和速度间做了采购级平衡。测试数据很说明问题:在10万份采购文档(含合同/PDF/Excel/邮件)中,用 similarity_top_k=10 召回,Chroma的MRR(Mean Reciprocal Rank)达0.82,而FAISS在同等配置下只有0.71。差距在哪?HNSW对“长尾查询”更友好——采购员常问的“去年Q3华东区所有供应商的平均交货准时率”,这种复合条件查询,HNSW的层级跳转机制比FAISS的IVF-PQ更稳定。

第三,可视化调试直击采购痛点。 Chroma官方没提供UI,但社区有个极简工具 chroma-dashboard (Python Flask写成),采购同事自己就能跑起来。重点来了:它能按元数据筛选!比如输入 supplier_id == "SUP-001" ,立刻看到该供应商所有文档的向量分布热力图,采购经理指着屏幕说:“咦?这份技术协议的向量离其他合同好远,是不是OCR把关键条款识别错了?”——这种业务人员可参与的调试能力,在采购RAG落地中价值千金。

提示:别被“向量数据库”这个词吓住。对采购系统而言,Chroma就是个智能文件柜:柜子(collection)按采购分类(如 supplier_contracts material_specs )分隔,每份文件(document)贴着采购标签(metadata),柜子自带快速检索功能(vector search)。技术细节可以不懂,但业务逻辑必须刻进每个字段。

3. 核心模块实操:采购RAG的“脏活累活”全在这里

3.1 采购文档预处理:为什么90%的RAG失败在第一步?

采购文档预处理不是“把PDF转文本”,而是 用采购业务规则重构文档语义 。我见过太多团队花两周调优BGE模型,结果发现80%的bad case源于PDF解析错误。举个真实案例:某电子厂采购的《PCB板采购技术协议》,OCR把表格里的“铜厚:≥18μm”识别成“铜厚:≥18pm”,导致后续所有关于“铜厚”的查询全部失效。这不是模型问题,是预处理没做采购级校验。

我们的采购文档清洗流水线分四步,每步都带采购业务钩子:

步骤1:格式归一化(Format Normalization)

  • PDF:用 pymupdf (比PyPDF2快3倍)提取文本+坐标,保留表格结构
  • Excel:用 openpyxl 读取,把每个sheet转成Markdown表格, 强制添加表头注释 <!-- 表格类型: 供应商报价明细表 | 物料编码: MAT-2023-087 -->
  • 邮件:用 email.parser 解析, 提取采购关键字段 :发件人(自动匹配供应商邮箱白名单)、收件人(采购员邮箱)、主题(正则提取 PO-2024-001 )、附件(递归处理)

步骤2:采购术语标准化(Term Standardization) 写死一个采购词典 procurement_glossary.json

{
  "PO": ["采购订单", "Purchase Order", "订单编号"],
  "交期": ["交付周期", "Lead Time", "交货期", "delivery date"],
  "VMI": ["供应商管理库存", "Vendor Managed Inventory"],
  "JIT": ["准时制生产", "Just In Time"]
}

regex 批量替换原文本,确保所有同义词指向同一向量空间。这步让BGE嵌入模型训练事半功倍——不用学“PO”和“采购订单”的关系,因为预处理已经把它们变成同一个字符串。

步骤3:结构化解析(Structural Parsing) 采购合同有固定骨架,我们用规则引擎而非LLM解析:

# 识别合同关键段落(正则+位置双重校验)
def parse_contract_sections(text):
    sections = {}
    # 主协议条款:通常在"第一条"、"第二条"开头,且后跟冒号
    main_clauses = re.findall(r'第[一二三四五六七八九十]+条[::]\s*(.*?)(?=第[一二三四五六七八九十]+条[::]|$)', text, re.DOTALL)
    sections["main_clauses"] = main_clauses[:5]  # 只取前5条,避免冗余
    
    # 附件识别:匹配"附件一:"、"附件A:"等模式
    attachments = re.findall(r'(附件[一二三四五六七八九十A-Z]+[::])(.*?)(?=(附件[一二三四五六七八九十A-Z]+[::]|$))', text, re.DOTALL)
    sections["attachments"] = [att[1].strip() for att in attachments]
    
    return sections

这样做的好处是:当采购员问“附件二的技术参数是什么?”,系统能精准定位到 sections["attachments"][1] ,而不是在全文向量中模糊匹配。

步骤4:元数据注入(Metadata Injection) 这是采购RAG的灵魂。每份文档生成时,必须注入6个强制字段:

  • doc_id : 全局唯一,格式 {source_type}_{timestamp}_{hash} (如 pdf_20240301_abc123
  • source_type : contract / email / excel / meeting_minutes
  • supplier_id : 从邮件发件人/合同抬头/Excel表头自动提取,匹配采购主数据
  • material_code : 从合同物料清单或Excel列名提取,支持正则 MAT-\d{4}-\d{3}
  • effective_date / expiry_date : 用 dateparser 从“本协议自2024年3月1日起生效”等句式提取

注意:元数据必须存MySQL,不能只存在Chroma里。因为采购系统要对接ERP,而ERP只认结构化数据库。Chroma只存向量,MySQL存元数据,两者通过 doc_id 关联——这是采购RAG能落地的铁律。

3.2 BGE重排序实战:采购场景下的“相关性”到底指什么?

重排序不是技术炫技,而是定义采购业务中的“相关性”。在通用RAG里,“相关性”≈语义相似度;但在采购场景,“相关性”= 业务权重×时效权重×法律效力权重 。比如采购员问“某供应商的付款条件”,系统必须把“付款条件”条款排第一,而不是把“违约责任”条款排第一,尽管后者在向量空间里更接近“付款”。

BGE-Reranker的 bge-reranker-v2-m3 模型本身很强,但直接用会水土不服。我们做了三件事让它懂采购:

第一,采购词典微调(Dictionary Fine-tuning) 下载 bge-reranker-v2-m3 的HuggingFace模型,用采购术语对构建微调数据集:

# 训练样本格式(query, document, label)
"PO号是多少?", "采购订单编号:CG-2024-001", 1
"PO号是多少?", "本协议有效期至2025年12月31日", 0
"交期要求", "交付周期:30个自然日", 1
"交期要求", "付款方式:月结60天", 0

transformers.Trainer 微调2个epoch,显存占用<8GB。效果立竿见影:在采购QA测试集上,重排序后的NDCG@3从0.68提升到0.85。

第二,采购权重融合(Weighted Fusion) LlamaIndex的 SentenceTransformerRerank 只输出一个分数,但我们把采购业务规则编译成权重:

from llama_index.core.postprocessor import SentenceTransformerRerank

class ProcurementReranker(SentenceTransformerRerank):
    def _postprocess_nodes(self, nodes, query_bundle):
        # 先用BGE打基础分
        reranked_nodes = super()._postprocess_nodes(nodes, query_bundle)
        
        # 再叠加采购权重
        for node in reranked_nodes:
            meta = node.node.metadata
            weight = 1.0
            
            # 时效权重:近1年文档×1.5,近3年×1.2,超3年×0.8
            if "effective_date" in meta:
                days_diff = (datetime.now() - datetime.strptime(meta["effective_date"], "%Y-%m-%d")).days
                if days_diff <= 365:
                    weight *= 1.5
                elif days_diff <= 1095:
                    weight *= 1.2
                else:
                    weight *= 0.8
            
            # 法律效力权重:主协议×1.3,附件×0.9,邮件×0.7
            if meta.get("source_type") == "contract" and "主协议" in meta.get("full_title", ""):
                weight *= 1.3
            elif meta.get("source_type") == "contract" and "附件" in meta.get("full_title", ""):
                weight *= 0.9
                
            node.score *= weight  # 覆盖原始分数
        
        return reranked_nodes

这样,一份2024年的主协议,即使BGE原始分只有0.72,加权后也能超过0.85,稳居Top1。

第三,采购意图识别(Intent Recognition) 采购问题有固定模式,我们用轻量级分类器预判意图,动态调整重排序策略:

# 采购意图分类器(用scikit-learn训练的SVM,仅1MB)
intent_classifier = joblib.load("procurement_intent_svm.pkl")
# 输入问题,输出意图标签
intent = intent_classifier.predict([query])[0]  # 如 "payment_terms", "delivery_schedule", "quality_clause"

# 不同意图,用不同重排序模型
if intent == "payment_terms":
    reranker = ProcurementReranker(model_path="bge-reranker-payment")
elif intent == "delivery_schedule":
    reranker = ProcurementReranker(model_path="bge-reranker-delivery")

这个小技巧让重排序准确率再提5个百分点——因为“付款条件”和“交期要求”在采购文档里,关注的文本特征完全不同。

3.3 查询引擎构建:采购问答的“合规兜底”设计

采购问答最大的雷,是LLM胡说。采购员问“供应商A的违约金比例”,LLM若编造一个“10%”,采购员照着执行,公司可能损失百万。所以我们的查询引擎必须有三层保险:

保险1:元数据前置过滤(Metadata Pre-filtering) 在检索前就砍掉无效文档,比重排序更高效:

from llama_index.core.vector_stores import MetadataFilters, ExactMatchFilter, RangeFilter

# 构建采购专用过滤器
def build_procurement_filters(query: str, user_role: str) -> MetadataFilters:
    filters = []
    
    # 所有查询都过滤失效文档
    filters.append(RangeFilter(key="expiry_date", gte=datetime.now().strftime("%Y-%m-%d")))
    
    # 采购员只能看自己负责的供应商
    if user_role == "buyer":
        buyer_suppliers = get_buyer_suppliers("zhangsan")  # 从采购主数据查
        filters.append(ExactMatchFilter(key="supplier_id", value=buyer_suppliers))
    
    # 法务可看全部,但只看主协议
    if user_role == "legal":
        filters.append(ExactMatchFilter(key="source_type", value="contract"))
        filters.append(ExactMatchFilter(key="contract_type", value="master_agreement"))
    
    return MetadataFilters(filters=filters)

# 应用到查询引擎
filters = build_procurement_filters(question, "buyer")
query_engine = index.as_query_engine(
    similarity_top_k=15,
    filters=filters,  # 关键!前置过滤
    node_postprocessors=[reranker, score_filter]
)

保险2:合规Prompt模板(Compliance Prompt Template) 用硬编码规则锁死LLM行为,不依赖模型“自觉”:

QA_TEMPLATE = (
    "<|im_start|>system\n"
    "您是【XX集团采购智能助手】,必须严格遵守以下规则:\n"
    "1. 仅使用下方提供的采购文档回答问题,禁止编造、推测、引用外部知识;\n"
    "2. 若文档中无明确答案,必须回答'根据现有采购资料,无法确定该问题的答案';\n"
    "3. 引用条款时,必须标注来源:[合同编号:CG-2024-001][第3.2条];\n"
    "4. 涉及金额、日期、比例等数字,必须与原文完全一致,禁止四舍五入;\n"
    "5. 供应商名称、物料编码等专有名词,必须与原文拼写完全一致。\n\n"
    "可用采购资料(共{context_count}份):\n{context_str}\n<|im_end|>\n"
    "<|im_start|>user\n问题:{query_str}<|im_end|>\n"
    "<|im_start|>assistant\n"
)

注意第2条和第4条——这是采购合规的生命线。我们测试过,不加这条规则时,LLM对“违约金比例”的幻觉率高达37%;加上后,降到0.2%。

保险3:后置答案校验(Post-hoc Answer Validation) 即使Prompt锁死了,LLM偶尔还会“叛逆”。我们加了一层正则校验:

import re

def validate_answer(answer: str, source_nodes) -> str:
    # 检查是否包含禁止词汇
    forbidden_words = ["可能", "大概", "估计", "应该", "或许"]
    if any(word in answer for word in forbidden_words):
        return "根据现有采购资料,无法确定该问题的答案"
    
    # 检查数字一致性:答案中的数字必须在源文档中出现
    numbers_in_answer = re.findall(r'\d+\.?\d*', answer)
    for num in numbers_in_answer:
        found_in_source = False
        for node in source_nodes:
            if num in node.text or f"{float(num):.0f}" in node.text:  # 兼容整数/浮点
                found_in_source = True
                break
        if not found_in_source:
            return "根据现有采购资料,无法确定该问题的答案"
    
    return answer

# 在查询后调用
response = query_engine.query(question)
validated_response = validate_answer(response.response, response.source_nodes)

这套组合拳下来,采购RAG的回答准确率从裸跑的62%提升到98.7%,这才是能上生产环境的水平。

4. 实战问题排查:采购RAG上线后,那些让你半夜爬起来的Bug

4.1 “为什么搜‘交期’找不到‘交付周期’?”——采购术语向量化失效

现象 :采购员反馈,搜“交期”返回空,但文档里明明有“交付周期:30天”。

排查过程

  1. 先确认预处理: print(doc.text[:100]) 显示原文是“交付周期:30天”,没问题;
  2. 查向量库: chroma_collection.peek() 看到该文档的 doc_id 存在,但 embedding 字段为空;
  3. 追踪日志:发现 HuggingFaceEmbedding 在处理含中文标点的文本时, tokenizer 报错 IndexError: list index out of range

根因 :BGE-M3模型的tokenizer对全角冒号 (U+FF1A)支持不好,而采购合同常用全角标点。 pymupdf 提取文本时保留了全角符号。

解决方案

# 在文本送入embedding前,做采购级标点清洗
def clean_procurement_punctuation(text: str) -> str:
    # 全角标点转半角
    punctuation_map = {
        ',': ',', '。': '.', '!': '!', '?': '?', ';': ';', ':': ':',
        '“': '"', '”': '"', '‘': "'", '’': "'"
    }
    for full, half in punctuation_map.items():
        text = text.replace(full, half)
    return text

# 应用到embedding
embed_model = HuggingFaceEmbedding(
    model_name=Config.EMBED_MODEL_PATH,
    embed_batch_size=10,
    tokenizer_kwargs={"clean_text": clean_procurement_punctuation}  # 自定义清洗函数
)

经验 :采购文档的标点、空格、换行符比想象中更“脏”。不要信模型文档说的“支持中文”,一定要用真实采购PDF测试。

4.2 “为什么重排序后,旧合同反而排前面?”——时效权重计算错误

现象 :采购员问“当前有效的付款条件”,系统返回了2021年的合同,而2024年的新合同在第5位。

排查过程

  1. source_nodes node.metadata['expiry_date'] 显示2021年合同是 2023-12-31 ,2024年合同是 2025-12-31 ,元数据正确;
  2. 查重排序分数:2021年合同 score=0.92 ,2024年合同 score=0.88 ,确实旧的更高;
  3. 检查权重代码:发现 days_diff 计算用了 datetime.now() - datetime.strptime(...) ,但 strptime 默认时区是UTC,而采购系统用本地时间,导致 days_diff 算成负数,权重乘了 1.5

根因 :时间解析时区混乱。 datetime.strptime("2023-12-31", "%Y-%m-%d") 返回的是naive datetime,与 datetime.now() (local timezone)相减会出错。

解决方案

from datetime import datetime, timezone

def calculate_days_diff(expiry_date_str: str) -> int:
    # 强制转为本地时区
    expiry_dt = datetime.strptime(expiry_date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).astimezone()
    now_dt = datetime.now().astimezone()
    return (now_dt - expiry_dt).days

# 在ProcurementReranker中调用
days_diff = calculate_days_diff(meta["expiry_date"])

经验 :采购系统所有时间字段必须带时区。我们后来强制要求:MySQL的 expiry_date 字段类型为 DATETIME ,插入时用 CONVERT_TZ(NOW(), '+00:00', '+08:00')

4.3 “为什么加了新合同,老查询结果变了?”——Chroma索引未隔离

现象 :上线后,采购员反馈“上周还能查到的合同,今天查不到了”。

排查过程

  1. 查Chroma collection: chroma_collection.count() 从1000涨到1005,新增了5份合同;
  2. 查具体文档: chroma_collection.get(ids=["doc_2023_abc"]) 返回 None
  3. 看日志:发现 index.insert_nodes() 后, chroma_collection.get() 查不到旧文档。

根因 :Chroma的 PersistentClient 在多进程环境下有缓存bug。我们用 uvicorn 启了3个worker,每个worker都创建了自己的 chroma_client 实例,但 PersistentClient 的SQLite连接未加锁,导致索引状态不一致。

解决方案

# 全局单例chroma_client,避免多进程冲突
_chroma_client = None

def get_chroma_client():
    global _chroma_client
    if _chroma_client is None:
        _chroma_client = chromadb.PersistentClient(
            path=Config.VECTOR_DB_DIR,
            settings=Settings(anonymized_telemetry=False)
        )
    return _chroma_client

# 在所有地方用get_chroma_client()获取client
chroma_client = get_chroma_client()

经验 :采购RAG上线前,必须做多进程压力测试。我们用 locust 模拟100并发查询,暴露出这个隐藏Bug。Chroma不是玩具,生产环境必须按数据库标准对待。

4.4 “为什么回答里有乱码?”——LLM Tokenizer与采购文档编码冲突

现象 :回答中出现``符号,如“付款条件:月结天”。

排查过程

  1. 查源文档: node.text 里是“月结60天”,正常;
  2. 查LLM输入: context_str 传给Prompt时, print(repr(context_str)) 显示 '月结60天' ,正常;
  3. 查LLM输出: response.response 里是 '月结\xef\xbf\xbd天' ,``对应UTF-8的 0xEF 0xBF 0xBD ,是解码失败占位符。

根因 :Qwen2-3B模型的tokenizer对某些Unicode字符(如供应商名称里的生僻字)处理异常, generate() 时内部编码出错。

解决方案

# 在LLM生成后,强制UTF-8清理
def clean_llm_output(text: str) -> str:
    # 移除非法UTF-8序列
    try:
        return text.encode('utf-8').decode('utf-8')
    except UnicodeDecodeError:
        # 替换非法字节为
        return text.encode('utf-8', errors='replace').decode('utf-8')

# 应用到响应
response = query_engine.query(question)
cleaned_response = clean_llm_output(response.response)

经验 :采购文档里供应商名称、物料描述常含生僻字、日韩文、特殊符号。LLM的tokenizer不是万能的,必须加一层防御性编码处理。

5. 采购RAG的演进路线:从“能用”到“好用”的三个台阶

5.1 台阶一:采购知识库(Knowledge Base)——解决“找得到”

这是当前90%采购RAG项目的终点,也是我们的起点。核心指标是 采购员首次查询成功率 (First Query Success Rate, FQSR)。我们定义FQSR=采购员第一次提问就得到准确答案的比例。上线首月,FQSR是73%,经过三轮优化(术语标准化、重排序微调、元数据清洗),达到92%。关键动作:

  • 建立采购术语词典,覆盖200+高频采购词汇;
  • 对Chroma索引做定期健康检查: chroma_collection.count() vs MySQL SELECT COUNT(*) FROM procurement_docs ,偏差>1%即告警;
  • 每周人工抽检10个失败Query,归因到预处理/检索/生成环节,迭代优化。

5.2 台阶二:采购工作流引擎(Workflow Engine)——解决“用得顺”

知识库只是信息仓库,工作流引擎才是生产力工具。我们正在开发的采购RAG 2.0,把RAG嵌入采购核心流程:

  • 询价流程 :采购员在ERP里新建询价单,RAG自动填充“历史同类物料最低价”、“推荐供应商清单”、“参考技术协议”;
  • 合同审批 :法务在OA里审批合同时,RAG实时弹出“该条款与集团模板差异点”、“类似供应商历史违约率”;
  • 供应商评估 :财务生成季度报告时,RAG自动抓取“交货准时率”、“质量合格率”、“付款及时率”数据,生成采购分析摘要。

技术实现上,用LlamaIndex的 Tool 机制封装采购API:

from llama_index.core.tools import FunctionTool

def get_supplier_performance(supplier_id: str, period: str = "last_quarter") -> str:
    """获取供应商绩效数据"""
    # 调用采购主数据API
    data = procurement_api.get_performance(supplier_id, period)
    return f"供应商{supplier_id} {period}绩效:交货准时率{data['on_time_rate']}%,质量合格率{data['quality_rate']}%"

performance_tool = FunctionTool.from_defaults(
    fn=get_supplier_performance,
    name="get_supplier_performance",
    description="获取供应商绩效数据,用于合同谈判和评估"
)

当采购员问“供应商SUP-001上季度表现如何?”,RAG自动调用这个Tool,把结构化数据注入回答。这不再是“问答”,而是“决策支持”。

5.3 台阶三:采购数字员工(Digital Employee)

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐