采购RAG系统从零到一实战:LlamaIndex+Chroma构建语义外挂
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_minutessupplier_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天”。
排查过程 :
- 先确认预处理:
print(doc.text[:100])显示原文是“交付周期:30天”,没问题; - 查向量库:
chroma_collection.peek()看到该文档的doc_id存在,但embedding字段为空; - 追踪日志:发现
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位。
排查过程 :
- 查
source_nodes:node.metadata['expiry_date']显示2021年合同是2023-12-31,2024年合同是2025-12-31,元数据正确; - 查重排序分数:2021年合同
score=0.92,2024年合同score=0.88,确实旧的更高; - 检查权重代码:发现
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索引未隔离
现象 :上线后,采购员反馈“上周还能查到的合同,今天查不到了”。
排查过程 :
- 查Chroma collection:
chroma_collection.count()从1000涨到1005,新增了5份合同; - 查具体文档:
chroma_collection.get(ids=["doc_2023_abc"])返回None; - 看日志:发现
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与采购文档编码冲突
现象 :回答中出现``符号,如“付款条件:月结天”。
排查过程 :
- 查源文档:
node.text里是“月结60天”,正常; - 查LLM输入:
context_str传给Prompt时,print(repr(context_str))显示'月结60天',正常; - 查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 MySQLSELECT 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)
更多推荐

所有评论(0)