# 如何将包含 Document 对象的字符串转换为 List[Document]?
如何将包含 Document 对象的字符串转换为 List[Document]?
问题背景
在 LangChain 开发中,我们经常需要处理Document对象。但有时候你会拿到一个"看起来像列表、实际上是字符串"的东西——比如从数据库读取、从 API 返回、或从日志中提取的内容:
# 你拿到的可能是这样一个字符串,而不是真正的 listdoc_string="[Document(metadata={'pk': 12, 'page': 2}, page_content='费用报销内容...'), Document(metadata={'pk': 27, 'page': 2}, page_content='...')]"你的目标是把它变成真正的list[Document]。本文介绍3 种方案,从最简单到最健壮,覆盖不同场景。
先认识 Document 对象
在 LangChain 中,Document的结构非常简单:
fromlangchain_core.documentsimportDocument doc=Document(metadata={"pk":12,"page":2},page_content="这是文档的文本内容")它只有两个核心字段:
metadata:字典,存储元数据(主键、页码等)page_content:字符串,存储实际文本内容
当它被str()或repr()转成字符串时,输出格式是Document(metadata={...}, page_content='...')。
方案一:eval() —— 最直接(需注意安全风险)
如果字符串中引用的是真实的Document类,且你的环境中已经导入了该类,最简单的方法就是直接eval():
fromlangchain_core.documentsimportDocument doc_string="[Document(metadata={'pk': 12, 'page': 2}, page_content='...')]"# 直接 evaldocs=eval(doc_string)print(type(docs))# <class 'list'>print(type(docs[0]))# <class 'langchain_core.documents.base.Document'>print(docs[0].metadata)# {'pk': 12, 'page': 2}优点:
- 一行代码搞定,简单粗暴
- 完整还原对象类型
缺点:
eval()会执行字符串中的任意 Python 代码,存在安全风险- 仅适用于完全可信的数据来源
安全提示:如果字符串来自用户输入、网络请求等不可信来源,绝对不要使用 eval()。
方案二:正则 + ast.literal_eval —— 安全且通用
ast.literal_eval是 Python 标准库提供的安全解析函数,它只解析 Python 字面量(字符串、数字、字典、列表等),不会执行任何代码。
但问题是:ast.literal_eval无法直接解析Document(...)这种自定义类的构造调用。所以我们需要分两步:
- 用正则提取每个
Document(...)块中的metadata和page_content - 用
ast.literal_eval安全解析提取出的字面量
importreimportastfromlangchain_core.documentsimportDocumentdefparse_documents_from_string(s:str)->list[Document]:""" 将包含 Document(...) 的字符串解析为 list[Document] 使用正则提取 + ast.literal_eval 安全解析 """results=[]idx=0whileTrue:start=s.find("Document(",idx)ifstart==-1:break# 通过括号深度匹配,找到完整的 Document(...)depth=1i=start+len("Document(")whilei<len(s)anddepth>0:ifs[i]=='(':depth+=1elifs[i]==')':depth-=1i+=1doc_str=s[start:i]# 提取 metadata(字典格式)meta_match=re.search(r"metadata=(\{.*?\})",doc_str,re.DOTALL)# 提取 page_content(引号包裹的字符串,到块结尾)content_match=re.search(r"page_content=(.*?)(?:\)\s*$)",doc_str,re.DOTALL)ifmeta_matchandcontent_match:metadata=ast.literal_eval(meta_match.group(1))page_content_str=content_match.group(1).strip().rstrip(')')page_content=ast.literal_eval(page_content_str)results.append(Document(metadata=metadata,page_content=page_content))idx=ireturnresults核心原理解析
为什么要用"括号深度匹配"?
page_content的值本身可能包含括号,比如page_content='费用报销(凭发票)'。如果用简单的正则Document\(([^)]*)\),遇到第一个)就会提前截断。
括号深度匹配的原理是:遍历字符,遇到(加一,遇到)减一,当深度归零时就找到了完整的闭合位置:
Document(metadata={'pk': 12}, page_content='(USD)') ^ ^ start end (depth=0)为什么用 ast.literal_eval 而不是 eval?
| 特性 | eval() | ast.literal_eval() |
|---|---|---|
| 执行代码 | 是 | 否 |
| 解析字面量 | 是 | 是 |
| 安全性 | 低(可执行任意代码) | 高(仅解析字面量) |
| 适用场景 | 可信数据 | 任意数据 |
使用示例
doc_string="""[Document(metadata={'pk': 12, 'page': 2}, page_content='费用报销内容...'), Document(metadata={'pk': 27, 'page': 2}, page_content='奖惩制度...')]"""docs=parse_documents_from_string(doc_string)fordocindocs:print(f"pk={doc.metadata['pk']}, page={doc.metadata['page']}")print(f"content:{doc.page_content[:50]}...")print()输出:
pk=12, page=2 content: 费用报销内容... pk=27, page=2 content: 奖惩制度...方案三:处理无依赖场景(自定义 Document 类)
如果你的环境中没有安装 LangChain,但你需要解析这种字符串并重建类似结构,可以自定义一个等价的 Document 类:
importreimportastfromdataclassesimportdataclass@dataclassclassDocument:"""模拟 langchain Document 的简化版本"""metadata:dictpage_content:strdef__repr__(self):returnf"Document(metadata={self.metadata}, page_content={repr(self.page_content)})"defparse_documents_from_string(s:str,doc_class=Document)->list:"""解析字符串为 list[Document],支持自定义 Document 类"""results=[]idx=0whileTrue:start=s.find("Document(",idx)ifstart==-1:breakdepth=1i=start+len("Document(")whilei<len(s)anddepth>0:ifs[i]=='(':depth+=1elifs[i]==')':depth-=1i+=1doc_str=s[start:i]meta_match=re.search(r"metadata=(\{.*?\})",doc_str,re.DOTALL)content_match=re.search(r"page_content=(.*?)(?:\)\s*$)",doc_str,re.DOTALL)ifmeta_matchandcontent_match:metadata=ast.literal_eval(meta_match.group(1))page_content_str=content_match.group(1).strip().rstrip(')')page_content=ast.literal_eval(page_content_str)results.append(doc_class(metadata=metadata,page_content=page_content))idx=ireturnresults这个版本通过doc_class参数支持传入任意 Document 类,无论你用的是 LangChain 的 Document 还是自定义的。
方案对比
| 方案 | 安全性 | 健壮性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| eval() | 低 | 高 | 极低 | 数据完全可信,且 Document 类已在作用域中 |
| 正则 + ast.literal_eval | 高 | 高 | 中 | 任意数据来源,生产环境推荐 |
| 自定义类 + 正则 | 高 | 高 | 中 | 无 LangChain 环境,或需要自定义结构 |
实际测试验证
用用户提供的真实数据测试方案二:
raw_string=r"""[Document(metadata={'pk': 12, 'page': 2}, page_content='2. 费用报销:...'), Document(metadata={'pk': 27, 'page': 2}, page_content='2. 费用报销:...'), Document(metadata={'pk': 14, 'page': 3}, page_content='3. 最后警告:...')]"""docs=parse_documents_from_string(raw_string)print(len(docs))# 3测试结果:
- 3 个 Document 全部正确解析
- metadata 中
pk和page字段完整保留 - page_content 中包含
\n换行符、中文、特殊标点均正确还原 - 含括号
(连续三天或月累计五天)的内容也被正确处理
常见陷阱
1. page_content 中包含括号
page_content='金额($100)'错误做法:用简单正则Document\(([^)]*)\)会在$100后的)处提前截断。
正确做法:使用括号深度匹配。
2. page_content 中包含引号
page_content='He said "hello"'ast.literal_eval能正确处理嵌套引号(单引号包裹的字符串中包含双引号),无需特殊处理。
3. 转义字符 \n
字符串中的\n在ast.literal_eval解析后会被正确转换为换行符,不需要手动替换。
完整代码
importreimportastfromlangchain_core.documentsimportDocumentdefparse_documents_from_string(s:str)->list[Document]:""" 将包含 Document(...) 的字符串安全地解析为 list[Document]。 Args: s: 包含 Document(...) 表示的字符串 Returns: list[Document]: 解析后的 Document 对象列表 """results=[]idx=0whileTrue:start=s.find("Document(",idx)ifstart==-1:break# 括号深度匹配,处理 page_content 中含括号的情况depth=1i=start+len("Document(")whilei<len(s)anddepth>0:ifs[i]=='(':depth+=1elifs[i]==')':depth-=1i+=1doc_str=s[start:i]meta_match=re.search(r"metadata=(\{.*?\})",doc_str,re.DOTALL)content_match=re.search(r"page_content=(.*?)(?:\)\s*$)",doc_str,re.DOTALL)ifmeta_matchandcontent_match:metadata=ast.literal_eval(meta_match.group(1))page_content_str=content_match.group(1).strip().rstrip(')')page_content=ast.literal_eval(page_content_str)results.append(Document(metadata=metadata,page_content=page_content))idx=ireturnresults# ===== 使用示例 =====if__name__=="__main__":doc_string="[Document(metadata={'pk': 12, 'page': 2}, page_content='示例内容')]"docs=parse_documents_from_string(doc_string)print(f"解析到{len(docs)}个文档")print(docs[0].metadata)# {'pk': 12, 'page': 2}print(docs[0].page_content)# 示例内容总结
| 需求 | 推荐方案 |
|---|---|
| 快速验证、数据完全可信 | eval() |
| 生产环境、数据来源不可控 | 正则 +ast.literal_eval |
| 无 LangChain 依赖 | 自定义 Document 类 + 正则 |
核心原则:能用ast.literal_eval就不用eval,安全第一。
