当前位置:Java -> 如何利用RAG技术提升Spring AI和OpenAI GPT在您的文件中的应用价值
这个 AIDocumentLibraryChat 项目使用了 Spring AI 项目和 OpenAI 来在文档库中搜索问题的答案。为了实现这一目的,该项目使用了检索增强生成技术对文档进行处理。
该过程如下:
该过程如下:
搜索文档:
上传的文档存储在数据库中,以便有答案的源文档。文档文本必须拆分成块以创建每个块的嵌入。这些嵌入是由OpenAI的嵌入模型创建的,是用超过1500维表示文本块的矢量。嵌入存储在具有块文本和源文档ID的AI文档中,存储在向量数据库中。
文档搜索将搜索提示使用Open AI嵌入模型转换为嵌入。该嵌入用于在向量数据库中搜索最近邻的向量。这意味着搜索提示和具有最大相似性的文本块的嵌入。AIDocument中的ID用于读取关系数据库中的文档。使用搜索提示和AIDocument的文本块创建文档提示。然后,调用OpenAI GPT模型以创建基于搜索提示和文档上下文的答案。这使模型基于提供的文档创建紧密相关的答案,提高了准确性。GPT模型的答案被返回并与文档的链接一起显示,以提供答案的来源。
该项目的架构围绕着使用Spring Boot和Spring AI构建。Angular UI提供用户界面以展示文档列表,上传文档,并提供具有答案和源文档的搜索提示。它通过rest接口与Spring Boot后端通信。Spring Boot后端为前端提供rest控制器,使用Spring AI与OpenAI模型和Postgresql向量数据库通信。文档使用Jpa存储在Postgresql关系数据库中。选择使用Postgresql数据库是因为它将关系数据库和向量数据库结合到一个Docker镜像中。
前端基于使用Angular构建的惰性加载独立组件。惰性加载的独立组件在 app.config.ts 中配置:
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideAnimations(), provideHttpClient()]
};
配置设置路由并启用http客户端和动画。
惰性加载路由在 app.routes.ts 中定义:
export const routes: Routes = [
{
path: "doclist",
loadChildren: () => import("./doc-list").then((mod) => mod.DOCLIST),
},
{
path: "docsearch",
loadChildren: () => import("./doc-search").then((mod) => mod.DOCSEARCH),
},
{ path: "**", redirectTo: "doclist" },
];
在'loadChildren'中,'import("...").then((mod) => mod.XXX)'从提供的路径惰性加载路由,并设置 'mod.XXX' 中定义的导出路由。
惰性加载路由'docsearch'具有 index.ts 来导出常量:
export * from "./doc-search.routes";
这导出了 doc-search.routes.ts:
export const DOCSEARCH: Routes = [
{
path: "",
component: DocSearchComponent,
},
{ path: "**", redirectTo: "" },
];
它定义了路由到'DocSearchComponent'。
文件上传可以在DocImportComponent中的模板doc-import.component.html中找到:
<h1 mat-dialog-title i18n="@@docimportImportFile">Import file</h1>
<div mat-dialog-content>
<p i18n="@@docimportFileToImport">File to import</p>
@if(uploading) {
<div class="upload-spinner"><mat-spinner></mat-spinner></div>
} @else {
<input type="file" (change)="onFileInputChange($event)">
}
@if(!!file) {
<div>
<ul>
<li>Name: {{file.name}}</li>
<li>Type: {{file.type}}</li>
<li>Size: {{file.size}} bytes</li>
</ul>
</div>
}
</div>
<div mat-dialog-actions>
<button mat-button (click)="cancel()" i18n="@@cancel">Cancel</button>
<button mat-flat-button color="primary" [disabled]="!file || uploading"
(click)="upload()" i18n="@@docimportUpload">Upload</button>
</div>
文件上传使用''标签。它提供了上传功能,并在每次上传后调用'onFileInputChange(...)'方法。
'上传'按钮在点击时调用'upload()'方法将文件发送到服务器。
doc-import.component.ts中包含了模板的方法:
@Component({
selector: 'app-docimport',
standalone: true,
imports: [CommonModule,MatFormFieldModule, MatDialogModule,MatButtonModule, MatInputModule, FormsModule, MatProgressSpinnerModule],
templateUrl: './doc-import.component.html',
styleUrls: ['./doc-import.component.scss']
})
export class DocImportComponent {
protected file: File | null = null;
protected uploading = false;
private destroyRef = inject(DestroyRef);
constructor(private dialogRef: MatDialogRef<DocImportComponent>,
@Inject(MAT_DIALOG_DATA) public data: DocImportComponent,
private documentService: DocumentService) { }
protected onFileInputChange($event: Event): void {
const files = !$event.target ? null :
($event.target as HTMLInputElement).files;
this.file = !!files && files.length > 0 ?
files[0] : null;
}
protected upload(): void {
if(!!this.file) {
const formData = new FormData();
formData.append('file', this.file as Blob, this.file.name as string);
this.documentService.postDocumentForm(formData)
.pipe(tap(() => {this.uploading = true;}),
takeUntilDestroyed(this.destroyRef))
.subscribe(result => {this.uploading = false;
this.dialogRef.close();});
}
}
protected cancel(): void {
this.dialogRef.close();
}
}
这是一个独立的组件,具有其模块导入和注入的'DestroyRef'
'onFileInputChange(...)'方法接受事件参数,并将其'files'属性存储在'files'常量中。然后它检查第一个文件,并将其存储在'file'组件属性中。
'upload()'方法检查'file'属性,并创建用于文件上传的'FormData()'。'formData'常量具有数据类型('file')、内容('this.file')和附加的文件名('this.file.name')。然后使用'documentService'将'FormData()'对象发送到服务器。'takeUntilDestroyed(this.destroyRef)'函数在组件销毁后取消订阅Rxjs管道。这使得在Angular中取消订阅管道非常方便。
后端是一个使用Spring AI框架的Spring Boot应用程序。Spring AI管理对OpenAI模型和Vector数据库请求的请求。
数据库设置使用Liquibase完成,脚本可以在db.changelog-1.xml中找到:
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
<changeSet id="1" author="angular2guy">
<sql>CREATE EXTENSION if not exists hstore;</sql>
</changeSet>
<changeSet id="2" author="angular2guy">
<sql>CREATE EXTENSION if not exists vector;</sql>
</changeSet>
<changeSet id="3" author="angular2guy">
<sql>CREATE EXTENSION if not exists "uuid-ossp";</sql>
</changeSet>
<changeSet author="angular2guy" id="4">
<createTable tableName="document">
<column name="id" type="bigint">
<constraints primaryKey="true"/>
</column>
<column name="document_name" type="varchar(255)">
<constraints notNullConstraintName="document_document_name_notnull"
nullable="false"/>
</column>
<column name="document_type" type="varchar(25)">
<constraints notNullConstraintName="document_document_type_notnull"
nullable="false"/>
</column>
<column name="document_content" type="blob"/>
</createTable>
</changeSet>
<changeSet author="angular2guy" id="5">
<createSequence sequenceName="document_seq" incrementBy="50"
startValue="1000" />
</changeSet>
<changeSet id="6" author="angular2guy">
<createTable tableName="vector_store">
<column name="id" type="uuid"
defaultValueComputed="uuid_generate_v4 ()">
<constraints primaryKey="true"/>
</column>
<column name="content" type="text"/>
<column name="metadata" type="json"/>
<column name="embedding" type="vector(1536)">
<constraints notNullConstraintName=
"vectorstore_embedding_type_notnull" nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet id="7" author="angular2guy">
<sql>CREATE INDEX vectorstore_embedding_index ON vector_store
USING HNSW (embedding vector_cosine_ops);</sql>
</changeSet>
</databaseChangeLog>
在changeset 4中,创建了Jpa文档实体的表,主键为'id'。内容类型/大小未知,因此设置为'blob'。在changeset 5中,创建了Jpa实体的序列,它具有Spring Boot 3.x使用的Hibernate 6序列的默认属性。
在changeset 6中,创建了表'vector_store',其主键为'type'类型的'uuid',由'uuid-ossp'扩展创建。'content'列的类型为'text'(其他数据库中为'clob'),以具有灵活的大小。'metadata'列使用'json'类型存储AIDocuments的元数据。'embedding'列存储具有OpenAI维度的嵌入向量。
在changeset 7中,设置了用于'embeddings'列的快速搜索的索引。由于Liquibase的限定参数,使用'<sql>'直接创建它。
前端的DocumentController如下:
@RestController
@RequestMapping("rest/document")
public class DocumentController {
private final DocumentMapper documentMapper;
private final DocumentService documentService;
public DocumentController(DocumentMapper documentMapper,
DocumentService documentService) {
this.documentMapper = documentMapper;
this.documentService = documentService;
}
@PostMapping("/upload")
public long handleDocumentUpload(
@RequestParam("file") MultipartFile document) {
var docSize = this.documentService
.storeDocument(this.documentMapper.toEntity(document));
return docSize;
}
@GetMapping("/list")
public List<DocumentDto> getDocumentList() {
return this.documentService.getDocumentList().stream()
.flatMap(myDocument ->Stream.of(this.documentMapper.toDto(myDocument)))
.flatMap(myDocument -> {
myDocument.setDocumentContent(null);
return Stream.of(myDocument);
}).toList();
}
@GetMapping("/doc/{id}")
public ResponseEntity<DocumentDto> getDocument(
@PathVariable("id") Long id) {
return ResponseEntity.ofNullable(this.documentService
.getDocumentById(id).stream().map(this.documentMapper::toDto)
.findFirst().orElse(null));
}
@GetMapping("/content/{id}")
public ResponseEntity<byte[]> getDocumentContent(
@PathVariable("id") Long id) {
var resultOpt = this.documentService.getDocumentById(id).stream()
.map(this.documentMapper::toDto).findFirst();
var result = resultOpt.stream().map(this::toResultEntity)
.findFirst().orElse(ResponseEntity.notFound().build());
return result;
}
private ResponseEntity<byte[]> toResultEntity(DocumentDto documentDto) {
var contentType = switch (documentDto.getDocumentType()) {
case DocumentType.PDF -> MediaType.APPLICATION_PDF;
case DocumentType.HTML -> MediaType.TEXT_HTML;
case DocumentType.TEXT -> MediaType.TEXT_PLAIN;
case DocumentType.XML -> MediaType.APPLICATION_XML;
default -> MediaType.ALL;
};
return ResponseEntity.ok().contentType(contentType)
.body(documentDto.getDocumentContent());
}
@PostMapping("/search")
public DocumentSearchDto postDocumentSearch(@RequestBody
SearchDto searchDto) {
var result = this.documentMapper
.toDto(this.documentService.queryDocuments(searchDto));
return result;
}
}
'handleDocumentUpload(...)'负责'/rest/document/upload'路径处的'documentService'处理上传的文件。
'getDocumentList()'处理对文档列表的获取请求,并移除文档内容以减少响应大小。
'getDocumentContent(...)'处理获取文档内容的请求。它使用'documentService'加载文档,并将'DocumentType'映射到'MediaType'。然后返回内容和内容类型,浏览器根据内容类型打开内容。
'postDocumentSearch(...)'将请求内容放入'SearchDto'对象中,并返回'documentService.queryDocuments(...)'调用的AI生成结果。
DocumentService的'storeDocument(...)'方法如下:
public Long storeDocument(Document document) {
var myDocument = this.documentRepository.save(document);
Resource resource = new ByteArrayResource(document.getDocumentContent());
var tikaDocuments = new TikaDocumentReader(resource).get();
record TikaDocumentAndContent(org.springframework.ai.document.Document
document, String content) { }
var aiDocuments = tikaDocuments.stream()
.flatMap(myDocument1 -> this.splitStringToTokenLimit(
myDocument1.getContent(), CHUNK_TOKEN_LIMIT)
.stream().map(myStr -> new TikaDocumentAndContent(myDocument1, myStr)))
.map(myTikaRecord -> new org.springframework.ai.document.Document(
myTikaRecord.content(), myTikaRecord.document().getMetadata()))
.peek(myDocument1 -> myDocument1.getMetadata()
.put(ID, myDocument.getId().toString())).toList();
LOGGER.info("Name: {}, size: {}, chunks: {}", document.getDocumentName(),
document.getDocumentContent().length, aiDocuments.size());
this.documentVsRepository.add(aiDocuments);
return Optional.ofNullable(myDocument.getDocumentContent()).stream()
.map(myContent -> Integer.valueOf(myContent.length).longValue())
.findFirst().orElse(0L);
}
private List<String> splitStringToTokenLimit(String documentStr,
int tokenLimit) {
List<String> splitStrings = new ArrayList<>();
var tokens = new StringTokenizer(documentStr).countTokens();
var chunks = Math.ceilDiv(tokens, tokenLimit);
if (chunks == 0) {
return splitStrings;
}
var chunkSize = Math.ceilDiv(documentStr.length(), chunks);
var myDocumentStr = new String(documentStr);
while (!myDocumentStr.isBlank()) {
splitStrings.add(myDocumentStr.length() > chunkSize ?
myDocumentStr.substring(0, chunkSize) : myDocumentStr);
myDocumentStr = myDocumentStr.length() > chunkSize ?
myDocumentStr.substring(chunkSize) : "";
}
return splitStrings;
}
'storeDocument(...)'方法将文档保存到关系数据库中。然后,文档被转换为'ByteArrayResource'并通过Spring AI的'TikaDocumentReader'读取,转换为AIDocument列表。然后AIDocument列表被扁平映射以将文档拆分为具有'Metadata'映射中存储的存储文档的'id'的'chunk'。映射到新的带有存储文档'id'的'AIDocument'。'Metadata'中的'id'使得能够为AIDocuments加载匹配的文档实体。然后通过调用'documentVsRepository.add(...)'方法隐式创建AIDocuments的嵌入,该方法调用OpenAI Embedding模型并将带有嵌入的AIDocuments存储在向量数据库中。然后返回结果。
'queryDocument(...)'方法如下:
public AiResult queryDocuments(SearchDto searchDto) {
var similarDocuments = this.documentVsRepository
.retrieve(searchDto.getSearchString());
var mostSimilar = similarDocuments.stream()
.sorted((myDocA, myDocB) -> ((Float) myDocA.getMetadata().get(DISTANCE))
.compareTo(((Float) myDocB.getMetadata().get(DISTANCE)))).findFirst();
var documentChunks = mostSimilar.stream().flatMap(mySimilar ->
similarDocuments.stream().filter(mySimilar1 ->
mySimilar1.getMetadata().get(ID).equals(
mySimilar.getMetadata().get(ID)))).toList();
Message systemMessage = switch (searchDto.getSearchType()) {
case SearchDto.SearchType.DOCUMENT -> this.getSystemMessage(
documentChunks, (documentChunks.size() <= 0 ? 2000
: Math.floorDiv(2000, documentChunks.size())));
case SearchDto.SearchType.PARAGRAPH ->
this.getSystemMessage(mostSimilar.stream().toList(), 2000);
};
UserMessage userMessage = new UserMessage(searchDto.getSearchString());
Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
LocalDateTime start = LocalDateTime.now();
AiResponse response = aiClient.generate(prompt);
LOGGER.info("AI response time: {}ms",
ZonedDateTime.of(LocalDateTime.now(),
ZoneId.systemDefault()).toInstant().toEpochMilli()
- ZonedDateTime.of(start, ZoneId.systemDefault()).toInstant()
.toEpochMilli());
var documents = mostSimilar.stream().map(myGen ->
myGen.getMetadata().get(ID)).filter(myId ->
Optional.ofNullable(myId).stream().allMatch(myId1 ->
(myId1 instanceof String))).map(myId ->
Long.parseLong(((String) myId)))
.map(this.documentRepository::findById)
.filter(Optional::isPresent)
.map(Optional::get).toList();
return new AiResult(searchDto.getSearchString(),
response.getGenerations(), documents);
}
private Message getSystemMessage(
List<org.springframework.ai.document.Document> similarDocuments,
int tokenLimit) {
String documents = similarDocuments.stream()
.map(entry -> entry.getContent())
.filter(myStr -> myStr != null && !myStr.isBlank())
.map(myStr -> this.cutStringToTokenLimit(myStr, tokenLimit))
.collect(Collectors.joining("\n"));
SystemPromptTemplate systemPromptTemplate =
new SystemPromptTemplate(this.systemPrompt);
Message systemMessage = systemPromptTemplate
.createMessage(Map.of("documents", documents));
return systemMessage;
}
private String cutStringToTokenLimit(String documentStr, int tokenLimit) {
String cutString = new String(documentStr);
while (tokenLimit < new StringTokenizer(cutString, " -.;,").countTokens()){
cutString = cutString.length() > 1000 ?
cutString.substring(0, cutString.length() - 1000) : "";
}
return cutString;
}
该方法首先从向量数据库中加载最匹配“searchDto.getSearchString()”的文档。为了实现这一点,调用OpenAI Embedding模型将搜索字符串转换为嵌入,并使用该嵌入查询向量数据库,获取与搜索嵌入和数据库嵌入之间距离最小的AIDocuments。然后将距离最小的AIDocument存储在“mostSimilar”变量中。然后通过匹配它们的元数据'id'的文档实体id来收集文档块的所有AIDocuments。使用“documentChunks”或“mostSimilar”内容创建“systemMessage”。 “getSystemMessage(...)”方法获取它们并将contentChunks分割为OpenAI GPT模型可以处理的大小,并返回“Message”。然后将“systemMessage”和“userMessage”转换为使用“aiClient.generate(prompt)”发送到OpenAi GPT模型的“prompt”。之后,AI回答可用,同时根据'mostSimilar' AIDocument的元数据id加载文档实体。使用搜索字符串,GPT答案,文档实体创建'AiResult'并返回。
Spring AI是Spring团队的一个非常好的框架。在试验版本中没有出现任何问题。
借助Spring AI,现在很容易在我们自己的文档上使用大型语言模型。
矢量数据库存储库DocumentVsRepositoryBean与Spring AI“VectorStore”如下:
@Repository
public class DocumentVSRepositoryBean implements DocumentVsRepository {
private final VectorStore vectorStore;
public DocumentVSRepositoryBean(JdbcTemplate jdbcTemplate,
EmbeddingClient embeddingClient) {
this.vectorStore = new PgVectorStore(jdbcTemplate, embeddingClient);
}
public void add(List<Document> documents) {
this.vectorStore.add(documents);
}
public List<Document> retrieve(String query, int k, double threshold) {
return new VectorStoreRetriever(vectorStore, k,
threshold).retrieve(query);
}
public List<Document> retrieve(String query) {
return new VectorStoreRetriever(vectorStore).retrieve(query);
}
}
存储库具有“vectorStore”属性,用于访问向量数据库。它在构造函数中使用注入参数调用“new PgVectorStore(...)”创建。PgVectorStore类作为Postgresql向量数据库扩展提供。它具有“embeddingClient”用于使用OpenAI嵌入模型和“jdbcTemplate”用于访问数据库。
方法“add(...)”调用OpenAI嵌入模型,并将AIDocuments添加到向量数据库。
方法“retrieve(...)”查询向量数据库以获取最短距离的嵌入。
Angular使得前端的创建变得容易。独立的模块配合延迟加载使得初始加载变得更小。Angular Material组件在实现中也提供了很大的帮助,并且使用起来很容易。
Spring Boot与Spring AI使得使用大型语言模型变得容易。Spring AI提供了框架来隐藏嵌入的创建,并提供了易于使用的接口来存储AIDocuments在向量数据库中(支持多个)。为搜索提示创建嵌入也由Spring AI进行,并且向量数据库的接口也很简单。Spring AI的prompt类也使得创建OpenAI GPT模型的提示变得容易。通过注入“aiClient”调用模型,并返回结果。
推荐阅读: 阿里巴巴面经(11)
本文链接: 如何利用RAG技术提升Spring AI和OpenAI GPT在您的文件中的应用价值